Skip to content

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:

  • routertag: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.routertags = ["tag:router", "tag:ns", "tag:security"]
  • foundation tailscale_oauth_client.nastags = ["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-tailscale compose → --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-auth
router_tailscale_oauth_client_id_field: client_id
router_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:
- tailscale

The router_tailscale_client_id is not defined guard 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_bundle is 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_AUTHKEY for 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_SECRET are mutually exclusive with TS_AUTHKEY (deepwiki tailscale/tailscale) — do not set both. Like the old key, they render only on fresh deploy (gated on router_tailscale_client_id), so an established router’s compose omits them and the container is not recreated for auth. However, adding --advertise-tags to TS_EXTRA_ARGS changes 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.

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).


  • --syntax-check clean; dry-run shows only the --advertise-tags diff on the established router (Vault read skipped).
  • Apply recreates the container once; tailscale reconnects; router retains tag:router+tag:ns+tag:security and its exit//22 advertisement.
  • Idempotent re-run = changed=0.
  • ansible/secrets/router-tailscale.sops.yaml deleted; no tailscale_auth_key/tailscale_sops_file references remain.
  • No secret in any transcript (no_log).