Skip to content

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:

  1. Relocate the ACL policy (policy.hujson) and its tailscale/gitops-acl-action sync workflow from the standalone fzymgc-house/tailscale-config repo into tailscale/ at the root of this repo, then retire the standalone repo.
  2. Codify the non-ACL tailnet surface — DNS, tailnet settings, and OAuth clients — in a new tf/tailscale Terraform/TFC workspace.
  3. Fold in two long-standing fixes that this centralization makes trivial: the missing autoApprovers for the 192.168.20.0/22 route (hl-ww73) and retiring the dead tag:k8s-operator (hl-o89e).
  4. Unify node auth onto TF-minted OAuth clients: migrate the Firewalla router’s router-tailscale role 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.

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). No autoApprovers block.
  • .github/workflows/tailscale-sync.yml: tailscale/gitops-acl-action@v1 (Renovate-pinned by digest) — action: test on PR, action: apply on push to main. Auth via secrets.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”
SurfaceLive value (to adopt)
MagicDNStrue
Global nameserver2606:1a40::13:ee4c:7f63:7c6b:0
Search paths["fzymgc.house"]
Split-DNSfzymgc.house → 192.168.20.1 (the LAN router; the tag:ns role)
Device approvalon
Device auto-updateson
Key duration180 days
User approvalon
Join external tailnetsadmin role only
Network-flow loggingoff
Regional routingoff
Posture identity collectionon
HTTPS certson
ACLs externally managedfalse (console editing is not locked → drift risk)
ACL external link→ the tailscale-config repo (stale after relocation)
  • 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.
  • autoApprovers is empty → the NAS /22 + exit-node routes are manually approved today (root of hl-ww73’s friction).
  • Route advertisers: router and truenas-scale both advertise 192.168.20.0/22 + exit; edge-node, seattle advertise exit-only.
  • 2 dead tailscale-operator nodes (tag:k8s-operator, offline since 2026-06-19 / 2026-01-01) linger from the retired k8s operator (hl-o89e).
  • The NAS node truenas-scale is 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.)

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 client

Ownership boundary (the one coordination point):

