{"id":50399961,"url":"https://github.com/rajsinghtech/tailgate","last_synced_at":"2026-05-30T23:00:27.065Z","repository":{"id":361315892,"uuid":"1254001609","full_name":"rajsinghtech/tailgate","owner":"rajsinghtech","description":"Native Tailscale egress for Kubernetes pods — one shared tailscaled gateway per EgressGroup (CGNAT, subnet routes, app connectors, exit nodes, MagicDNS, IPv6/4via6), config-file reconcile with no pod restarts.","archived":false,"fork":false,"pushed_at":"2026-05-30T03:30:58.000Z","size":130,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-30T05:11:42.706Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","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-05-30T03:06:53.000Z","updated_at":"2026-05-30T03:30:56.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/rajsinghtech/tailgate","commit_stats":null,"previous_names":["rajsinghtech/tailgate"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/rajsinghtech/tailgate","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rajsinghtech%2Ftailgate","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rajsinghtech%2Ftailgate/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rajsinghtech%2Ftailgate/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rajsinghtech%2Ftailgate/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/rajsinghtech","download_url":"https://codeload.github.com/rajsinghtech/tailgate/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rajsinghtech%2Ftailgate/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33712579,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-05-30T02:00:06.278Z","response_time":92,"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-05-30T23:00:21.482Z","updated_at":"2026-05-30T23:00:27.057Z","avatar_url":"https://github.com/rajsinghtech.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# tailgate\n\n\u003cp align=\"center\"\u003e\n  \u003cstrong\u003eNative Tailscale egress for Kubernetes pods — one shared gateway per group, not one node per pod.\u003c/strong\u003e\n\u003c/p\u003e\n\n\u003cp align=\"center\"\u003e\n  \u003ca href=\"https://github.com/rajsinghtech/tailgate/actions/workflows/test.yml\"\u003e\u003cimg src=\"https://github.com/rajsinghtech/tailgate/actions/workflows/test.yml/badge.svg\" alt=\"CI\"\u003e\u003c/a\u003e\n  \u003ca href=\"https://goreportcard.com/report/github.com/rajsinghtech/tailgate\"\u003e\u003cimg src=\"https://goreportcard.com/badge/github.com/rajsinghtech/tailgate\" alt=\"Go Report Card\"\u003e\u003c/a\u003e\n  \u003ca href=\"https://github.com/users/rajsinghtech/packages/container/package/tailgate-operator\"\u003e\u003cimg src=\"https://img.shields.io/badge/ghcr.io-tailgate-blue?logo=docker\u0026logoColor=white\" alt=\"GHCR\"\u003e\u003c/a\u003e\n  \u003ca href=\"https://github.com/rajsinghtech/tailgate/pkgs/container/charts%2Ftailgate-operator\"\u003e\u003cimg src=\"https://img.shields.io/badge/helm-OCI-0F1689?logo=helm\u0026logoColor=white\" alt=\"Helm\"\u003e\u003c/a\u003e\n  \u003ca href=\"LICENSE\"\u003e\u003cimg src=\"https://img.shields.io/badge/license-Apache--2.0-green\" alt=\"License\"\u003e\u003c/a\u003e\n\u003c/p\u003e\n\n`tailgate` is a Kubernetes operator that puts a *set* of pods onto a [Tailscale](https://tailscale.com)\ntailnet for **egress** — reaching CGNAT peers, advertised subnet routes, app connectors, and exit\nnodes — without giving every pod its own `tailscaled`. Each `EgressGroup` gets **one shared\ntailscaled gateway**; a node agent veth-stitches member pods into that gateway's network namespace.\nThe tailnet sees `O(groups)` devices, not `O(pods)`.\n\n- **One device per group, not per pod** — scale a workload from 3 to 3,000 pods and the tailnet gains no new nodes for that group. Member pod churn never adds or removes gateway devices from the tailnet.\n- **Native L3 egress** — the gateway is a kernel-TUN `tailscaled`, so members get whole-CIDR, all-protocol egress (TCP/UDP/ICMP/…) at the network layer, rather than redirecting traffic through an application-level socket proxy.\n- **Reach everything the tailnet exposes** — CGNAT peers (`100.64.0.0/10` + the IPv6 ULA), advertised subnet-router CIDRs, app-connector ranges, and full-tunnel through a chosen exit node.\n- **IPv6 / dual-stack** — the pod↔gateway veth is dual-stack, enabling members to reach peers over both their CGNAT v4 and ULA v6 regardless of the cluster's own IP family.\n- **Live reconcile, no pod flap** — change `acceptRoutes`, swap the `exitNode`, or adjust DNS on a running group and the gateway hot-reloads its config in place, keeping member tunnels up through the reconcile.\n- **MagicDNS** — members point at `100.100.100.100` and resolve peers' `*.ts.net` names through the shared gateway's resolver.\n- **No Multus, no Spiderpool, no second NIC** — the default `routed` attach is a single chained route-only CNI plugin that injects tailnet routes onto each member pod's existing `eth0`.\n\n## Custom Resources\n\n| CRD | Scope | Short | Description |\n|-----|-------|-------|-------------|\n| `EgressGroup` | Cluster | `eg` | A set of pods that egress onto the tailnet through one shared gateway |\n\nAPI group: `tailscale.rajsingh.info/v1alpha1`.\n\n## Architecture\n\n```\n              ┌──────────────────────────────────────────────┐\n EgressGroup ─►│  tailgate-operator (controller-runtime)      │\n (selector,    │  reconcile → authkey Secret (OAuth, tagged)  │\n  mode, routes,│            + tailscaled config ConfigMap      │\n  exitNode,    │            + per-group gateway DaemonSet      │\n  acceptRoutes)└───────────────┬──────────────────────────────┘\n                               │ mints tag:egress-\u003cgroup\u003e, renders\n                               │ tailscaled.json from the spec\n                               ▼\n   ┌──────── node ─────────────────────────────────────────────┐\n   │  tailgate-agent (DaemonSet, hostPID/hostNetwork)           │\n   │   • installs the route-only CNI (chained into the conflist)│\n   │   • watches Pods + EgressGroups; for each MEMBER pod:      │\n   │       veth-stitches it into the node's gateway netns +     │\n   │       injects 100.64/10, the ULA, and spec.routes          │\n   │                                                            │\n   │  tailgate-gateway (per-group DaemonSet, own netns)         │\n   │   tailscaled --tun=tailscale0 --config=tailscaled.json     │\n   │   ip_forward + MASQUERADE onto tailscale0 (SNAT-to-tag)    │\n   │   fwmark member traffic → policy table → tailscale0        │\n   └────────────────────────────┬──────────────────────────────┘\n                                │ WireGuard\n                                ▼\n            tailnet: CGNAT peers · subnet routers · app\n            connectors · exit node (full tunnel)\n```\n\nThree binaries make up the system (all published to `ghcr.io/rajsinghtech/`):\n\n- **`tailgate-operator`** (Deployment) — a controller-runtime reconciler. For each `EgressGroup` it mints a per-group OAuth authkey tagged `tag:egress-\u003cgroup\u003e` into a Secret, renders a declarative `tailscaled` config (`ipn.ConfigVAlpha`, `alpha0`) from the spec into a ConfigMap, and stamps out the gateway DaemonSet. Everything it creates is owner-referenced for garbage collection; a finalizer deletes the gateway's tailnet device on teardown.\n- **`tailgate-gateway`** (per-group DaemonSet, privileged) — the *one shared tailnet node* for the group. It runs the official `tailscale/tailscale` image's `tailscaled` in kernel-TUN mode inside its **own** pod netns (not `hostNetwork`, so each group's `tailscale0` is isolated and the agent can stitch member veths in). It enables IP forwarding, MASQUERADEs forwarded member traffic onto `tailscale0` (SNAT to the group's tag), and `fwmark`s member traffic into a policy table whose default routes through `tailscale0` — so `tailscaled` routes each destination per its netmap (CGNAT peer, accepted subnet/app-connector CIDR, or the exit node for `0.0.0.0/0`). It watches the config file and calls LocalAPI `ReloadConfig` on change — **no restart**. Node-local so a member always has a same-node gateway to wire to; persisted state keeps the node identity stable across restarts.\n- **`tailgate-agent`** (DaemonSet, privileged + `hostPID` + `hostNetwork`) — self-installs the chained route-only CNI plugin (`tailgate-cni`) into the node's conflist, then watches Pods and `EgressGroup`s. For each pod a group selects, it veth-stitches the pod into that node's group gateway netns and injects the tailnet routes (`100.64.0.0/10`, the ULA, and `spec.routes`) toward the gateway. Selection is *data* (an informer), not network plumbing — there is no per-pod annotation or NAD to manage.\n\nWhy this shape: tailgate groups pods behind a shared gateway to keep the device count `O(groups)` rather than `O(pods)`, bounding growth by workload count instead of pod count, and each per-group gateway preserves the group's tag as the source identity for all member traffic. Other egress paths make different tradeoffs: the operator's ProxyGroup egress offers centralized control but does not preserve each pod's individual source identity on the tailnet and does not support 4via6; a subnet router holds a WireGuard peer for every node its ACL reaches, which can become a scaling constraint in large tailnets; per-pod `tailscaled` preserves identity but grows the device count and control-plane churn with pod count. `tailgate` keeps a small, fixed set of gateway devices (`O(groups)`), bounded by group rather than tailnet, while still giving members native whole-CIDR L3.\n\n## Install\n\n`tailgate` needs a Tailscale **OAuth client** (with the `auth_keys` write scope and an owner of the\n`tag:egress-*` tags) so the operator can mint per-group authkeys. Create one in the Tailscale admin\nconsole under **Settings → OAuth clients**, and make sure your tailnet policy file owns the tags, e.g.:\n\n```jsonc\n// tailnet policy file\n\"tagOwners\": {\n  \"tag:egress-*\": [\"autogroup:admin\"],\n}\n```\n\nCreate the namespace and the credentials Secret the operator reads:\n\n```bash\nkubectl create namespace tailgate-system\n\nkubectl create secret generic tailgate-tailnet-creds \\\n  --namespace tailgate-system \\\n  --from-literal=TS_TAILNET='your-org.ts.net' \\\n  --from-literal=TS_OAUTH_CLIENT_ID='\u003coauth-client-id\u003e' \\\n  --from-literal=TS_OAUTH_CLIENT_SECRET='\u003coauth-client-secret\u003e'\n```\n\nThen install the operator, agent, and CRD with Helm from the GHCR OCI registry:\n\n```bash\nhelm install tailgate oci://ghcr.io/rajsinghtech/charts/tailgate-operator \\\n  --namespace tailgate-system\n```\n\n\u003e The gateway pods are privileged (kernel TUN + iptables + sysctls) and the agent runs\n\u003e `hostNetwork`/`hostPID`. On clusters with Pod Security Admission, label the namespace\n\u003e `pod-security.kubernetes.io/enforce: privileged`.\n\n### Install from raw manifests\n\nIf you aren't using Helm, apply the CRD and the bundled manifests directly (operator Deployment,\nagent DaemonSet, ServiceAccount, and ClusterRole/Binding in `tailgate-system`):\n\n```bash\nkubectl apply -f config/crd/tailscale.rajsingh.info_egressgroups.yaml\nkubectl apply -f deploy/manifests/tailgate.yaml\n```\n\nThe operator Deployment reads `GW_IMAGE` (the gateway image it stamps into per-group DaemonSets) and\nthe `TS_TAILNET` / `TS_OAUTH_CLIENT_ID` / `TS_OAUTH_CLIENT_SECRET` keys from the\n`tailgate-tailnet-creds` Secret above. The agent reads `TAILGATE_CLUSTER_CIDRS` — the in-cluster\npod/service ranges to keep on the primary CNI for exit-node members so kube-DNS and the API server\nnever blackhole through the full tunnel (set it to your real pod **and** service CIDRs, e.g.\n`10.244.0.0/16,10.96.0.0/12`).\n\n## Quickstart\n\nLabel the workload you want to egress, then declare an `EgressGroup` that selects it. This example\nreaches CGNAT peers plus a `10.0.0.0/8` subnet-router range, accepts whatever routes the tailnet\nadvertises, and full-tunnels everything else through a chosen exit node:\n\n```yaml\napiVersion: tailscale.rajsingh.info/v1alpha1\nkind: EgressGroup\nmetadata:\n  name: payments\nspec:\n  selector:\n    namespaceSelector:\n      matchLabels:\n        kubernetes.io/metadata.name: payments\n    podSelector:\n      matchLabels:\n        tailgate.dev/egress: \"true\"\n  mode: subnet               # cgnat (default) | subnet\n  routes:                    # tailnet CIDRs to steer onto members (CGNAT + ULA are always steered)\n    - 10.0.0.0/8\n  acceptRoutes: true         # gateway accepts subnet-router + app-connector routes (default true)\n  exitNode:                  # optional — route members' default route through this tailnet node\n    nodeID: exit-fra1.your-org.ts.net\n    allowLANAccess: true\n  replicas: 1                # gateway HA; pod churn never touches this\n```\n\n```bash\nkubectl apply -f payments-egress.yaml\nkubectl get eg\n# NAME       MODE     ATTACH   PODS   AGE\n# payments   subnet   routed   4      30s\n```\n\nAny pod matching the selector now reaches `100.64.0.0/10`, the IPv6 ULA, and `10.0.0.0/8`\n**natively** through the shared gateway — no sidecar, no per-pod annotation, no app changes. A pod\nthat doesn't match is untouched.\n\nThe minimal group is just a selector (everything else defaults — `mode: cgnat`, `attach: routed`,\n`datapath: kernel`, `replicas: 1`):\n\n```yaml\napiVersion: tailscale.rajsingh.info/v1alpha1\nkind: EgressGroup\nmetadata:\n  name: peers\nspec:\n  selector:\n    podSelector:\n      matchLabels:\n        tailgate.dev/egress: \"true\"\n```\n\n## Live reconcile — no pod flap\n\nThe gateway is driven entirely by a declarative `tailscaled` config file the operator renders from\nthe `EgressGroup` spec. Change the spec and the operator re-renders the ConfigMap; the gateway's\nentrypoint watches the file (by content hash) and calls LocalAPI `ReloadConfig`. `tailscaled`\nre-applies its prefs **in place** — same pod, same node identity, `restartCount` unchanged.\n\nThat means these are all live edits that reload the gateway config in place, avoiding a pod restart:\n\n```bash\n# add a subnet-router range\nkubectl patch eg payments --type=merge -p '{\"spec\":{\"routes\":[\"10.0.0.0/8\",\"192.168.0.0/16\"]}}'\n\n# select an exit node (or change it, or remove it)\nkubectl patch eg payments --type=merge \\\n  -p '{\"spec\":{\"exitNode\":{\"nodeID\":\"exit-ams1.your-org.ts.net\",\"allowLANAccess\":true}}}'\n\n# stop accepting advertised routes\nkubectl patch eg payments --type=merge -p '{\"spec\":{\"acceptRoutes\":false}}'\n```\n\n`cidr` / `exit-node` / `dns` changes re-render the config and reload prefs without flapping the pod,\nso traffic in flight survives the reconcile. When the config reloads successfully, editing a\ngroup's reachability avoids a pod restart. (Because tags ride on the authkey rather than the config,\nthey're deliberately *not* a hot-reload field.)\n\n## Egress modes\n\n| `spec.mode` | Members reach |\n|-------------|---------------|\n| `cgnat` (default) | Tailnet peers by CGNAT IP (`100.64.0.0/10`) + the IPv6 ULA. |\n| `subnet` | Everything in `cgnat`, plus the advertised subnet-router / app-connector CIDRs listed in `spec.routes`. |\n\n`spec.exitNode` is orthogonal to `mode`: set it (in any mode) to push `0.0.0.0/0` + `::/0` onto\nmembers through the chosen exit node. The full-tunnel default route lives in a dedicated policy table\n(never the pod's `main` table), and the agent keeps `TAILGATE_CLUSTER_CIDRS` on the primary CNI so\nkube-DNS and the API server stay reachable. The gateway *uses* an exit node; it does not advertise\nitself as one.\n\n`spec.datapath` defaults to `kernel` (the full-fat client: userspace WireGuard + a kernel TUN device,\nneeding a privileged/`NET_ADMIN` gateway pod) — the only path that delivers native whole-CIDR,\nall-protocol egress. `spec.attach` is `routed` (the chained route-only CNI, the no-dependency\ndefault).\n\n## Development\n\nThe dev/test loop runs against a local [kind](https://kind.sigs.k8s.io) cluster and a **per-run\nephemeral tailnet** created and destroyed through the Tailscale org API, so each run is hermetic and\nleaves nothing behind.\n\n```bash\n# build all four binaries (operator, agent, gateway, cni), build + load images into a kind cluster\nhack/build.sh \u003ctag\u003e \u003ckind-cluster\u003e\n```\n\nThe e2e harness spins up a kind cluster for a given IP family, deploys tailgate, and runs the\ndatapath tests (which own the ephemeral-tailnet lifecycle):\n\n```bash\nhack/e2e.sh v4          # IPv4 kind cluster\nhack/e2e.sh dual        # dual-stack\nhack/e2e.sh v4 --keep   # leave the cluster up afterwards\n```\n\nThe same datapath test runs for every family — it curls the peer over **both** v4 and v6 through the\nalways-dual-stack veth, proving family-independence regardless of the cluster's own primary family.\n\nThe e2e suite (`test/e2e/*.go`, build tag `e2e`) needs an OAuth client with org-tailnet\ncreate/delete scope, supplied via `TS_ORG_OAUTH_CLIENT_ID` / `TS_ORG_OAUTH_CLIENT_SECRET` (CI uses\nGitHub OIDC token-exchange so there's no long-lived secret). It covers the full stack against a real\ntailnet:\n\n| Test | Proves |\n|------|--------|\n| `TestEgressDatapath` | member → node-local gateway (veth + MASQUERADE) → CGNAT peer, over both v4 and v6 |\n| `TestSubnetRouterReachability` | `mode: subnet` member reaches a backend IP inside an advertised CIDR; non-member denied |\n| `TestAppConnectorReachability` | member's traffic to a real app-connector preset's published CIDRs is intercepted through the gateway |\n| `TestExitNodeFullTunnel` | exit-node selection + member full-tunnel routing (table 7717 + cluster carve-outs); kube-DNS still resolves |\n| `TestMagicDNSThroughGateway` | a member pointed only at `100.100.100.100` resolves a peer's `*.ts.net` name through the gateway |\n| `TestConfigReconcileNoRestart` | flipping `acceptRoutes` + selecting an `exitNode` on a running group reloads prefs with the gateway pod unchanged (same UID, `restartCount=0`) |\n| `TestRenderGatewayConfigRoundTrips` | the rendered `tailscaled.json` round-trips through the real `conffile.Load` |\n\nPolicy throughout the e2e is expressed using the **grants** field in the policy file (`{src, dst, ip}`,\nplus grants-`via` for routing through a specific tagged node).\n\n## License\n\n[Apache 2.0](LICENSE)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Frajsinghtech%2Ftailgate","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Frajsinghtech%2Ftailgate","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Frajsinghtech%2Ftailgate/lists"}