{"id":46282162,"url":"https://github.com/rajsinghtech/tailvoy","last_synced_at":"2026-03-07T03:05:37.815Z","repository":{"id":341963176,"uuid":"1172218678","full_name":"rajsinghtech/tailvoy","owner":"rajsinghtech","description":"tsnet + envoy","archived":false,"fork":false,"pushed_at":"2026-03-04T09:32:06.000Z","size":207,"stargazers_count":0,"open_issues_count":2,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-03-04T09:32:51.000Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"","language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/rajsinghtech.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-03-04T04:06:33.000Z","updated_at":"2026-03-04T09:32:09.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/rajsinghtech/tailvoy","commit_stats":null,"previous_names":["rajsinghtech/tailvoy"],"tags_count":8,"template":false,"template_full_name":null,"purl":"pkg:github/rajsinghtech/tailvoy","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rajsinghtech%2Ftailvoy","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rajsinghtech%2Ftailvoy/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rajsinghtech%2Ftailvoy/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rajsinghtech%2Ftailvoy/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/rajsinghtech","download_url":"https://codeload.github.com/rajsinghtech/tailvoy/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rajsinghtech%2Ftailvoy/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":30206350,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-03-06T19:07:06.838Z","status":"online","status_checked_at":"2026-03-07T02:00:06.765Z","response_time":53,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-03-04T06:07:36.486Z","updated_at":"2026-03-07T03:05:37.807Z","avatar_url":"https://github.com/rajsinghtech.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# tailvoy\n\nTailscale identity-aware proxy for Envoy. tailvoy joins your tailnet, identifies callers via WhoIs, and enforces access policy using [Tailscale peer capabilities](https://tailscale.com/kb/1324/acl-grants#app-capabilities) before traffic reaches your backend.\n\n```\nTailnet Client (100.x.x.x)\n        |\n   tsnet Listener -- L4 check (listener + hostname match?)\n        |\n   TCP/UDP Proxy -- PROXY protocol v2 (preserves client IP)\n        |\n      Envoy -- L7 check via gRPC ext_authz (listener + hostname + path match?)\n        |\n   Backend Service\n```\n\n## Quickstart\n\n```sh\ndocker pull ghcr.io/rajsinghtech/tailvoy:latest\n```\n\nCreate `config.yaml`:\n\n```yaml\ntailscale:\n  serviceMappings:\n    web: [http]\n  tags: [\"tag:my-gw\"]\n  serviceTags: [\"tag:my-gw\"]\n\nlisteners:\n  http:\n    port: 80\n    protocol: http\n    routes:\n      - backend: 127.0.0.1:8080\n```\n\nRun:\n\n```sh\ndocker run \\\n  -e TS_CLIENT_ID=... \\\n  -e TS_CLIENT_SECRET=... \\\n  -v $(pwd)/config.yaml:/config.yaml \\\n  ghcr.io/rajsinghtech/tailvoy:latest \\\n  -config /config.yaml\n```\n\ntailvoy connects to the tailnet, creates VIP services per service mapping, generates Envoy config, and starts proxying. Authorization is controlled entirely by your Tailscale ACL -- the config file defines infrastructure and service identity.\n\n## How it works\n\ntailvoy embeds [tsnet](https://pkg.go.dev/tailscale.com/tsnet) to join the tailnet as an ephemeral OAuth node. It uses [Tailscale Services](https://tailscale.com/docs/features/tailscale-services) (`tsnet.ListenService`) with per-service VIPs so each service mapping gets its own stable address and multiple replicas can serve it. Every connection triggers a WhoIs lookup to resolve the caller's identity and peer capabilities.\n\nAuthorization uses the `rajsingh.info/cap/tailvoy` capability with three dimensions:\n\n| Dimension | Controls | Source |\n|-----------|----------|--------|\n| `listeners` | Which listeners a peer can reach | Listener name from config or Envoy |\n| `routes` | Which paths are accessible (L7 only) | Request path |\n| `hostnames` | Which hostnames are allowed | TLS SNI / HTTP Host header |\n\n**Within a rule**: AND -- all specified dimensions must match.\n**Across rules**: OR -- any matching rule grants access.\n**Omitted dimension**: unrestricted.\n\n## Deployment modes\n\n### Standalone (default)\n\ntailvoy auto-generates Envoy bootstrap config from your listener definitions and manages Envoy as a subprocess. No Envoy YAML needed. Best when you want explicit control over listeners.\n\n```sh\ntailvoy -config config.yaml\n```\n\n### Envoy Gateway data plane\n\ntailvoy replaces the default Envoy image via the `EnvoyProxy` CRD, acting as the data plane for [Envoy Gateway](https://gateway.envoyproxy.io/). EG manages routing via xDS while tailvoy handles Tailscale ingress and policy. Uses discovery mode to auto-create listeners as Gateway resources change.\n\n```yaml\napiVersion: gateway.envoyproxy.io/v1alpha1\nkind: EnvoyProxy\nmetadata:\n  name: tailvoy-proxy\n  namespace: envoy-gateway-system\nspec:\n  provider:\n    type: Kubernetes\n    kubernetes:\n      envoyDeployment:\n        container:\n          image: \"ghcr.io/rajsinghtech/tailvoy:latest\"\n        patch:\n          type: StrategicMerge\n          value:\n            spec:\n              template:\n                spec:\n                  containers:\n                    - name: envoy\n                      command: [\"tailvoy\", \"--config\", \"/etc/tailvoy/config.yaml\",\n                                \"--authz-addr\", \"0.0.0.0:9001\", \"--\"]\n                      env:\n                        - name: TS_CLIENT_ID\n                          valueFrom:\n                            secretKeyRef:\n                              name: tailvoy-oauth\n                              key: TS_CLIENT_ID\n                        - name: TS_CLIENT_SECRET\n                          valueFrom:\n                            secretKeyRef:\n                              name: tailvoy-oauth\n                              key: TS_CLIENT_SECRET\n                      volumeMounts:\n                        - name: tailvoy-policy\n                          mountPath: /etc/tailvoy\n                          readOnly: true\n                  volumes:\n                    - name: tailvoy-policy\n                      configMap:\n                        name: tailvoy-policy\n```\n\nEG's generated Envoy args are appended after `--` automatically. Requires Envoy Gateway v1.7.0+.\n\nFor L7 listeners, apply a SecurityPolicy with gRPC ext_authz pointing at tailvoy's authz server:\n\n```yaml\napiVersion: gateway.envoyproxy.io/v1alpha1\nkind: SecurityPolicy\nmetadata:\n  name: tailvoy-authz\nspec:\n  targetRefs:\n    - group: gateway.networking.k8s.io\n      kind: HTTPRoute\n      name: my-route\n  extAuth:\n    grpc:\n      backendRefs:\n        - name: tailvoy-authz\n          namespace: envoy-gateway-system\n          port: 9001\n```\n\n## Configuration\n\n### Tailscale\n\n```yaml\ntailscale:\n  serviceMappings:            # map of service name -\u003e listener names\n    web: [http, https]        # svc:web serves ports 80 + 443\n    postgres: [db]            # svc:postgres serves port 5432\n  tags: [\"tag:my-gw\"]        # ACL tags for the tsnet node\n  serviceTags: [\"tag:my-gw\"] # ACL tags for VIP services\n  hostname: tailvoy-proxy    # optional: tsnet node hostname (default: tailvoy-proxy)\n```\n\nCredentials are read from `TS_CLIENT_ID` and `TS_CLIENT_SECRET` environment variables. Your ACL must include:\n\n```jsonc\n{\n    \"tagOwners\": { \"tag:my-gw\": [\"autogroup:admin\"] },\n    \"autoApprovers\": {\n        \"services\": {\n            \"svc:web\": [\"tag:my-gw\"],\n            \"svc:postgres\": [\"tag:my-gw\"]\n        }\n    }\n}\n```\n\n### Listeners\n\nListeners are a named map. The key is the listener name used in ACL cap rules.\n\n| Protocol | Behavior | Config |\n|----------|----------|--------|\n| `http` | L7 via Envoy with ext_authz | `routes` required |\n| `https` | L7 via Envoy, TLS terminated | `routes` + `tls` required |\n| `grpc` | L7 via Envoy with ext_authz | `routes` required, optional `tls` |\n| `tls` | Passthrough, SNI-based routing | `routes` with `hostname` + `backend` |\n| `tcp` | Plain TCP forwarding | `backend` required |\n| `udp` | UDP forwarding (no VIP support) | `backend` required |\n\nL7 protocols (`http`, `https`, `grpc`) route through Envoy for path-level policy. L4 protocols (`tls`, `tcp`, `udp`) are handled directly by tailvoy.\n\n```yaml\nlisteners:\n  # L7: HTTP with hostname-based virtual hosting and path routing\n  http:\n    port: 80\n    protocol: http\n    routes:\n      - hostname: app.example.com\n        backend: frontend:3000\n      - hostname: api.example.com\n        paths:\n          /v1/*: api-v1:8080\n          /v2/*: api-v2:8080\n      - backend: fallback:8080          # catch-all\n\n  # L7: gRPC with TLS termination\n  grpc:\n    port: 50051\n    protocol: grpc\n    tls:\n      cert: /certs/cert.pem\n      key: /certs/key.pem\n    routes:\n      - backend: grpc-backend:50051\n\n  # L4: TLS passthrough with SNI routing\n  tls:\n    port: 443\n    protocol: tls\n    routes:\n      - hostname: app.example.com\n        backend: app:8443\n      - hostname: admin.example.com\n        backend: admin:8443\n\n  # L4: plain TCP\n  postgres:\n    port: 5432\n    protocol: tcp\n    backend: db:5432\n\n  # L4: UDP (node IP only, VIP services don't support UDP)\n  dns:\n    port: 53\n    protocol: udp\n    backend: dns:1053\n```\n\n### Discovery mode\n\nInstead of static listeners, tailvoy can poll Envoy's admin API to auto-discover listeners. Mutually exclusive with `listeners`.\n\n```yaml\ntailscale:\n  serviceMappings:\n    http: [\"default/eg/http\"]\n    tcp: [\"default/eg/tcp\"]\n  tags: [\"tag:my-gw\"]\n  serviceTags: [\"tag:my-gw\"]\n\ndiscovery:\n  envoyAdmin: \"http://127.0.0.1:19000\"\n  envoyAddress: \"127.0.0.1\"\n  pollInterval: \"5s\"\n  proxyProtocol: v2\n  listenerFilter: \"default/eg/.*\"      # optional: regex to include only matching names\n```\n\nIn discovery mode, `serviceMappings` maps Envoy Gateway listener names (format: `\u003cnamespace\u003e/\u003cgateway\u003e/\u003clistener\u003e`) to VIP service names. Discovered listeners not in any mapping are skipped with a warning.\n\nDiscovered listener names follow Envoy Gateway convention: `\u003cnamespace\u003e/\u003cgateway\u003e/\u003clistener\u003e`. Use these names in ACL grants and SecurityPolicy `contextExtensions`:\n\n```yaml\ncontextExtensions:\n  - name: listener\n    type: Value\n    value: \"default/eg/http\"\n```\n\n## Authorization\n\nAll authorization lives in your Tailscale ACL using `rajsingh.info/cap/tailvoy` grants. The config file never defines who can access what.\n\n### Cap rule schema\n\n```jsonc\n\"rajsingh.info/cap/tailvoy\": [\n    {\n        \"listeners\": [\"http\", \"grpc\"],       // optional: which listeners\n        \"routes\": [\"/api/*\", \"/health\"],      // optional: which paths (L7 only)\n        \"hostnames\": [\"app.example.com\"]      // optional: which hostnames\n    }\n]\n```\n\nAn empty rule `[{}]` grants unrestricted access.\n\n### Route patterns\n\n| Pattern | Matches | Does not match |\n|---------|---------|----------------|\n| `/*` | All paths | -- |\n| `/api/*` | `/api/users`, `/api/v1/foo` | `/apiv2` |\n| `/health` | Exactly `/health` | `/health/`, `/healthz` |\n| `/grpc.health.v1.Health/*` | `/grpc.health.v1.Health/Check` | `/grpc.other.Service/Method` |\n\n### Hostname patterns\n\n| Pattern | Matches | Does not match |\n|---------|---------|----------------|\n| `app.example.com` | Exactly `app.example.com` | `other.example.com` |\n| `*.example.com` | `app.example.com`, `sub.app.example.com` | `example.com` |\n\n### Example: multi-team gateway\n\nA single tailvoy instance with scoped access per team:\n\n```yaml\ntailscale:\n  serviceMappings:\n    http: [http]\n    grpc: [grpc]\n    postgres: [postgres]\n    tls: [tls]\n  tags: [\"tag:my-gw\"]\n  serviceTags: [\"tag:my-gw\"]\n\nlisteners:\n  http:\n    port: 80\n    protocol: http\n    routes:\n      - backend: web:8080\n  grpc:\n    port: 50051\n    protocol: grpc\n    routes:\n      - backend: grpc:50051\n  postgres:\n    port: 5432\n    protocol: tcp\n    backend: db:5432\n  tls:\n    port: 443\n    protocol: tls\n    routes:\n      - hostname: app.example.com\n        backend: app:8443\n```\n\n```jsonc\n{\n    \"grants\": [\n        {\n            // Frontend: HTTP + gRPC health only\n            \"src\": [\"tag:frontend\"], \"dst\": [\"svc:http\", \"svc:grpc\"],\n            \"app\": { \"rajsingh.info/cap/tailvoy\": [{\n                \"routes\": [\"/api/*\", \"/grpc.health.v1.Health/*\"]\n            }]}\n        },\n        {\n            // DBA: postgres only\n            \"src\": [\"tag:dba\"], \"dst\": [\"svc:postgres\"]\n        },\n        {\n            // Engineers: TLS to app.example.com only\n            \"src\": [\"group:engineers\"], \"dst\": [\"svc:tls\"],\n            \"app\": { \"rajsingh.info/cap/tailvoy\": [{\n                \"hostnames\": [\"app.example.com\"]\n            }]}\n        },\n        {\n            // Ops: all services\n            \"src\": [\"group:ops\"], \"dst\": [\"svc:http\", \"svc:grpc\", \"svc:postgres\", \"svc:tls\"],\n            \"app\": { \"rajsingh.info/cap/tailvoy\": [{}] }\n        }\n    ]\n}\n```\n\n| Caller | HTTP `/api/users` | gRPC `Health/Check` | postgres | TLS `app.example.com` |\n|--------|:---:|:---:|:---:|:---:|\n| tag:frontend | 200 | OK | conn reset | conn reset |\n| tag:dba | conn reset | conn reset | allowed | conn reset |\n| group:engineers | conn reset | conn reset | conn reset | allowed |\n| group:ops | 200 | OK | allowed | allowed |\n\nMultiple matching grants merge via OR -- if alice is in both `tag:frontend` and `group:engineers`, she gets HTTP + gRPC + TLS access.\n\n## Reference\n\n### Flags\n\n| Flag | Default | Description |\n|------|---------|-------------|\n| `-config` | `config.yaml` | Path to config file |\n| `-authz-addr` | `127.0.0.1:9001` | ext_authz listen address |\n| `-log-level` | `info` | Log level (`debug`/`info`/`warn`/`error`) |\n\nArguments after `--` are passed directly to Envoy.\n\n### Identity headers\n\nOn allowed L7 requests, tailvoy injects headers before the request reaches the backend:\n\n| Header | Value |\n|--------|-------|\n| `X-Tailscale-User` | Tailscale login (e.g. `alice@example.com`) |\n| `X-Tailscale-Node` | Node FQDN (e.g. `alices-laptop.tailnet.ts.net`) |\n| `X-Tailscale-IP` | Tailscale IP (e.g. `100.64.0.1`) |\n| `X-Tailscale-Tags` | Comma-separated ACL tags |\n\n### Deny response\n\nDenied L7 requests return HTTP 403:\n\n```json\n{\"error\":\"forbidden\",\"message\":\"access denied by tailvoy policy\"}\n```\n\nDenied L4 connections are closed immediately.\n\n## Development\n\n```sh\nmake test              # unit tests with race detector\nmake lint              # golangci-lint\nmake integration-test  # docker compose tests (requires TS_CLIENT_ID, TS_CLIENT_SECRET)\nmake kind-test         # kind cluster tests with Envoy Gateway\nmake docker-build      # build container image\n```\n\nBuild from source: `make build` (requires Go 1.25+).\n\n## License\n\n[MIT](LICENSE)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Frajsinghtech%2Ftailvoy","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Frajsinghtech%2Ftailvoy","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Frajsinghtech%2Ftailvoy/lists"}