Field / surfaceOwner
ACL acls / tagOwners / ssh / sshTests / hosts / groups / nodeAttrs / autoApproverstailscale/policy.hujson + the action
DNS, tailnet settings, acls_externally_managed_on, acls_external_linktf/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.hujsontailscale/policy.hujson; recreate the sync workflow as .github/workflows/tailscale-acl-sync.yml, path-filtered to tailscale/** so it only runs when the policy changes (critical in a high-traffic monorepo where apply-on-push would otherwise fire on every merge).
  • Dedicated CI OAuth client (scopes = ["policy_file"], minted by Component 2) replaces the current TS_OAUTH_*; its secret is set as a repo Actions secret (one-time manual seed — the repo has no github Terraform provider, and adding one just to write one secret is not worth the coupling).
  • HuJSON linting folds into this repo’s lefthook + .rumdl/.yamllint tooling (replacing the standalone pre-commit), keeping formatting/validation on PR.
  • Folded-in ACL fixes (pure policy.hujson edits, no Terraform):
    • Add an autoApprovers block auto-approving 192.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-operator from tagOwners and 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 at secret/fzymgc-house/cluster/tailscale/oauth (plus any Vault policy grants referencing it) is deleted — closing the last hand-managed operator artifact.

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 the fzymgc.house → 192.168.20.1 split-DNS. (override_local_dns confirmed from live dns/preferences at implementation; expected false.)
  • tailscale_tailnet_settings — adopts every toggle in the table above; sets acls_externally_managed_on = true and acls_external_link → this repo’s tailscale/policy.hujson.
  • tailscale_oauth_client × 3, each with a co-located Vault (or Actions-secret) write:
    • CI clientscopes = ["policy_file"], for the ACL-sync workflow. Secret → repo Actions secret (manual seed of the TF-minted value).
    • NAS app clientscopes = ["auth_keys"], tags = ["tag:nas"], → Vault secret/fzymgc-house/infrastructure/nas/tailscale-auth (fields client_id + client_secret). Consumed by the nas-tailscale role (Spec B), which converts the app from catalog to custom so it can pass TS_CLIENT_ID/TS_CLIENT_SECRET.
    • Router clientscopes = ["auth_keys"], tags = ["tag:router"], → Vault secret/fzymgc-house/infrastructure/router/tailscale-auth (fields client_id + client_secret). Consumed by the router-tailscale role (Component 3); the router node is already tag-owned (tag:ns, tag:router, tag:security).
  • Container auth mechanism (grounded — deepwiki tailscale/tailscale): the official ghcr.io/tailscale/tailscale image takes OAuth via TS_CLIENT_ID + TS_CLIENT_SECRET, which are mutually exclusive with TS_AUTHKEY; containerboot exchanges 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 in TS_AUTHKEY). Tags are applied via TS_EXTRA_ARGS=--advertise-tags=tag:nas|tag:router (the ACL tagOwners authorize 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 of tagOwners. 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-tailscale entry in tf/hcp-terraform (VCS-triggered on tf/tailscale/**) and a terraform-tailscale-admin policy in tf/vault granting write on the new Vault paths (nas + router tailscale-auth), applies before tf/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 at secret/fzymgc-house/infrastructure/router/tailscale-auth — the idiom already used by nas-otel-collector / nas-mail / nas-kopia-backup (not router-hosts, which authenticates via a raw vault CLI + AppRole + a Vault-Agent sidecar). The read runs on the control node; the router’s pi@router (1Password-agent) SSH path is unaffected.
  • Switch the router compose env from TS_AUTHKEY to TS_CLIENT_ID + TS_CLIENT_SECRET plus TS_EXTRA_ARGS=--advertise-tags=tag:router (the OAuth mechanism from Component 2).
  • Retire ansible/secrets/router-tailscale.sops.yaml and the SOPS-specific tasks/vars in the role.
  • No live disruption: the router node’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.

  • Two-tier credentials, mirroring the Cloudflare bootstrap-vs-per-app split: the bootstrap OAuth client (manually created, broad scopes, in Vault) authenticates the tf/tailscale provider; 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 follow nas_email.tf (a sensitive computed value cannot cross workspaces).
  • acls_externally_managed_on = true closes 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.

Ordered to avoid two systems applying the same tailnet:

  1. Land tf/vault grant → 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 deliberate externally_managed_on flip.
  2. Seed the CI client secret as this repo’s Actions secret; add tailscale/policy.hujson + tailscale-acl-sync.yml here.
  3. Disable the standalone repo’s workflow first (so two apply jobs never race), verify a no-op apply from the monorepo, then archive fzymgc-house/tailscale-config.
  4. Fold in the autoApprovers + tag:k8s-operator edits as normal PRs against tailscale/policy.hujson; delete the 2 dead operator nodes and the orphaned cluster/tailscale/oauth Vault secret (hl-o89e).
  5. Migrate router-tailscale to 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-registered router node.

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.

  • nas-tailscale / nas-dozzle / nas-rustfs Ansible roles — Spec B.
  • traefik app codification — hl-383r.
  • storj keep-vs-graceful-exit decision — unfiled; still running/earning.
  • Full ACL-in-Terraform (tailscale_acl) — explicitly rejected; the gitops-acl-action is retained for native sshTests execution and HuJSON ergonomics.
  • tf/tailscale plan shows the DNS/settings adoption as (near-)no-op imports + 2 client creations; apply green via TFC.
  • ACL-sync workflow runs from the monorepo: test green on a PR, apply no-op on merge (policy unchanged by the move).
  • Standalone repo archived; aclsExternalLink now points at the monorepo; aclsExternallyManagedOn = true in the live tailnet.
  • autoApprovers present → a fresh NAS/router registration auto-approves 192.168.20.0/22 without console action (hl-ww73 acceptance).
  • tag:k8s-operator absent; dead operator nodes gone; orphaned secret/…/cluster/tailscale/oauth deleted (hl-o89e).
  • NAS OAuth client (client_id + client_secret) readable at secret/…/nas/tailscale-auth for Spec B’s converted custom app.
  • Router OAuth client (client_id + client_secret) readable at secret/…/router/tailscale-auth; router-tailscale reads it from Vault and authenticates via TS_CLIENT_ID/TS_CLIENT_SECRET (SOPS file removed); a re-run against the live router node is a no-op (identity persisted).
  • ACL: action vs Terraform?Action (relocated). Rejected tailscale_acl to keep native test execution.
  • CI-client secret delivery?One-time manual Actions secret (no github TF provider).
  • tf/tailscale scope?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-tailscale SOPS→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.