router-tailscale SOPS→Vault OAuth Migration Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use dev-flow:subagent-driven-development (recommended) or dev-flow:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Migrate the Firewalla router’s router-tailscale role off its SOPS-encrypted static auth key onto the Vault-sourced OAuth client minted by the foundation plan, eliminating the last hand-managed Tailscale credential.
Architecture: Replace the SOPS auth-key load (tasks/main.yml) with a control-node Vault read (community.hashi_vault.vault_kv2_get) of secret/fzymgc-house/infrastructure/router/tailscale-auth (fields client_id/client_secret), and switch the compose env (docker-compose.yml.j2) from TS_AUTHKEY to TS_CLIENT_ID/TS_CLIENT_SECRET + an explicit --advertise-tags. The router node’s identity persists in its state volume, so on the already-registered router this is a code/secret-source change, not a re-auth; the OAuth path only exercises on a fresh deploy/rebuild.
Tech Stack: Ansible (FQCN, no_log), community.hashi_vault.vault_kv2_get, Vault KV v2, the official ghcr.io/tailscale/tailscale container (containerboot OAuth mode).
Scope note: Plan 3 of 3 for Spec A (hl-mjh0). Depends on the foundation plan: the router OAuth client + secret/…/router/tailscale-auth must exist. Sibling of the ACL-relocation plan.
Model hints (for bead materialization, Rule 5): all tasks = model:sonnet (Ansible + Vault + live-router verification; no purely mechanical task — every change touches auth or the running router’s tailnet connectivity).
Grounding (this session): router-tailscale role read — tasks/main.yml (SOPS load gated on not tailscale_state.stat.exists), templates/docker-compose.yml.j2 (TS_AUTHKEY gated + TS_EXTRA_ARGS from exit/routes + cap_add:[NET_ADMIN,SYS_MODULE] + network_mode: host), defaults/main.yml. Container OAuth mechanism (TS_CLIENT_ID/TS_CLIENT_SECRET, tags via --advertise-tags) verified via deepwiki tailscale/tailscale. Live router node tags: tag:ns, tag:router, tag:security. The vault_kv2_get+vault_ca_cert_bundle idiom is used by nas-otel-collector/nas-mail.
⚠️ Cross-plan correction required (foundation plan + Spec B)
Section titled “⚠️ Cross-plan correction required (foundation plan + Spec B)”Writing this plan surfaced a defect in the foundation plan’s OAuth-client tags. A node’s tags are (re)assigned at registration from the OAuth client’s allowed tags + the container’s --advertise-tags. The live nodes carry MORE than one tag each:
- router →
tag:router, tag:ns, tag:security - nas (
truenas-scale) →tag:core, tag:nas
The foundation plan (Task 6) mints tailscale_oauth_client.router with tags = ["tag:router"] and .nas with tags = ["tag:nas"] — the primary tag only. If a node re-registers through such a client, it can only advertise that one tag and silently loses its other tags, breaking any ACL grant / SSH rule / DNS role (tag:ns) that keys on them.
Resolution (preserve all node tags — decided 2026-07-05, grounded): tag:ns is load-bearing (an ACL dst for DNS :53, so the router serves tailnet DNS) — dropping it on a rebuild breaks tailnet DNS; tag:core/tag:security appear only in tagOwners (no active grants) so keeping them is zero-cost insurance. The following corrections have been applied to the foundation plan and Spec B:
- foundation
tailscale_oauth_client.router→tags = ["tag:router", "tag:ns", "tag:security"]✓ - foundation
tailscale_oauth_client.nas→tags = ["tag:nas", "tag:core"]✓ - foundation prerequisite: the bootstrap OAuth client must be allowed to assign all five tags (verify in the admin console) ✓
- Spec B’s
nas-tailscalecompose →--advertise-tags=tag:nas,tag:core✓
This plan uses the corrected router client (all three tags).
Task 1: Role defaults — Vault path + tags, retire SOPS var
Section titled “Task 1: Role defaults — Vault path + tags, retire SOPS var”Files:
-
Modify:
ansible/roles/router-tailscale/defaults/main.yml -
Step 1: Add OAuth/Vault vars; mark the SOPS var removed
Add (mirroring nas-role Vault conventions):
# Tailscale OAuth client (control-node Vault read; replaces the SOPS auth key).router_tailscale_oauth_vault_path: fzymgc-house/infrastructure/router/tailscale-authrouter_tailscale_oauth_client_id_field: client_idrouter_tailscale_oauth_client_secret_field: client_secret
# Tags advertised at (re)registration — MUST cover every tag the router node holds,# or a fresh deploy drops the missing ones. Live: tag:router, tag:ns, tag:security.router_tailscale_advertise_tags: - "tag:router" - "tag:ns" - "tag:security"Delete the now-unused tailscale_sops_file var (its only consumer is removed in Task 2).
- Step 2: Commit.
Task 2: Role tasks — Vault read replaces SOPS load
Section titled “Task 2: Role tasks — Vault read replaces SOPS load”Files:
-
Modify:
ansible/roles/router-tailscale/tasks/main.yml -
Step 1: Replace the SOPS block with a control-node Vault read
Remove the three SOPS tasks (“Check if SOPS file exists”, “Load auth key from SOPS”, and the SOPS mention in “Fail if no auth key”). Replace with a Vault read, gated on fresh deploy exactly like the old SOPS load (state persistence means an established router never needs the credential):
- name: Read Tailscale OAuth client from Vault (fresh deploy only) ansible.builtin.set_fact: router_tailscale_client_id: >- {{ lookup('community.hashi_vault.vault_kv2_get', router_tailscale_oauth_vault_path, ca_cert=vault_ca_cert_bundle).data.data[router_tailscale_oauth_client_id_field] }} router_tailscale_client_secret: >- {{ lookup('community.hashi_vault.vault_kv2_get', router_tailscale_oauth_vault_path, ca_cert=vault_ca_cert_bundle).data.data[router_tailscale_oauth_client_secret_field] }} when: - not tailscale_state.stat.exists - router_tailscale_client_id is not defined no_log: true tags: - tailscaleThe
router_tailscale_client_id is not definedguard preserves the role’s prior escape hatch: an operator can still bypass Vault on a fresh deploy by passing-e router_tailscale_client_id=... -e router_tailscale_client_secret=...(the old role allowed the same via-e tailscale_auth_key=...).
- Step 2: Update the fail-fast guard
Replace the “Fail if no auth key” task’s condition/message (the SOPS reference is gone):
- name: Fail if OAuth client unavailable for a fresh deploy ansible.builtin.fail: msg: >- No Tailscale state found and no OAuth client resolved from Vault ({{ router_tailscale_oauth_vault_path }}). Ensure the foundation tf/tailscale apply has written the router client, and that your Vault session can read it. when: - not tailscale_state.stat.exists - router_tailscale_client_id is not defined tags: - tailscale- Step 3: Confirm
vault_ca_cert_bundleis in scope for the router play
Run: rg -n 'vault_ca_cert_bundle' ansible/inventory/group_vars/ ansible/inventory/host_vars/
Expected: a definition reachable by the router host (the nas roles rely on the same var). If it’s nas-scoped only, add it to the router group_vars (same value — the internal fzymgc CA bundle). Fix before running.
- Step 4: Commit.
Task 3: Compose template — OAuth env + advertise-tags
Section titled “Task 3: Compose template — OAuth env + advertise-tags”Files:
-
Modify:
ansible/roles/router-tailscale/templates/docker-compose.yml.j2 -
Step 1: Swap
TS_AUTHKEYfor the OAuth env, add--advertise-tags
Replace the TS_AUTHKEY block and extend extra_args:
- TS_STATE_DIR=/var/lib/tailscale{% if router_tailscale_client_id is defined and router_tailscale_client_id %} - TS_CLIENT_ID={{ router_tailscale_client_id }} - TS_CLIENT_SECRET={{ router_tailscale_client_secret }}{% endif %}{% set extra_args = [] %}{% if router_tailscale_advertise_tags %}{% set _ = extra_args.append('--advertise-tags=' + router_tailscale_advertise_tags | join(',')) %}{% endif %}{% if tailscale_advertise_exit_node %}{% set _ = extra_args.append('--advertise-exit-node') %}{% endif %}{% if tailscale_advertise_routes %}{% set _ = extra_args.append('--advertise-routes=' + tailscale_advertise_routes | join(',')) %}{% endif %} - TS_EXTRA_ARGS={{ extra_args | join(' ') }} - TS_USERSPACE={{ tailscale_userspace | string | lower }}
TS_CLIENT_ID/TS_CLIENT_SECRETare mutually exclusive withTS_AUTHKEY(deepwikitailscale/tailscale) — do not set both. Like the old key, they render only on fresh deploy (gated onrouter_tailscale_client_id), so an established router’s compose omits them and the container is not recreated for auth. However, adding--advertise-tagstoTS_EXTRA_ARGSchanges the rendered compose even on an established router → a one-time container recreate (brief tailscale restart). This is covered: the NAS redundantly advertises the same/22+exit, and the router keeps its identity (state persists) so it does NOT re-register.cap_add,network_mode: host, and the tun mount are unchanged.
- Step 2: Commit.
Task 4: Retire the SOPS secret
Section titled “Task 4: Retire the SOPS secret”Files:
-
Delete:
ansible/secrets/router-tailscale.sops.yaml -
Step 1: Remove the file (its only consumers — the role’s SOPS tasks/var — are gone):
Run: rg -n 'router-tailscale.sops|tailscale_sops_file|tailscale_auth_key' ansible/ (rg is recursive by default — do not use -r, which is ripgrep’s replace flag and corrupts output)
Expected: 0 hits after Tasks 1-3. If any remain, they’re stragglers — remove them. Then git rm ansible/secrets/router-tailscale.sops.yaml (VCS-appropriate).
- Step 2: Commit.
Task 5: Verify (syntax → dry-run → idempotent apply on the live router)
Section titled “Task 5: Verify (syntax → dry-run → idempotent apply on the live router)”Files: none.
- Step 1: Syntax check
Run: ansible-playbook -i ansible/inventory ansible/router-playbook.yml --syntax-check
Expected: no errors.
- Step 2: Dry-run against the router
Run: ansible-playbook -i ansible/inventory ansible/router-playbook.yml --tags tailscale --check --diff --limit router
Expected: the Vault read task is skipped (state exists on the live router); the compose template diff shows only the --advertise-tags addition to TS_EXTRA_ARGS (no TS_CLIENT_ID/SECRET rendered — state exists). No secret in output (no_log). (Router SSH is pi@router via the 1Password agent — no Vault-key wrapper, unlike NAS.)
- Step 3: Apply
Run: ansible-playbook -i ansible/inventory ansible/router-playbook.yml --tags tailscale --limit router
Expected: changed on the compose render + a one-time container recreate; the router’s tailscale reconnects within seconds (identity persisted). Verify: ssh pi@router 'sudo docker ps --filter name=tailscale --format "{{.Status}}"' → Up.
- Step 4: Confirm the router keeps all its tags + routes
Run (from a tailnet-connected host): tailscale status --json | python3 -c 'import sys,json;d=json.load(sys.stdin);[print(p["HostName"],p.get("Tags")) for p in d.get("Peer",{}).values() if p.get("HostName")=="router"]'
Expected: router ['tag:router', 'tag:ns', 'tag:security'] (tags intact — the recreate did not drop tag:ns/tag:security), still advertising exit + 192.168.20.0/22. A missing tag here is a FAIL (the advertise-tags set or the client tags are wrong).
- Step 5: Idempotent re-run
Run Step 3 again.
Expected (PASS): changed=0 on the compose task (rendered content now stable). A changed here is a FAIL (non-deterministic render).
Verification summary
Section titled “Verification summary”--syntax-checkclean; dry-run shows only the--advertise-tagsdiff on the established router (Vault read skipped).- Apply recreates the container once; tailscale reconnects; router retains
tag:router+tag:ns+tag:securityand its exit//22advertisement. - Idempotent re-run =
changed=0. ansible/secrets/router-tailscale.sops.yamldeleted; notailscale_auth_key/tailscale_sops_filereferences remain.- No secret in any transcript (
no_log).