Skip to content

Tailscale ACL Relocation 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: Move the tailnet ACL (policy.hujson) and its gitops-acl-action sync into the selfhosted-cluster monorepo under tailscale/, fold in the missing autoApprovers (hl-ww73) and drop the dead tag:k8s-operator (hl-o89e), then retire the standalone fzymgc-house/tailscale-config repo.

Architecture: Lift-and-shift, not a rewrite. The tailscale/policy.hujson content is byte-identical to the standalone repo (so the first monorepo apply is a no-op), with two follow-up content edits (autoApprovers, tag removal). A path-filtered GitHub Actions workflow runs tailscale/gitops-acl-actiontest on PR, apply on push to main — authenticated by the dedicated policy_file CI OAuth client minted in the foundation plan. Cleanup deletes the orphaned operator OAuth secret and the two dead operator nodes.

Tech Stack: Tailscale HuJSON policy, tailscale/gitops-acl-action@v1 (GitHub Actions), lefthook, the Tailscale API (node/secret cleanup).

Scope note: Plan 2 of 3 for Spec A (docs/engineering/specs/2026-07-05-tailscale-config-as-code-design.md, hl-mjh0). Depends on the foundation plan (2026-07-05-tf-tailscale-foundation.md): the CI OAuth client + the TS_OAUTH_ID/TS_OAUTH_SECRET/TS_TAILNET repo Actions secrets must exist (foundation Task 7 Step 4) before this workflow runs.

Model hints (for bead materialization, Rule 5): Task 1 = model:haiku (verbatim file relocation). Task 4 = model:haiku (small lefthook entry). Tasks 2, 3, 5, 6 = model:sonnet (ACL semantics/autoApprovers, the CI workflow + path-filter correctness, the destructive cutover/cleanup, and the verification gate).

Grounding (this session): the live policy.hujson (266 lines) and tailscale-sync.yml fetched from fzymgc-house/tailscale-config; autoApprovers confirmed absent; tag:k8s-operator present in tagOwners (3 references). lefthook.yaml structure (pre-commit: commands with glob/run) read. The orphaned secret/fzymgc-house/cluster/tailscale/oauth has no tf/ or argocd/ references (pure CLI-delete orphan). Dead operator node IDs captured: nGjscWyEb611CNTRL, nT3cirzJwk11CNTRL.


Files:

  • Create: tailscale/policy.hujson (byte-identical copy of the standalone repo’s current main)

  • Step 1: Fetch the canonical current policy

Run (authoritative source is the standalone repo’s main, not a local cache):

Terminal window
mkdir -p tailscale
gh api repos/fzymgc-house/tailscale-config/contents/policy.hujson \
-H "Accept: application/vnd.github.raw" > tailscale/policy.hujson

Expected: a 266-line HuJSON file with top-level groups, hosts, acls, ssh, sshTests, tagOwners, nodeAttrs.

  • Step 2: Confirm no transformation happened

Run: gh api repos/fzymgc-house/tailscale-config/contents/policy.hujson -H "Accept: application/vnd.github.raw" | diff - tailscale/policy.hujson && echo IDENTICAL Expected: IDENTICAL (this file must match the live-applied policy so the monorepo’s first apply is a no-op).

  • Step 3: Commit the verbatim relocation (VCS per references/vcs-preamble.md). No content changes yet — those are Task 2, kept as a separate reviewable diff.

Task 2: Fold in autoApprovers + drop tag:k8s-operator

Section titled “Task 2: Fold in autoApprovers + drop tag:k8s-operator”

Files:

  • Modify: tailscale/policy.hujson

  • Step 1: Add the autoApprovers block

Add a top-level autoApprovers key (the tailnet currently has none — this is why the NAS/router /22 + exit routes require manual approval, hl-ww73). Insert after tagOwners (HuJSON key order is cosmetic):

"autoApprovers": {
"routes": {
"192.168.20.0/22": ["tag:nas", "tag:router"],
},
"exitNode": ["tag:nas", "tag:router"],
},

This auto-approves the LAN /22 subnet route and exit-node advertisement from any node tagged tag:nas or tag:router — covering both the current NAS/router nodes and the NAS’s Spec-B re-register (a freshly-registered tag:nas node is auto-approved because autoApprovers matches on tag, not node ID).

  • Step 2: Remove tag:k8s-operator

The in-cluster Tailscale operator was retired (hl-tzxo); its tag and nodes are dead (hl-o89e). Delete the tag:k8s-operator entry from tagOwners, and remove it from any other tag’s owner list. Find every reference first:

