Tailscale ops
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.From its SKILL.md
npx -y skills add Enzojol/claude-homelab-skills --skill tailscale-opsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- 22 days oldThe repository was created 22 days ago. New is not bad, but a brand new repository carrying a familiar-sounding name is the shape a typosquat arrives in, and there has been no time for anyone else to find a problem with it.
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
9.3 KB, ~2.4k tokens by cl100k_base, as published. Nobody here has run it
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/-/aclwith headerAccept: application/hujsonto preserve comments. Save theETagresponse 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/validatewith the full proposed file. Or runscripts/validate-policy.sh <file>. A 200 with empty body or{"message":""}means valid. Anymessagecontent = 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
srcmatches the admin user (directly, via group, or via*) with adstthat is not empty. - Strongest check: add a
testsblock to the proposed policy asserting the admin's access (e.g.{"src": "[email protected]", "accept": ["tag:server:22"]}) and let/acl/validateprove 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— checkSelf.Online, find the peer, check itsOnline,CurAddr(empty = no direct connection, using DERP),Relay(which DERP region).tailscale netcheck— readUDP: 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 fromvia DERP(xxx)to a directvia <ip>:<port>path.- Interpret:
- Peer offline → not a network problem. Check the peer machine,
key expiry (
Expiredin status JSON),tailscale upon 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: falsein 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/dstmatch; ask the user to test withtailscale ping(layer 3, ACL-gated) vs regularpingof the Tailscale IP.
- Peer offline → not a network problem. Check the peer machine,
key expiry (
Runbook: subnet router not routing
In order:
- Advertised vs approved:
GET /device/{id}/routes— routes appear inadvertisedRoutesbut notenabledRoutes= not approved. Approve in admin console (Machines → route settings) orPOST /device/{id}/routeswith the routes to enable. CheckautoApproversin policy for future routes. - IP forwarding on the router host (the #1 cause):
sysctl net.ipv4.ip_forwardandnet.ipv6.conf.all.forwardingmust 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 withtailscale status --json(Selfhas no route info — checktailscale 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:
dstmust allow the subnet CIDR, not just tailnet IPs —autogroup:internetdoes NOT cover private subnets. - Firewall on the router host: FORWARD chain must accept, and
masquerading is handled by tailscaled unless
--snat-subnet-routes=falsewas 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/validateis sufficient — it never modifies anything.
What ships with it: 7 files
188.5 KB alongside SKILL.md, 3 of them executable
references/
- acl-examples.md51.8 KB
- grant-examples.md43.7 KB
- policy-syntax.md61.9 KB
- serve-funnel.md24.9 KB
scripts/
- diagnose.shruns3.9 KB
- setup.shruns476 B
- validate-policy.shruns1.9 KB