---
name: tailscale
description: "Manage an ACG-administered Tailscale tailnet via the Tailscale REST API v2: list/inspect devices, delete a device/node, authorize a device, list/create/revoke tagged pre-auth keys. ADMIN rights (add + delete). Reads run freely; every device delete/authorize and key create/delete is gated --confirm. Triggers: tailscale, tailnet, add device, delete device/node, revoke/create auth key, tailscale offboard."
---

# Tailscale Skill

Standalone bash CLI for a Tailscale tailnet that ACG administers, via the **Tailscale
REST API v2** (`https://api.tailscale.com/api/v2`). Read-only by default; every
destructive or state-changing operation (delete a device, authorize a device, create an
auth key, revoke an auth key) is **gated behind `--confirm`** and prints a preview first.

This skill implements the API side of ACG's **per-client tailnet doctrine** — see
`wiki/patterns/tailscale-client-management.md`. One tailnet per client, ACG holds an
Admin/Owner seat, devices enroll as **tagged nodes via reusable + pre-approved pre-auth
keys** pushed from GuruRMM. This skill is how you drive that tailnet from the terminal:
mint the tagged keys, watch devices land, authorize/prune nodes, and revoke keys at
offboarding.

## Running the CLI

```bash
TS="bash $CLAUDETOOLS_ROOT/.claude/skills/tailscale/scripts/tailscale-api.sh"
$TS status                                   # auth check + tailnet + device count
$TS devices                                  # list devices (table)
$TS devices --json                           # machine output
$TS device <name|id|100.x>                   # device detail
$TS keys                                      # list auth keys
$TS create-key --tag tag:roberts --reusable --preauth --expiry-days 90 --desc "onboard" --confirm
$TS delete-key <keyId> --confirm             # revoke an auth key
$TS delete-device <name|id|100.x> --confirm  # remove a node
$TS authorize <name|id|100.x> --confirm      # approve a device
$TS --help                                    # full usage (runs with no creds)
```

`--vault <path>` selects a different vault entry (per-client tailnets each have their own).
`CLAUDETOOLS_ROOT` resolves from the env var, else the repo root via `git rev-parse`,
else `C:/claudetools`.

## Credentials & auth (vault-first, OAuth preferred)

The bearer is NEVER hardcoded. At runtime it is resolved from the SOPS vault entry
`tailscale/api-access.sops.yaml` (override with `--vault` or `TAILSCALE_VAULT`). Two
credential shapes are supported, **OAuth preferred**:

- **OAuth client (preferred, non-expiring):** `credentials.client_id` +
  `credentials.client_secret`. The CLI POSTs
  `grant_type=client_credentials&client_id=...&client_secret=...` (secret fed over
  **stdin**, never argv) to `/oauth/token` and uses the returned short-lived
  `access_token` as the bearer. Required scopes: **`devices:core`** (list/delete/authorize
  devices) and **`auth_keys`** (create/list/delete keys).
- **Raw API access token (fallback):** `credentials.api_key` (a `tskey-api-...` token)
  used directly as `Authorization: Bearer <token>`.

**Tailnet** is a path segment, resolved (first hit wins) from: env `TAILSCALE_TAILNET`,
`credentials.tailnet`, top-level plaintext `tailnet:`, else default **`-`** (the default
tailnet of the authenticated principal). Set a specific org name (e.g. `example.com`) only
when the account administers more than one tailnet.

The bearer is written to a `0600` temp curl config (`-K`) and removed on exit, so it never
appears in argv, the process list, or stdout.

### Provision the vault entry (admin, one-time — entry does NOT exist yet)

OAuth client (preferred):

```bash
bash .claude/skills/vault/scripts/vault-helper.sh new tailscale/api-access.sops.yaml \
  --kind oauth-client --name "ACG Tailscale API (OAuth client)" \
  --url https://api.tailscale.com/api/v2 --tag tailscale \
  --set client_id='<OAUTH_CLIENT_ID>' \
  --set client_secret='<OAUTH_CLIENT_SECRET>' \
  --set tailnet='-'
```

Raw API access token (fallback):

```bash
bash .claude/skills/vault/scripts/vault-helper.sh new tailscale/api-access.sops.yaml \
  --kind api-key --name "ACG Tailscale API (access token)" \
  --url https://api.tailscale.com/api/v2 --tag tailscale \
  --set api_key='tskey-api-XXXXXXXXXXXXXXXX' \
  --set tailnet='-'
```

`vault-helper.sh new` places every `--set` under the encrypted `credentials:` block, so
`tailnet` reads back via `credentials.tailnet` (the CLI also honors a top-level plaintext
`tailnet:` if an admin prefers it in cleartext). After creating: `vault-helper.sh verify
tailscale/api-access.sops.yaml`, then publish with `bash .claude/scripts/sync.sh`. For a
**per-client** tailnet, store its own entry (e.g. `tailscale/roberts.sops.yaml`) and pass
`--vault tailscale/roberts.sops.yaml`.