Run: rg -n 'k8s-operator' tailscale/policy.hujson Expected: 3 hits — the "tag:k8s-operator": [...] tagOwners entry, and its appearance as an owner of tag:k8s and tag:minecraft ("tag:k8s": [..., "tag:k8s-operator", ...], "tag:minecraft": [..., "tag:k8s-operator"]). Remove the standalone entry and strip it from those two owner lists. Re-run the rg — expect 0 hits.

Do NOT touch tag:k8s itself (still in use) — only remove tag:k8s-operator.

  • Step 3: Local syntax sanity-check

Run: python3 -c "import re,sys; s=open('tailscale/policy.hujson').read(); s=re.sub(r'//.*','',s); s=re.sub(r',(\s*[}\]])',r'\1',s); import json; json.loads(s); print('parses')" Expected: parses (strips // comments + trailing commas, then JSON-validates — a cheap local guard; the authoritative validation is the CI test step in Task 3).

  • Step 4: Commit.

Task 3: ACL-sync GitHub Actions workflow (path-filtered)

Section titled “Task 3: ACL-sync GitHub Actions workflow (path-filtered)”

Files:

  • Create: .github/workflows/tailscale-acl-sync.yml

  • Step 1: Write the workflow

Adapts the standalone repo’s tailscale-sync.yml, adding a path filter so it only runs on ACL changes (critical in a high-traffic monorepo where apply-on-push would otherwise fire every merge). Pin the action to the same digest the standalone repo used (Renovate keeps it current after landing):

# SPDX-License-Identifier: MIT-0
name: Sync Tailscale ACLs
on:
push:
branches: [main]
paths: ["tailscale/**"]
pull_request:
branches: [main]
paths: ["tailscale/**"]
workflow_dispatch: {}
jobs:
acls:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Test ACL
if: github.event_name == 'pull_request' || github.event_name == 'push'
uses: tailscale/gitops-acl-action@5a4a17f5708e9bf96f4ee915a95e9f83c2eebe1a # v1
with:
oauth-client-id: ${{ secrets.TS_OAUTH_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tailnet: ${{ secrets.TS_TAILNET }}
policy-file: tailscale/policy.hujson
action: test
- name: Deploy ACL
if: github.event_name == 'push'
uses: tailscale/gitops-acl-action@5a4a17f5708e9bf96f4ee915a95e9f83c2eebe1a # v1
with:
oauth-client-id: ${{ secrets.TS_OAUTH_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tailnet: ${{ secrets.TS_TAILNET }}
policy-file: tailscale/policy.hujson
action: apply

The policy-file input is required because the policy is no longer at the repo-root default path. Confirm the input name against the action’s action.yml at the pinned digest: gh api repos/tailscale/gitops-acl-action/contents/action.yml -H "Accept: application/vnd.github.raw" | rg -n 'policy-file|policy_file' — adjust if the action uses a different key.

  • Step 2: Confirm the checkout action digest is still current

The snippet above uses this repo’s current standard actions/checkout digest (@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0). Confirm it still matches the rest of the repo (Renovate reconciles later): rg -n 'actions/checkout@' .github/workflows/ | head -1 (rg is recursive by default — do not add -r, which is ripgrep’s replace flag and would corrupt the output).

  • Step 3: yamllint (repo pre-commit will run it): yamllint -c .yamllint.yaml .github/workflows/tailscale-acl-sync.yml — expect clean. Commit.

Files:

  • Modify: lefthook.yaml

  • Step 1: Add a pre-commit command for the policy file

The standalone repo’s pre-commit did only generic hygiene (trailing-whitespace, eof-fixer). HuJSON semantic/ACL validation is owned by the CI test step (Task 3) — it needs live API creds and cannot run locally. So lefthook’s job here is only cheap local hygiene: verify the file still parses as HuJSON before commit. Add under pre-commit.commands (match the existing glob/run shape):

tailscale-hujson:
glob: "tailscale/*.hujson"
run: |
python3 -c "import re,sys; s=open('{staged_files}').read(); s=re.sub(r'//.*','',s); s=re.sub(r',(\s*[}\]])',r'\1',s); import json; json.loads(s)"

{staged_files} expands to the matched file(s); if multiple .hujson files ever exist, split per-file. This is the same tolerant parse as Task 2 Step 3, wired as a guard. It does NOT replace the CI test (which validates ACL semantics + runs sshTests).

  • Step 2: Verify the hook fires

Run: lefthook run pre-commit --files tailscale/policy.hujson Expected: the tailscale-hujson command runs and passes. Commit.


Task 5: Cutover — CI secrets, retire the standalone repo, cleanup + docs

Section titled “Task 5: Cutover — CI secrets, retire the standalone repo, cleanup + docs”

Files:

  • Modify: docs/reference/secrets.md (remove the cluster/tailscale/oauth row — Step 6)
  • Modify: docs/reference/services.md (remove the cluster/tailscale/oauth Vault-Path reference — Step 6)

Prerequisite: the foundation plan’s Task 7 Step 4 must have set TS_OAUTH_ID/TS_OAUTH_SECRET/TS_TAILNET as repo Actions secrets. Confirm: gh secret list --repo fzymgc-house/selfhosted-cluster | rg TS_OAUTH (expect all three).

  • Step 1: Disable the standalone repo’s workflow FIRST

Two apply jobs must never race the same tailnet. Before the monorepo workflow can apply, disable the old one:

Terminal window
gh workflow disable "Sync Tailscale ACLs" --repo fzymgc-house/tailscale-config

Expected: the standalone tailscale-sync.yml no longer triggers.

  • Step 2: Merge this plan’s PR → confirm the monorepo apply is a no-op

On merge to main, the path-filtered workflow’s Deploy ACL runs. Because Task 1 relocated the policy verbatim and Task 2’s edits are additive/removal-of-dead, the applied policy should be logically identical except the intended autoApprovers addition + tag:k8s-operator removal. Check the Actions run log: apply succeeds, reports the diff it pushed.

  • Step 3: Archive the standalone repo
Terminal window
gh repo archive fzymgc-house/tailscale-config --yes

(Its aclsExternalLink is superseded by the foundation plan’s tailscale_tailnet_settings.acls_external_link → the monorepo path.)

  • Step 4: Delete the orphaned operator OAuth secret (hl-o89e)

No tf/ or argocd/ consumer references it (verified). Delete via CLI (needs a token with delete perms on the path):

Terminal window
vault kv metadata delete secret/fzymgc-house/cluster/tailscale/oauth

Expected: deleted. Also revoke the corresponding operator OAuth client in the Tailscale admin console (Settings → OAuth clients) if still present.

  • Step 5: Delete the two dead operator nodes (hl-o89e)

With tag:k8s-operator gone from the ACL, the offline operator nodes are orphaned. Delete them via the API (token from the bootstrap client’s exchange, as used during inventory):

Terminal window
for id in nGjscWyEb611CNTRL nT3cirzJwk11CNTRL; do
curl -s -X DELETE -H "Authorization: Bearer $TOKEN" "https://api.tailscale.com/api/v2/device/$id"
done

Expected: HTTP 200 each. Re-confirm they’re gone: the device list no longer shows tailscale-operator.

  • Step 6: Update the reference docs (top-level CLAUDE.md mandates docs updates for operational changes)

secret/fzymgc-house/cluster/tailscale/oauth no longer exists after Step 4, but two pages still document it as live. Remove those rows:

  • docs/reference/secrets.md — delete the | secret/fzymgc-house/cluster/tailscale/oauth | Tailscale operator OAuth | … | row.
  • docs/reference/services.md — delete/repoint the Vault Path: secret/fzymgc-house/cluster/tailscale/oauth reference in the (now-decommissioned) Tailscale-operator entry.

Find both first: rg -n 'cluster/tailscale/oauth' docs/reference/secrets.md docs/reference/services.md (expect 1 hit each). Commit the doc edits with this task.


Files: none.

  • Step 1: PR test gate

On the PR, the Test ACL job runs gitops-acl-action ... action: test against tailscale/policy.hujson — validates syntax + runs the sshTests block against the live tailnet. Must be green before merge. A failure here (e.g. an sshTests expectation broken by the tag:k8s-operator removal) is a FAIL — reconcile before merging.

  • Step 2: autoApprovers effective

After merge/apply, confirm the routes auto-approve. Fastest check: in the admin console the NAS/router 192.168.20.0/22 + exit routes show “auto-approved” (no pending-approval badge). Definitive test happens on the NAS re-register (Spec B).

  • Step 3: Console lock + link

Admin console → Access Controls shows externally-managed (from the foundation plan) with the link → the monorepo tailscale/policy.hujson, and the editor is read-only. The monorepo apply still succeeds via API despite the lock.

  • Step 4: No orphans remain

rg -n 'k8s-operator' tailscale/policy.hujson → 0 hits; device list has no tailscale-operator; vault kv get secret/fzymgc-house/cluster/tailscale/oauth → not found; fzymgc-house/tailscale-config shows archived.


  • tailscale/policy.hujson present, test green on PR, apply no-op-plus-intended-diff on merge.
  • autoApprovers block present (hl-ww73); tag:k8s-operator gone everywhere (hl-o89e).
  • Standalone repo workflow disabled then repo archived (no double-apply).
  • Orphaned cluster/tailscale/oauth secret + client revoked; 2 dead operator nodes deleted.
  • Admin console externally-managed, link → monorepo, editor locked.