Files
claudetools/.claude/skills/tailscale/SKILL.md
Howard Enos d5f7eadf82 sync: auto-sync from HOWARD-HOME at 2026-07-06 08:29:31
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-06 08:29:31
2026-07-06 08:30:01 -07:00

172 lines
9.2 KiB
Markdown

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