## Endpoint map (Tailscale REST API v2)

| Subcommand | Method + path | Notes |
|---|---|---|
| `devices` | `GET /tailnet/{tailnet}/devices?fields=all` | full device inventory |
| `device <q>` | `GET /device/{deviceId}?fields=all` | detail after name->id resolve |
| `delete-device <q>` | `DELETE /device/{deviceId}` | **GATED** |
| `authorize <q>` | `POST /device/{deviceId}/authorized` `{"authorized":true}` | **GATED** |
| `keys` | `GET /tailnet/{tailnet}/keys` | list auth keys |
| `create-key` | `POST /tailnet/{tailnet}/keys` | **GATED**; tagged reusable/preauth pattern |
| `delete-key <keyId>` | `DELETE /tailnet/{tailnet}/keys/{keyId}` | **GATED** revoke |
| `status` | `GET .../devices` | reachability + counts |

`create-key` body (matches the wiki's reusable + pre-approved + tagged pattern):

```json
{"capabilities":{"devices":{"create":{"reusable":true,"ephemeral":false,"preauthorized":true,"tags":["tag:roberts"]}}},"expirySeconds":7776000,"description":"onboard roberts"}
```

Built from `--tag` (repeatable), `--reusable`, `--preauth`, `--ephemeral`,
`--expiry-days N` (default 90), `--desc`. The created key secret is returned **once** — the
CLI prints a `[WARNING]` and the secret; store it in the vault immediately, never in
chat/commit.

## Hard rules

- **Read before write.** Device ops accept a NAME, the stable numeric/node id, or a 100.x
  address; the name is resolved to an id against the live device list before any action.
- **Gated destructive ops.** `delete-device`, `authorize`, `create-key`, `delete-key`
  refuse to run without `--confirm` — they print the exact preview and exit 3. Never run a
  delete casually; deleting a node drops it off the tailnet.
- **Ambiguous-match STOP.** If a device query matches >1 device, the CLI lists the
  candidates and refuses to act (exit 4). Re-run with a specific id or a unique name/address.
- **Per-client isolation.** Never merge a client into ACG's own tailnet and never share one
  tailnet across clients — one tailnet per client, one vault entry per tailnet
  (`wiki/patterns/tailscale-client-management.md`). Confirm you are pointed at the right
  `--vault`/tailnet before a write.
- **Secrets stay out of argv/stdout/logs.** Bearer via `-K` config file; OAuth secret via
  stdin. Do not paste key secrets into tickets/commits — vault the path.

## Bot alert (after every write)

Mirroring the `/rmm` and `screenconnect` skills, every successful **write** posts a
one-line `[TAILSCALE] ...` alert via `.claude/scripts/post-bot-alert.sh` (soft-fail,
ASCII only). Read operations (`status`, `devices`, `device`, `keys`) do NOT alert. Covered
writes: `delete-device`, `authorize`, `create-key`, `delete-key`.

## Verified API response shapes

- **Device (list item / detail):** `id` (stable numeric string), `nodeId`, `name` (MagicDNS
  FQDN), `hostname`, `addresses` (`["100.x.y.z", "fd7a:..."]`), `os`, `user`, `tags`
  (`["tag:roberts"]`), `authorized` (bool), `lastSeen`, `clientVersion`, `updateAvailable`.
  Device ops use `id`.
- **Auth key (list / create):** `id` (keyId), `key` (the secret — **create only, once**),
  `created`, `expires`, `capabilities.devices.create.{reusable,ephemeral,preauthorized,tags}`,
  `description`.
- **Errors:** JSON `{"message":"..."}` with a non-2xx status.

## Error table

| Symptom | Cause | Fix |
|---|---|---|
| `[BLOCKED] no Tailscale credential` | vault entry missing / wrong fields | provision per above; check `--vault` path |
| `OAuth token exchange failed (HTTP 401)` | bad client_id/secret or missing scopes | verify OAuth client has `devices:core` + `auth_keys` |
| `HTTP 403` on a write | token/OAuth scope lacks the capability | add `auth_keys` (keys) / `devices:core` (devices) scope |
| `HTTP 404` on device/key | wrong id, or wrong tailnet | check `status` tailnet; re-resolve by name |
| `AMBIGUOUS (>N matches)` | name matches several nodes | pass a specific id or a unique name/address |
| `no device matches` | typo / device gone | run `devices` to see current inventory |

On a GENUINE functional error (auth failure, non-2xx API response) the CLI logs to
`errorlog.md` via `log-skill-error.sh` (soft-fail). Expected/handled conditions (a `--confirm`
gate blocking, an empty match, a preview) are NOT logged.

## Reference

- Doctrine: `wiki/patterns/tailscale-client-management.md` (per-client tailnets, tagged
  pre-auth keys, enrollment via GuruRMM, offboarding).
- Enrollment script: `wiki/patterns/tailscale-client-enroll.ps1`.
- Tailscale API docs: https://tailscale.com/api and https://tailscale.com/kb/1085/auth-keys
