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

- Skill: `enzojol/tailscale-ops` (Agent Skill, multi-file: 8 files)
- Install (CLI): `npx skillmds@latest add enzojol/tailscale-ops`
- Raw SKILL.md: https://api.skillmd.com/api/skills/enzojol/tailscale-ops/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: Enzojol (https://skillmd.com/u/enzojol)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/enzojol/tailscale-ops

---


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

1. **Read current policy**: `GET /tailnet/-/acl` with header
   `Accept: application/hujson` to preserve comments. Save the `ETag`
   response header.
2. **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.
3. **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.
4. **Anti-lockout check** (see below). If it fails or cannot be verified,
   refuse to apply.
5. **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:

1. Identify the admin's devices: `GET /tailnet/-/devices`, match on the
   admin's login name.
2. 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.
3. 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.
4. **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):

1. `tailscale status --json` — check `Self.Online`, find the peer, check
   its `Online`, `CurAddr` (empty = no direct connection, using DERP),
   `Relay` (which DERP region).
2. `tailscale netcheck` — read `UDP: true|false`, `MappingVariesByDestIP`
   (true = hard NAT), nearest DERP latency, `PortMapping` (UPnP/NAT-PMP/PCP
   available?).
3. `tailscale ping <peer>` — up to ~10 probes; watch whether it upgrades
   from `via DERP(xxx)` to a direct `via <ip>:<port>` path.
4. 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:

1. **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.
2. **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`.
3. **Client side**: Linux clients need `--accept-routes`; check with
   `tailscale status --json` (`Self` has no route info — check
   `tailscale debug prefs | grep RouteAll`).
4. **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.
5. **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.

