tailscale-ops
Judgment layer for Tailscale administration. Raw API access is easy — this
skill exists to prevent the two disasters that matter: locking yourself out
of your own tailnet and applying a broken policy. Read operations are
free. Policy writes follow a strict workflow. No exceptions.
Credentials
All scripts source ~/.config/claude-homelab/credentials.env if it exists,
falling back to environment variables. Expected variables:
TAILSCALE_API_KEY=tskey-api-...
TAILSCALE_TAILNET=- # "-" means the default tailnet of the key
TAILSCALE_API_BASE= # optional — set for headscale (e.g. https://headscale.example.com)
If credentials are missing or a request returns 401: do not improvise.
Show the current state, point the user to /homelab, and stop.
If the user pastes a token into the chat: warn them immediately — the
token is now in the conversation history. Tell them to revoke it in the
admin console (Settings → Keys), then run
bash ~/.claude/skills/tailscale-ops/scripts/setup.sh in their own terminal
so the replacement never touches the chat.
API basics
Base URL: https://api.tailscale.com/api/v2. Auth: Authorization: Bearer $TAILSCALE_API_KEY.
| Action |
Endpoint |
| Read policy file |
GET /tailnet/-/acl (Accept: application/hujson) |
| Validate policy |
POST /tailnet/-/acl/validate |
| Apply policy |
POST /tailnet/-/acl (If-Match: "<etag>") |
| List devices |
GET /tailnet/-/devices |
| Device routes |
GET /device/{id}/routes |
| Approve routes |
POST /device/{id}/routes |
- in paths means "default tailnet for this key" — always usable.
Policy modification workflow (MANDATORY)
Every change to the tailnet policy file (ACLs, grants, tags, autoApprovers,
ssh, anything) follows these five steps in order. Never skip, never reorder.
- Read current policy:
GET /tailnet/-/acl with header
Accept: application/hujson to preserve comments. Save the ETag
response header.
- Propose the diff: show the user a unified diff between current and
proposed policy — never just the new file. Explain in one line per hunk
what changes and why.
- Validate:
POST /tailnet/-/acl/validate with the full proposed file.
Or run scripts/validate-policy.sh <file>. A 200 with empty body or
{"message":""} means valid. Any message content = validation error —
show it verbatim and fix before continuing.
- Anti-lockout check (see below). If it fails or cannot be verified,
refuse to apply.
- Apply only after explicit user confirmation — the user must say yes
to the diff, not to a summary. Apply with
If-Match: "<etag>" from
step 1 so a concurrent edit fails loudly instead of being clobbered.
tests and sshTests sections in the policy run at validate time. When
adding or changing ACL rules, add a tests entry asserting the access that
must keep working — validation then guards it forever.
Anti-lockout rule
Before applying ANY policy, verify the admin keeps access:
- Identify the admin's devices:
GET /tailnet/-/devices, match on the
admin's login name.
- Check the proposed policy still contains at least one rule (ACL or
grant) whose
src matches the admin user (directly, via group, or via
*) with a dst that is not empty.
- Strongest check: add a
tests block to the proposed policy asserting
the admin's access (e.g. {"src": "admin@example.com", "accept": ["tag:server:22"]}) and let /acl/validate prove it.
- If in doubt, refuse to apply. Say exactly what could not be verified.
A rejected apply costs a minute; a lockout costs the tailnet.
Special case: policies where "acls": [] and "grants": [] (deny-all) or
removal of the admin from all groups — refuse outright, require the user to
type the confirmation phrase "I understand this may lock me out".
Runbook: connectivity diagnosis
When "X can't reach Y" / "it's slow" / "ping fails" — run in this exact
order (or scripts/diagnose.sh [peer] which automates 1–3):
tailscale status --json — check Self.Online, find the peer, check
its Online, CurAddr (empty = no direct connection, using DERP),
Relay (which DERP region).
tailscale netcheck — read UDP: true|false, MappingVariesByDestIP
(true = hard NAT), nearest DERP latency, PortMapping (UPnP/NAT-PMP/PCP
available?).
tailscale ping <peer> — up to ~10 probes; watch whether it upgrades
from via DERP(xxx) to a direct via <ip>:<port> path.
- Interpret:
- Peer offline → not a network problem. Check the peer machine,
key expiry (
Expired in status JSON), tailscale up on the peer.
- DERP relay, never direct → both sides behind hard NAT / blocked
UDP. Remediation order: (a) allow outbound UDP 41641 or open port
41641/udp on one side, (b) enable UPnP/NAT-PMP on one router,
(c) accept DERP — it works, just adds latency.
UDP: false in netcheck → firewall blocks all UDP; even DERP
over UDP fails, falls back to HTTPS DERP. Fix the firewall first,
nothing else matters until UDP works.
- Reachable but wrong service → not connectivity; check ACL policy
(does a rule allow that port?) and host firewall on the peer.
- ACL suspected → read the policy, check
src/dst match; ask the
user to test with tailscale ping (layer 3, ACL-gated) vs regular
ping of the Tailscale IP.
Runbook: subnet router not routing
In order:
- Advertised vs approved:
GET /device/{id}/routes — routes appear in
advertisedRoutes but not enabledRoutes = not approved. Approve in
admin console (Machines → route settings) or
POST /device/{id}/routes with the routes to enable. Check
autoApprovers in policy for future routes.
- IP forwarding on the router host (the #1 cause):
sysctl net.ipv4.ip_forward and net.ipv6.conf.all.forwarding must
be 1. Fix: echo 'net.ipv4.ip_forward = 1' | sudo tee -a /etc/sysctl.d/99-tailscale.conf && sudo sysctl -p /etc/sysctl.d/99-tailscale.conf.
- Client side: Linux clients need
--accept-routes; check with
tailscale status --json (Self has no route info — check
tailscale debug prefs | grep RouteAll).
- Route conflicts: the advertised subnet must not overlap the
client's own LAN (two 192.168.1.0/24 = broken; renumber or use
4via6). Also check the ACL:
dst must allow the subnet CIDR, not
just tailnet IPs — autogroup:internet does NOT cover private
subnets.
- Firewall on the router host: FORWARD chain must accept, and
masquerading is handled by tailscaled unless
--snat-subnet-routes=false
was set (then the LAN needs a return route).
Decision matrix: serve vs funnel vs Cloudflare Tunnel
| Criterion |
tailscale serve |
tailscale funnel |
Cloudflare Tunnel |
| Audience |
tailnet only |
public internet |
public internet |
| Auth built in |
tailnet identity |
none (public) |
Cloudflare Access optional |
| WAF / caching / DDoS |
n/a (private) |
none |
yes |
| Custom domain |
no (ts.net) |
no (ts.net) |
yes |
| Ports |
any |
443, 8443, 10000 only |
any (via tunnel) |
| Setup cost |
one command |
one command + DNS wait (~10 min) |
cloudflared install + CF account |
Concrete rules:
- Internal dashboard, another tailnet user needs it → serve.
- Quick public demo, webhook receiver, short-lived share → funnel.
- Production public site, needs custom domain, caching, WAF, or
protection from scrapers → Cloudflare Tunnel.
- Never funnel an unauthenticated admin UI (Proxmox, router, NAS). If it
must be public, put Cloudflare Access or authentication in front.
Details and full CLI syntax: references/serve-funnel.md.
References
references/policy-syntax.md — full HuJSON policy file syntax, ACLs
AND grants (pinned from official docs; retrieval date in header).
references/acl-examples.md — official ACL examples.
references/grant-examples.md — official grants examples.
references/serve-funnel.md — serve + funnel docs.
Consult references before writing policy from memory — syntax evolves
(grants notably). If a reference contradicts memory, the reference wins.
Scripts
scripts/validate-policy.sh <file> — validates a policy file against
the API, human-readable verdict.
scripts/diagnose.sh [peer] — status + netcheck (+ ping if peer given),
parsed into a structured diagnosis with probable cause.
scripts/setup.sh — credential onboarding; run by the USER in their own
terminal, never by Claude.
Test rules (when operating on a real tailnet)
- Read operations: always allowed.
- NEVER apply a policy change to the real tailnet without the full
5-step workflow AND explicit confirmation on the diff.
- For testing policy changes,
/acl/validate is sufficient — it never
modifies anything.
1---2name: tailscale-ops3description: Safe Tailscale/tailnet administration: ACL and grants policy editing with validation-before-apply and lockout prevention, connectivity diagnostics (DERP relay, NAT traversal, netcheck), subnet router and exit node troubleshooting, serve vs funnel vs Cloudflare Tunnel decisions. Use for any question about tailscale, headscale, tailnet, ACL policy, grants, exit nodes, subnet routers, MagicDNS, mesh VPN connectivity, or exposing homelab services.4---56# tailscale-ops78Judgment layer for Tailscale administration. Raw API access is easy — this9skill exists to prevent the two disasters that matter: **locking yourself out10of your own tailnet** and **applying a broken policy**. Read operations are11free. Policy writes follow a strict workflow. No exceptions.1213## Credentials1415All scripts source `~/.config/claude-homelab/credentials.env` if it exists,16falling back to environment variables. Expected variables:1718```19TAILSCALE_API_KEY=tskey-api-...20TAILSCALE_TAILNET=- # "-" means the default tailnet of the key21TAILSCALE_API_BASE= # optional — set for headscale (e.g. https://headscale.example.com)22```2324**If credentials are missing or a request returns 401:** do not improvise.25Show the current state, point the user to `/homelab`, and stop.2627**If the user pastes a token into the chat:** warn them immediately — the28token is now in the conversation history. Tell them to revoke it in the29admin console (Settings → Keys), then run30`bash ~/.claude/skills/tailscale-ops/scripts/setup.sh` in their own terminal31so the replacement never touches the chat.3233## API basics3435Base URL: `https://api.tailscale.com/api/v2`. Auth: `Authorization: Bearer $TAILSCALE_API_KEY`.3637| Action | Endpoint |38|---|---|39| Read policy file | `GET /tailnet/-/acl` (Accept: application/hujson) |40| Validate policy | `POST /tailnet/-/acl/validate` |41| Apply policy | `POST /tailnet/-/acl` (If-Match: `"<etag>"`) |42| List devices | `GET /tailnet/-/devices` |43| Device routes | `GET /device/{id}/routes` |44| Approve routes | `POST /device/{id}/routes` |4546`-` in paths means "default tailnet for this key" — always usable.4748## Policy modification workflow (MANDATORY)4950Every change to the tailnet policy file (ACLs, grants, tags, autoApprovers,51ssh, anything) follows these five steps in order. Never skip, never reorder.52531. **Read current policy**: `GET /tailnet/-/acl` with header54 `Accept: application/hujson` to preserve comments. Save the `ETag`55 response header.562. **Propose the diff**: show the user a unified diff between current and57 proposed policy — never just the new file. Explain in one line per hunk58 what changes and why.593. **Validate**: `POST /tailnet/-/acl/validate` with the full proposed file.60 Or run `scripts/validate-policy.sh <file>`. A 200 with empty body or61 `{"message":""}` means valid. Any `message` content = validation error —62 show it verbatim and fix before continuing.634. **Anti-lockout check** (see below). If it fails or cannot be verified,64 refuse to apply.655. **Apply only after explicit user confirmation** — the user must say yes66 to the diff, not to a summary. Apply with `If-Match: "<etag>"` from67 step 1 so a concurrent edit fails loudly instead of being clobbered.6869`tests` and `sshTests` sections in the policy run at validate time. When70adding or changing ACL rules, add a `tests` entry asserting the access that71must keep working — validation then guards it forever.7273## Anti-lockout rule7475Before applying ANY policy, verify the admin keeps access:76771. Identify the admin's devices: `GET /tailnet/-/devices`, match on the78 admin's login name.792. Check the proposed policy still contains at least one rule (ACL or80 grant) whose `src` matches the admin user (directly, via group, or via81 `*`) with a `dst` that is not empty.823. Strongest check: add a `tests` block to the proposed policy asserting83 the admin's access (e.g. `{"src": "admin@example.com", "accept":84 ["tag:server:22"]}`) and let `/acl/validate` prove it.854. **If in doubt, refuse to apply.** Say exactly what could not be verified.86 A rejected apply costs a minute; a lockout costs the tailnet.8788Special case: policies where `"acls": []` and `"grants": []` (deny-all) or89removal of the admin from all groups — refuse outright, require the user to90type the confirmation phrase "I understand this may lock me out".9192## Runbook: connectivity diagnosis9394When "X can't reach Y" / "it's slow" / "ping fails" — run in this exact95order (or `scripts/diagnose.sh [peer]` which automates 1–3):96971. `tailscale status --json` — check `Self.Online`, find the peer, check98 its `Online`, `CurAddr` (empty = no direct connection, using DERP),99 `Relay` (which DERP region).1002. `tailscale netcheck` — read `UDP: true|false`, `MappingVariesByDestIP`101 (true = hard NAT), nearest DERP latency, `PortMapping` (UPnP/NAT-PMP/PCP102 available?).1033. `tailscale ping <peer>` — up to ~10 probes; watch whether it upgrades104 from `via DERP(xxx)` to a direct `via <ip>:<port>` path.1054. Interpret:106 - **Peer offline** → not a network problem. Check the peer machine,107 key expiry (`Expired` in status JSON), `tailscale up` on the peer.108 - **DERP relay, never direct** → both sides behind hard NAT / blocked109 UDP. Remediation order: (a) allow outbound UDP 41641 or open port110 41641/udp on one side, (b) enable UPnP/NAT-PMP on one router,111 (c) accept DERP — it works, just adds latency.112 - **`UDP: false` in netcheck** → firewall blocks all UDP; even DERP113 over UDP fails, falls back to HTTPS DERP. Fix the firewall first,114 nothing else matters until UDP works.115 - **Reachable but wrong service** → not connectivity; check ACL policy116 (does a rule allow that port?) and host firewall on the peer.117 - **ACL suspected** → read the policy, check `src`/`dst` match; ask the118 user to test with `tailscale ping` (layer 3, ACL-gated) vs regular119 `ping` of the Tailscale IP.120121## Runbook: subnet router not routing122123In order:1241251. **Advertised vs approved**: `GET /device/{id}/routes` — routes appear in126 `advertisedRoutes` but not `enabledRoutes` = not approved. Approve in127 admin console (Machines → route settings) or128 `POST /device/{id}/routes` with the routes to enable. Check129 `autoApprovers` in policy for future routes.1302. **IP forwarding on the router host** (the #1 cause):131 `sysctl net.ipv4.ip_forward` and `net.ipv6.conf.all.forwarding` must132 be 1. Fix: `echo 'net.ipv4.ip_forward = 1' | sudo tee -a /etc/sysctl.d/99-tailscale.conf && sudo sysctl -p /etc/sysctl.d/99-tailscale.conf`.1333. **Client side**: Linux clients need `--accept-routes`; check with134 `tailscale status --json` (`Self` has no route info — check135 `tailscale debug prefs | grep RouteAll`).1364. **Route conflicts**: the advertised subnet must not overlap the137 client's own LAN (two 192.168.1.0/24 = broken; renumber or use138 4via6). Also check the ACL: `dst` must allow the subnet CIDR, not139 just tailnet IPs — `autogroup:internet` does NOT cover private140 subnets.1415. **Firewall on the router host**: FORWARD chain must accept, and142 masquerading is handled by tailscaled unless `--snat-subnet-routes=false`143 was set (then the LAN needs a return route).144145## Decision matrix: serve vs funnel vs Cloudflare Tunnel146147| Criterion | `tailscale serve` | `tailscale funnel` | Cloudflare Tunnel |148|---|---|---|---|149| Audience | tailnet only | public internet | public internet |150| Auth built in | tailnet identity | none (public) | Cloudflare Access optional |151| WAF / caching / DDoS | n/a (private) | none | yes |152| Custom domain | no (`ts.net`) | no (`ts.net`) | yes |153| Ports | any | 443, 8443, 10000 only | any (via tunnel) |154| Setup cost | one command | one command + DNS wait (~10 min) | cloudflared install + CF account |155156Concrete rules:157- Internal dashboard, another tailnet user needs it → **serve**.158- Quick public demo, webhook receiver, short-lived share → **funnel**.159- Production public site, needs custom domain, caching, WAF, or160 protection from scrapers → **Cloudflare Tunnel**.161- Never funnel an unauthenticated admin UI (Proxmox, router, NAS). If it162 must be public, put Cloudflare Access or authentication in front.163164Details and full CLI syntax: `references/serve-funnel.md`.165166## References167168- `references/policy-syntax.md` — full HuJSON policy file syntax, ACLs169 AND grants (pinned from official docs; retrieval date in header).170- `references/acl-examples.md` — official ACL examples.171- `references/grant-examples.md` — official grants examples.172- `references/serve-funnel.md` — serve + funnel docs.173174Consult references before writing policy from memory — syntax evolves175(grants notably). If a reference contradicts memory, the reference wins.176177## Scripts178179- `scripts/validate-policy.sh <file>` — validates a policy file against180 the API, human-readable verdict.181- `scripts/diagnose.sh [peer]` — status + netcheck (+ ping if peer given),182 parsed into a structured diagnosis with probable cause.183- `scripts/setup.sh` — credential onboarding; run by the USER in their own184 terminal, never by Claude.185186## Test rules (when operating on a real tailnet)187188- Read operations: always allowed.189- NEVER apply a policy change to the real tailnet without the full190 5-step workflow AND explicit confirmation on the diff.191- For testing policy changes, `/acl/validate` is sufficient — it never192 modifies anything.