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-action — test 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.
Task 1: Relocate policy.hujson verbatim
Section titled “Task 1: Relocate policy.hujson verbatim”Files:
-
Create:
tailscale/policy.hujson(byte-identical copy of the standalone repo’s currentmain) -
Step 1: Fetch the canonical current policy
Run (authoritative source is the standalone repo’s main, not a local cache):
mkdir -p tailscalegh api repos/fzymgc-house/tailscale-config/contents/policy.hujson \ -H "Accept: application/vnd.github.raw" > tailscale/policy.hujsonExpected: 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
autoApproversblock
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:k8sitself (still in use) — only removetag: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-0name: 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: applyThe
policy-fileinput is required because the policy is no longer at the repo-root default path. Confirm the input name against the action’saction.ymlat 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.
Task 4: Fold HuJSON hygiene into lefthook
Section titled “Task 4: Fold HuJSON hygiene into lefthook”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.hujsonfiles 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 CItest(which validates ACL semantics + runssshTests).
- 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 thecluster/tailscale/oauthrow — Step 6) - Modify:
docs/reference/services.md(remove thecluster/tailscale/oauthVault-Path reference — Step 6)
Prerequisite: the foundation plan’s Task 7 Step 4 must have set
TS_OAUTH_ID/TS_OAUTH_SECRET/TS_TAILNETas 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:
gh workflow disable "Sync Tailscale ACLs" --repo fzymgc-house/tailscale-configExpected: the standalone tailscale-sync.yml no longer triggers.
- Step 2: Merge this plan’s PR → confirm the monorepo
applyis 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
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):
vault kv metadata delete secret/fzymgc-house/cluster/tailscale/oauthExpected: 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):
for id in nGjscWyEb611CNTRL nT3cirzJwk11CNTRL; do curl -s -X DELETE -H "Authorization: Bearer $TOKEN" "https://api.tailscale.com/api/v2/device/$id"doneExpected: 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.mdmandates 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 theVault Path: secret/fzymgc-house/cluster/tailscale/oauthreference 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.
Task 6: Verify
Section titled “Task 6: Verify”Files: none.
- Step 1: PR
testgate
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.
Verification summary
Section titled “Verification summary”tailscale/policy.hujsonpresent,testgreen on PR,applyno-op-plus-intended-diff on merge.autoApproversblock present (hl-ww73);tag:k8s-operatorgone everywhere (hl-o89e).- Standalone repo workflow disabled then repo archived (no double-apply).
- Orphaned
cluster/tailscale/oauthsecret + client revoked; 2 dead operator nodes deleted. - Admin console externally-managed, link → monorepo, editor locked.