Tailscale Configuration as Code — Design
Design bead:
hl-mjh0. Sibling spec (forthcoming):2026-07-05-nas-app-role-codification-design.md(Spec B — the NAS app roles that consume this one). Related in-flight:hl-383r(nas-traefik),hl-ww73(subnet-route conflict),hl-o89e(dead Tailscale operator hygiene).
Bring all Tailscale configuration for the hound-skate.ts.net tailnet under version control in this monorepo, so the tailnet is reproducible from Git and drift is surfaced in PRs. Concretely:
- Relocate the ACL policy (
policy.hujson) and itstailscale/gitops-acl-actionsync workflow from the standalonefzymgc-house/tailscale-configrepo intotailscale/at the root of this repo, then retire the standalone repo. - Codify the non-ACL tailnet surface — DNS, tailnet settings, and OAuth clients — in a new
tf/tailscaleTerraform/TFC workspace. - Fold in two long-standing fixes that this centralization makes trivial: the missing
autoApproversfor the192.168.20.0/22route (hl-ww73) and retiring the deadtag:k8s-operator(hl-o89e). - Unify node auth onto TF-minted OAuth clients: migrate the Firewalla router’s
router-tailscalerole off its SOPS-encrypted static auth key onto a Vault-sourced OAuth client — eliminating the last hand-managed Tailscale credential.
This is also the foundation for nas-tailscale (Spec B): that role consumes a per-app OAuth client minted here and, to use it, converts the NAS Tailscale app from catalog to custom (the catalog app can’t pass TS_CLIENT_ID/TS_CLIENT_SECRET) — exactly the auth path router-tailscale uses.
Motivation
Section titled “Motivation”The operator’s stated preference is centralization and simplification — the whole platform is already IaC (Terraform/TFC + Ansible + ArgoCD GitOps). Tailscale is the outlier: its ACL lives in a separate GitHub repo, and its DNS / tailnet settings / credentials are hand-managed in the admin console. Pulling everything into the monorepo removes a repo, unifies review, and closes the “console drift” gap.
Current state (grounded, verified live 2026-07-05)
Section titled “Current state (grounded, verified live 2026-07-05)”Inventoried via the bootstrap OAuth client (secret/fzymgc-house/infrastructure/tailscale/bootstrap-oauth) against the Tailscale API.
ACL — already GitOps-managed, but off-monorepo
Section titled “ACL — already GitOps-managed, but off-monorepo”fzymgc-house/tailscale-config (private, active, last push 2026-07-03) contains:
policy.hujson(266 lines):groups(6),hosts(20),acls(17 rules),ssh(6 rules),sshTests,tagOwners(19 tags),nodeAttrs(2). NoautoApproversblock..github/workflows/tailscale-sync.yml:tailscale/gitops-acl-action@v1(Renovate-pinned by digest) —action: teston PR,action: applyon push tomain. Auth viasecrets.TS_OAUTH_ID/TS_OAUTH_SECRET/TS_TAILNET.- Supporting:
.pre-commit-config.yaml(generic hooks),.prettierrc.yaml,.editorconfig, LICENSE, README.
The gitops-acl-action is a push-via-API model (PUT /acl on merge, plus test = validate + run sshTests/tests against the live tailnet on PR). This is the right tool for the HuJSON policy and is kept — only relocated.
Non-ACL surface — hand-managed, not in Git
Section titled “Non-ACL surface — hand-managed, not in Git”| Surface | Live value (to adopt) |
|---|---|
| MagicDNS | true |
| Global nameserver | 2606:1a40::13:ee4c:7f63:7c6b:0 |
| Search paths | ["fzymgc.house"] |
| Split-DNS | fzymgc.house → 192.168.20.1 (the LAN router; the tag:ns role) |
| Device approval | on |
| Device auto-updates | on |
| Key duration | 180 days |
| User approval | on |
| Join external tailnets | admin role only |
| Network-flow logging | off |
| Regional routing | off |
| Posture identity collection | on |
| HTTPS certs | on |
| ACLs externally managed | false (console editing is not locked → drift risk) |
| ACL external link | → the tailscale-config repo (stale after relocation) |
Tailnet topology (relevant facts)
Section titled “Tailnet topology (relevant facts)”- Tags in use:
core, nas, ns, router, security, bastion, edge-node, ares-game-node, shared, vault, minecraft, k8s, k8s-operator (dead), tp-*, evennia-game-node, ansible-tp-host. autoApproversis empty → the NAS/22+ exit-node routes are manually approved today (root of hl-ww73’s friction).- Route advertisers:
routerandtruenas-scaleboth advertise192.168.20.0/22+ exit;edge-node,seattleadvertise exit-only. - 2 dead
tailscale-operatornodes (tag:k8s-operator, offline since 2026-06-19 / 2026-01-01) linger from the retired k8s operator (hl-o89e). - The NAS node
truenas-scaleis already tag-owned (tag:core, tag:nas), so its tag/preauth surface is in place. (The catalog→custom conversion in Spec B nonetheless re-registers it once as a new node — see “Cutover consequence” under Migration for why that is non-disruptive.)
Architecture
Section titled “Architecture”Two components with a clean, non-overlapping split of ownership — the ACL content vs. everything else.
selfhosted-cluster/├── tailscale/│ └── policy.hujson # ACL content (acls, tagOwners, ssh, autoApprovers, …)├── .github/workflows/│ └── tailscale-acl-sync.yml # gitops-acl-action, PATH-FILTERED to tailscale/**├── tf/tailscale/ # everything the ACL file does NOT cover│ ├── terraform.tf # provider (bootstrap OAuth from Vault) + TFC cloud block│ ├── dns.tf # tailscale_dns_configuration│ ├── settings.tf # tailscale_tailnet_settings (owns externally-managed + link)│ └── oauth_clients.tf # tailscale_oauth_client × (ci, nas, router) + Vault writes└── ansible/roles/router-tailscale/ # auth migrated: SOPS static key → Vault OAuth clientOwnership boundary (the one coordination point):
| Field / surface | Owner |
|---|---|
ACL acls / tagOwners / ssh / sshTests / hosts / groups / nodeAttrs / autoApprovers | tailscale/policy.hujson + the action |
DNS, tailnet settings, acls_externally_managed_on, acls_external_link | tf/tailscale (Terraform) |
| OAuth clients (CI + per-app) | tf/tailscale (Terraform) |
acls_externally_managed_on / acls_external_link are tailnet-settings metadata, not ACL content, so Terraform owning them does not collide with the action pushing the policy. Setting externally_managed_on = true (up from today’s false) is a deliberate hardening: it locks the admin-console editor, while the action still applies via API (the lock is UI-only).
Component 1 — ACL as code (relocate + fix)
Section titled “Component 1 — ACL as code (relocate + fix)”- Relocate
policy.hujson→tailscale/policy.hujson; recreate the sync workflow as.github/workflows/tailscale-acl-sync.yml, path-filtered totailscale/**so it only runs when the policy changes (critical in a high-traffic monorepo whereapply-on-push would otherwise fire on every merge). - Dedicated CI OAuth client (
scopes = ["policy_file"], minted by Component 2) replaces the currentTS_OAUTH_*; its secret is set as a repo Actions secret (one-time manual seed — the repo has nogithubTerraform provider, and adding one just to write one secret is not worth the coupling). - HuJSON linting folds into this repo’s
lefthook+.rumdl/.yamllinttooling (replacing the standalonepre-commit), keeping formatting/validation on PR. - Folded-in ACL fixes (pure
policy.hujsonedits, no Terraform):- Add an
autoApproversblock auto-approving192.168.20.0/22+ exit-node for the appropriate tags (tag:nas,tag:router) — declaratively fixes the manual-approval friction behind hl-ww73. - Remove
tag:k8s-operatorfromtagOwnersand any references — hl-o89e; the 2 dead operator nodes are deleted once (one-time API/console action, not TF state), and the now-orphaned operator OAuth credential atsecret/fzymgc-house/cluster/tailscale/oauth(plus any Vault policy grants referencing it) is deleted — closing the last hand-managed operator artifact.
- Add an
Component 2 — tf/tailscale (non-ACL config as IaC)
Section titled “Component 2 — tf/tailscale (non-ACL config as IaC)”New TFC workspace main-cluster-tailscale, modeled on tf/cloudflare (provider auth via an ephemeral Vault read of the bootstrap OAuth client; cloud {} block; co-located credential→Vault writes).
tailscale_dns_configuration— adopts MagicDNS, the global nameserver,search_paths, and thefzymgc.house → 192.168.20.1split-DNS. (override_local_dnsconfirmed from livedns/preferencesat implementation; expectedfalse.)tailscale_tailnet_settings— adopts every toggle in the table above; setsacls_externally_managed_on = trueandacls_external_link→ this repo’stailscale/policy.hujson.tailscale_oauth_client× 3, each with a co-located Vault (or Actions-secret) write:- CI client —
scopes = ["policy_file"], for the ACL-sync workflow. Secret → repo Actions secret (manual seed of the TF-minted value). - NAS app client —
scopes = ["auth_keys"],tags = ["tag:nas"], → Vaultsecret/fzymgc-house/infrastructure/nas/tailscale-auth(fieldsclient_id+client_secret). Consumed by thenas-tailscalerole (Spec B), which converts the app from catalog to custom so it can passTS_CLIENT_ID/TS_CLIENT_SECRET. - Router client —
scopes = ["auth_keys"],tags = ["tag:router"], → Vaultsecret/fzymgc-house/infrastructure/router/tailscale-auth(fieldsclient_id+client_secret). Consumed by therouter-tailscalerole (Component 3); therouternode is already tag-owned (tag:ns, tag:router, tag:security).
- CI client —
- Container auth mechanism (grounded — deepwiki
tailscale/tailscale): the officialghcr.io/tailscale/tailscaleimage takes OAuth viaTS_CLIENT_ID+TS_CLIENT_SECRET, which are mutually exclusive withTS_AUTHKEY;containerbootexchanges them for a fresh key internally at startup (no exchange code on our side, but it is an exchange — an OAuth secret cannot be placed inTS_AUTHKEY). Tags are applied viaTS_EXTRA_ARGS=--advertise-tags=tag:nas|tag:router(the ACLtagOwnersauthorize who may assign each tag). Device approval is bypassed because the key containerboot mints from an OAuth client is preauthorized by default — a property of the minted key itself, independent oftagOwners. OAuth client secrets do not expire, giving a no-rotation credential. Full env control is required, so both consumers are custom compose: the router already is; the NAS is converted catalog→custom in Spec B. - Cross-workspace plumbing: a
main-cluster-tailscaleentry intf/hcp-terraform(VCS-triggered ontf/tailscale/**) and aterraform-tailscale-adminpolicy intf/vaultgranting write on the new Vault paths (nas + routertailscale-auth), applies beforetf/tailscale, per the established ordering rule.
Component 3 — router-tailscale auth migration (SOPS → Vault OAuth client)
Section titled “Component 3 — router-tailscale auth migration (SOPS → Vault OAuth client)”The Firewalla router’s router-tailscale role currently loads a SOPS-encrypted static auth key (ansible/secrets/router-tailscale.sops.yaml, field tailscale_auth_key) into TS_AUTHKEY on fresh deploy. That is the last hand-managed Tailscale credential and the only tailscale secret still in SOPS rather than Vault.
- Replace the SOPS lookup with a control-node Vault read (
community.hashi_vault.vault_kv2_get+vault_ca_cert_bundle) of the router OAuth client minted in Component 2 atsecret/fzymgc-house/infrastructure/router/tailscale-auth— the idiom already used bynas-otel-collector/nas-mail/nas-kopia-backup(notrouter-hosts, which authenticates via a rawvaultCLI + AppRole + a Vault-Agent sidecar). The read runs on the control node; the router’spi@router(1Password-agent) SSH path is unaffected. - Switch the router compose env from
TS_AUTHKEYtoTS_CLIENT_ID+TS_CLIENT_SECRETplusTS_EXTRA_ARGS=--advertise-tags=tag:router(the OAuth mechanism from Component 2). - Retire
ansible/secrets/router-tailscale.sops.yamland the SOPS-specific tasks/vars in the role. - No live disruption: the
routernode’s identity persists in its state volume, so the credential only matters on a fresh deploy/rebuild — the migration is a code + secret-source change, not a re-auth. (This differs from the NAS, whose catalog→custom conversion re-registers it — see Migration.)
This leaves every Tailscale node auth (nas, router) sourced from a TF-minted, Vault-stored OAuth client — no static keys, no SOPS, all in Git.
Security
Section titled “Security”- Two-tier credentials, mirroring the Cloudflare bootstrap-vs-per-app split: the bootstrap OAuth client (manually created, broad scopes, in Vault) authenticates the
tf/tailscaleprovider; narrow per-purpose clients (CI =policy_file; NAS =auth_keys+tag:nas) are TF-minted for their single job. - All credential values are
sensitive/no_log; co-located Vault writes follownas_email.tf(a sensitive computed value cannot cross workspaces). acls_externally_managed_on = truecloses the console-drift hole.- Least privilege: the CI client cannot touch devices/DNS; the NAS client cannot edit the policy.
- Non-expiry tradeoff: OAuth client secrets have no forced expiry, so a leaked one has no rotation backstop — mitigated by narrow per-purpose scopes (
policy_file/auth_keys+single tag), Vault-only storage, and independent per-client revocation. Prefer revoke-on-suspicion over relying on expiry.
Migration / cutover
Section titled “Migration / cutover”Ordered to avoid two systems applying the same tailnet:
- Land
tf/vaultgrant →tf/tailscale(mints CI + NAS + router clients, adopts DNS/settings). DNS/settings adoption is a no-op import of current live values (zero behavior change) except the deliberateexternally_managed_onflip. - Seed the CI client secret as this repo’s Actions secret; add
tailscale/policy.hujson+tailscale-acl-sync.ymlhere. - Disable the standalone repo’s workflow first (so two
applyjobs never race), verify a no-opapplyfrom the monorepo, then archivefzymgc-house/tailscale-config. - Fold in the
autoApprovers+tag:k8s-operatoredits as normal PRs againsttailscale/policy.hujson; delete the 2 dead operator nodes and the orphanedcluster/tailscale/oauthVault secret (hl-o89e). - Migrate
router-tailscaleto the Vault-sourced OAuth client (TS_CLIENT_ID/TS_CLIENT_SECRET) and retire the SOPS file (Component 3) — verified by a no-op re-run against the already-registeredrouternode.
Each step is independently revertible; the ACL content is identical across the move, so cutover is a relocation, not a rewrite.
Cutover consequence — NAS re-register. The catalog→custom conversion (Spec B) replaces the app, so truenas-scale comes up as a new node registration (new node identity / 100.x; the stale prior node is removed, the same cleanup class as the dead operator nodes). This is non-disruptive to routing: router redundantly advertises the same 192.168.20.0/22 + exit-node, so the tailnet loses no route coverage during the swap, and the new node’s routes auto-approve immediately via the autoApprovers block added here — no manual approval, no /22/exit gap (hl-ww73 stays satisfied throughout). Preserving the old node’s identity was explicitly rejected (operator, 2026-07-05) as not worth the state-volume migration.
Out of scope (tracked separately)
Section titled “Out of scope (tracked separately)”nas-tailscale/nas-dozzle/nas-rustfsAnsible roles — Spec B.traefikapp codification —hl-383r.storjkeep-vs-graceful-exit decision — unfiled; still running/earning.- Full ACL-in-Terraform (
tailscale_acl) — explicitly rejected; thegitops-acl-actionis retained for nativesshTestsexecution and HuJSON ergonomics.
Verification
Section titled “Verification”tf/tailscaleplanshows the DNS/settings adoption as (near-)no-op imports + 2 client creations;applygreen via TFC.- ACL-sync workflow runs from the monorepo:
testgreen on a PR,applyno-op on merge (policy unchanged by the move). - Standalone repo archived;
aclsExternalLinknow points at the monorepo;aclsExternallyManagedOn = truein the live tailnet. autoApproverspresent → a fresh NAS/router registration auto-approves192.168.20.0/22without console action (hl-ww73 acceptance).tag:k8s-operatorabsent; dead operator nodes gone; orphanedsecret/…/cluster/tailscale/oauthdeleted (hl-o89e).- NAS OAuth client (
client_id+client_secret) readable atsecret/…/nas/tailscale-authfor Spec B’s converted custom app. - Router OAuth client (
client_id+client_secret) readable atsecret/…/router/tailscale-auth;router-tailscalereads it from Vault and authenticates viaTS_CLIENT_ID/TS_CLIENT_SECRET(SOPS file removed); a re-run against the liverouternode is a no-op (identity persisted).
Open decisions — resolved
Section titled “Open decisions — resolved”- ACL: action vs Terraform? → Action (relocated). Rejected
tailscale_aclto keep native test execution. - CI-client secret delivery? → One-time manual Actions secret (no
githubTF provider). tf/tailscalescope? → Full non-ACL adoption (DNS + settings + OAuth clients), per operator “all config in Git.”- Location? →
tailscale/at repo root (policy + workflow);tf/tailscale/(Terraform). - Fold
router-tailscaleSOPS→OAuth migration into this spec? → Yes (operator, 2026-07-05) — unifies all node auth; the SOPS key was the last hand-managed tailscale credential. - Node auth credential type? → OAuth client via
TS_CLIENT_ID/TS_CLIENT_SECRET(non-expiring), not a reusable tailnet key. Requires full container-env control, so the NAS app converts catalog→custom (Spec B) and re-registers once (autoApprovers makes it seamless). This corrects an initial “OAuth-secret-as-TS_AUTHKEY” design error — deepwiki confirms those env vars are mutually exclusive.