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
This commit is contained in:
2026-07-06 08:30:01 -07:00
parent c289ba3370
commit d5f7eadf82
14 changed files with 1069 additions and 0 deletions

View File

@@ -0,0 +1,171 @@
---
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

View File

@@ -0,0 +1,293 @@
#!/usr/bin/env bash
# tailscale-api.sh — CLI for the ACG Tailscale tailnet via the Tailscale REST API v2
# (base: https://api.tailscale.com/api/v2). Read-only by default; every device
# delete/authorize and every auth-key create/delete is GATED behind --confirm.
#
# AUTH (resolved from the SOPS vault, OAuth preferred, token fallback):
# vault path: tailscale/api-access.sops.yaml (override: --vault <path> or env TAILSCALE_VAULT)
# credentials.client_id + credentials.client_secret -> OAuth client (preferred, non-expiring)
# POST /oauth/token -> short-lived bearer
# credentials.api_key -> raw API access token (tskey-api-...) fallback
# tailnet (path segment): credentials.tailnet | top-level plaintext `tailnet:` | env
# TAILSCALE_TAILNET | default `-` (the authenticated principal's default tailnet).
#
# Secrets never touch argv/stdout: the bearer is written to a 0600 curl config file
# (-K) and the OAuth secret is fed to curl over stdin (--data @-).
#
# Subcommands:
# status auth check + tailnet + device count
# devices [--json] list devices (name/id/addr/os/user/lastSeen/tags/update/online)
# device <name|id|100.x> [--json] device detail
# delete-device <name|id|100.x> [--confirm] DELETE a device (GATED)
# authorize <name|id|100.x> [--confirm] POST authorized=true (GATED)
# keys [--json] list auth keys
# create-key [--tag tag:x]... [--reusable] [--preauth] [--ephemeral]
# [--expiry-days N] [--desc "..."] [--confirm] create an auth key (GATED)
# delete-key <keyId> [--confirm] DELETE/revoke an auth key (GATED)
#
# Device args 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. On an AMBIGUOUS match (>1 device) the
# candidates are printed and the op STOPS — it never acts on a guess.
#
# Usage: bash .claude/skills/tailscale/scripts/tailscale-api.sh <subcommand> [args] [--confirm]
# Runs `--help` with no credentials.
set -uo pipefail
API="https://api.tailscale.com/api/v2"
OAUTH_URL="https://api.tailscale.com/api/v2/oauth/token"
REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDETOOLS_ROOT:-C:/claudetools}")"
VAULT_SH="$REPO/.claude/scripts/vault.sh"
ALERT_SH="$REPO/.claude/scripts/post-bot-alert.sh"
logerr() { bash "$REPO/.claude/scripts/log-skill-error.sh" "tailscale" "$1" ${2:+--context "$2"} >/dev/null 2>&1 || true; }
usage() {
sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//'
exit "${1:-0}"
}
# ---- arg parse (global) -------------------------------------------------------
VAULT_PATH="${TAILSCALE_VAULT:-tailscale/api-access.sops.yaml}"
CONFIRM=0; JSON=0; TAGS=(); REUSABLE=false; PREAUTH=false; EPHEMERAL=false
EXPIRY_DAYS=90; DESC=""; POS=()
while [ $# -gt 0 ]; do
case "$1" in
-h|--help|help) usage 0;;
--vault) VAULT_PATH="${2:?--vault needs a path}"; shift 2;;
--confirm) CONFIRM=1; shift;;
--json) JSON=1; shift;;
--tag) TAGS+=("${2:?--tag needs a value e.g. tag:roberts}"); shift 2;;
--reusable) REUSABLE=true; shift;;
--preauth) PREAUTH=true; shift;;
--ephemeral) EPHEMERAL=true; shift;;
--expiry-days) EXPIRY_DAYS="${2:?--expiry-days needs N}"; shift 2;;
--desc) DESC="${2:-}"; shift 2;;
*) POS+=("$1"); shift;;
esac
done
SUB="${POS[0]:-}"; [ -n "$SUB" ] || usage 2
command -v jq >/dev/null 2>&1 || { echo "[ERROR] jq is required"; exit 2; }
command -v curl >/dev/null 2>&1 || { echo "[ERROR] curl is required"; exit 2; }
# ---- credentials + bearer -----------------------------------------------------
CURL_CFG="$(mktemp)"; chmod 600 "$CURL_CFG" 2>/dev/null || true
trap 'rm -f "$CURL_CFG"' EXIT
vget() { bash "$VAULT_SH" get-field "$VAULT_PATH" "$1" 2>/dev/null; }
TAILNET="${TAILSCALE_TAILNET:-}"
BEARER=""
resolve_auth() {
[ -n "$TAILNET" ] || TAILNET="$(vget credentials.tailnet)"
[ -n "$TAILNET" ] || TAILNET="$(vget tailnet)"
[ -n "$TAILNET" ] || TAILNET="-"
local cid csec key
cid="$(vget credentials.client_id)"; csec="$(vget credentials.client_secret)"
if [ -n "$cid" ] && [ -n "$csec" ]; then
# OAuth: secret over stdin (not argv); scopes devices:core + auth_keys.
local out code body
out="$(printf 'grant_type=client_credentials&client_id=%s&client_secret=%s' "$cid" "$csec" \
| curl -sS --data @- -w $'\n%{http_code}' "$OAUTH_URL" 2>/dev/null)"
code="${out##*$'\n'}"; body="${out%$'\n'*}"
if [ "$code" = "200" ]; then
BEARER="$(printf '%s' "$body" | jq -r '.access_token // empty')"
fi
[ -n "$BEARER" ] || { echo "[ERROR] OAuth token exchange failed (HTTP $code) at vault:$VAULT_PATH"; logerr "OAuth token exchange failed" "http=$code vault=$VAULT_PATH"; exit 2; }
return 0
fi
key="$(vget credentials.api_key)"
if [ -n "$key" ]; then BEARER="$key"; return 0; fi
echo "[BLOCKED] no Tailscale credential at vault:$VAULT_PATH"
echo " need credentials.client_id + credentials.client_secret (OAuth) OR credentials.api_key (token)."
exit 2
}
resolve_auth
printf 'header = "Authorization: Bearer %s"\n' "$BEARER" > "$CURL_CFG"
# ---- API wrapper (HTTP_CODE / HTTP_BODY) --------------------------------------
HTTP_CODE=""; HTTP_BODY=""
api() { # METHOD PATH [JSON_BODY]
local method="$1" path="$2" data="${3:-}" out
local args=(-sS -X "$method" -H "Accept: application/json" -K "$CURL_CFG" -w $'\n%{http_code}')
if [ -n "$data" ]; then
out="$(printf '%s' "$data" | curl "${args[@]}" -H "Content-Type: application/json" --data-binary @- "$API/$path" 2>/dev/null)"
else
out="$(curl "${args[@]}" "$API/$path" 2>/dev/null)"
fi
HTTP_CODE="${out##*$'\n'}"; HTTP_BODY="${out%$'\n'*}"
}
api_ok() { case "$HTTP_CODE" in 2*) return 0;; *) return 1;; esac; }
api_fail() { # context
echo "[ERROR] Tailscale API $1 -> HTTP $HTTP_CODE"
printf '%s\n' "$HTTP_BODY" | jq -r '.message // .' 2>/dev/null || printf '%s\n' "$HTTP_BODY"
logerr "API $1 HTTP $HTTP_CODE" "tailnet=$TAILNET vault=$VAULT_PATH"
}
alert() { [ -f "$ALERT_SH" ] && bash "$ALERT_SH" "$1" >/dev/null 2>&1 || true; }
# ---- device list + name->id resolution ----------------------------------------
DEVLIST_JSON=""
load_devices() {
[ -n "$DEVLIST_JSON" ] && return 0
api GET "tailnet/$TAILNET/devices?fields=all"
api_ok || { api_fail "list devices"; exit 2; }
DEVLIST_JSON="$HTTP_BODY"
}
# echoes the resolved device id on success; on none/ambiguous prints to stderr, non-zero.
resolve_device() {
local q="$1"; load_devices
local filt='.devices[] | select(%s) | .id'
local exact
exact="$(printf '%s' "$DEVLIST_JSON" | jq -r --arg q "$q" \
'.devices[] | select((.id==$q) or (.nodeId==$q) or ((.addresses//[])|index($q))
or ((.hostname//""|ascii_downcase)==($q|ascii_downcase))
or ((.name//""|ascii_downcase|split(".")[0])==($q|ascii_downcase))) | .id')"
local n; n="$(printf '%s\n' "$exact" | grep -c . || true)"
if [ "$n" -eq 1 ]; then printf '%s' "$exact"; return 0; fi
if [ "$n" -eq 0 ]; then
# substring fallback on name/hostname
exact="$(printf '%s' "$DEVLIST_JSON" | jq -r --arg q "$q" \
'.devices[] | select(((.name//""|ascii_downcase)|contains($q|ascii_downcase))
or ((.hostname//""|ascii_downcase)|contains($q|ascii_downcase))) | .id')"
n="$(printf '%s\n' "$exact" | grep -c . || true)"
fi
if [ "$n" -eq 1 ]; then printf '%s' "$exact"; return 0; fi
if [ "$n" -eq 0 ]; then echo "[ERROR] no device matches '$q' in tailnet $TAILNET" >&2; return 3; fi
echo "[BLOCKED] '$q' is AMBIGUOUS ($n matches) — refusing to act. Candidates:" >&2
printf '%s' "$DEVLIST_JSON" | jq -r --arg q "$q" \
'.devices[] | select(((.name//""|ascii_downcase)|contains($q|ascii_downcase))
or ((.hostname//""|ascii_downcase)|contains($q|ascii_downcase)))
| " " + .id + " " + (.name//"?") + " " + ((.addresses//[])|join(","))' >&2
return 4
}
dev_label() { # id -> "name (addr)"
printf '%s' "$DEVLIST_JSON" | jq -r --arg id "$1" \
'.devices[] | select(.id==$id) | (.name//"?") + " (" + ((.addresses//[])|join(","))+")"'
}
# ---- subcommands --------------------------------------------------------------
case "$SUB" in
status)
load_devices
local_count="$(printf '%s' "$DEVLIST_JSON" | jq -r '.devices | length')"
echo "[OK] Tailscale API reachable"
echo " tailnet : $TAILNET"
echo " auth : $([ -n "$(vget credentials.client_id)" ] && echo OAuth-client || echo api-token)"
echo " devices : $local_count"
;;
devices)
load_devices
if [ "$JSON" = 1 ]; then printf '%s\n' "$DEVLIST_JSON" | jq '.'; exit 0; fi
printf '%s' "$DEVLIST_JSON" | jq -r '
.devices | sort_by(.name) | .[]
| [ (.name//"?"), .id, ((.addresses//[])|join(",")), (.os//"?"),
(.user//"?"), (.lastSeen//"?"), ((.tags//[])|join(",")),
(if .updateAvailable then "update" else "-" end) ] | @tsv' \
| { echo -e "NAME\tID\tADDRESSES\tOS\tUSER\tLASTSEEN\tTAGS\tUPDATE"; cat; } | column -t -s $'\t'
;;
device)
Q="${POS[1]:?usage: device <name|id|100.x>}"
ID="$(resolve_device "$Q")" || exit $?
api GET "device/$ID?fields=all"; api_ok || { api_fail "device detail"; exit 2; }
if [ "$JSON" = 1 ]; then printf '%s\n' "$HTTP_BODY" | jq '.'; else
printf '%s\n' "$HTTP_BODY" | jq -r '
"name : " + (.name//"?"),
"id : " + (.id//"?"),
"nodeId : " + (.nodeId//"?"),
"addresses : " + ((.addresses//[])|join(", ")),
"os : " + (.os//"?"),
"user : " + (.user//"?"),
"tags : " + ((.tags//[])|join(", ")),
"authorized : " + ((.authorized//false)|tostring),
"lastSeen : " + (.lastSeen//"?"),
"clientVer : " + (.clientVersion//"?"),
"update : " + ((.updateAvailable//false)|tostring)'
fi
;;
delete-device)
Q="${POS[1]:?usage: delete-device <name|id|100.x> [--confirm]}"
ID="$(resolve_device "$Q")" || exit $?
LBL="$(dev_label "$ID")"
if [ "$CONFIRM" != 1 ]; then
echo "[BLOCKED] would DELETE device $ID $LBL from tailnet $TAILNET"
echo " re-run with --confirm to delete."
exit 3
fi
api DELETE "device/$ID"; api_ok || { api_fail "delete device"; exit 2; }
echo "[OK] deleted device $ID $LBL"
alert "[TAILSCALE] deleted device $LBL ($ID) from tailnet $TAILNET"
;;
authorize)
Q="${POS[1]:?usage: authorize <name|id|100.x> [--confirm]}"
ID="$(resolve_device "$Q")" || exit $?
LBL="$(dev_label "$ID")"
if [ "$CONFIRM" != 1 ]; then
echo "[BLOCKED] would AUTHORIZE device $ID $LBL on tailnet $TAILNET"
echo " re-run with --confirm to authorize."
exit 3
fi
api POST "device/$ID/authorized" '{"authorized":true}'; api_ok || { api_fail "authorize device"; exit 2; }
echo "[OK] authorized device $ID $LBL"
alert "[TAILSCALE] authorized device $LBL ($ID) on tailnet $TAILNET"
;;
keys)
api GET "tailnet/$TAILNET/keys"; api_ok || { api_fail "list keys"; exit 2; }
if [ "$JSON" = 1 ]; then printf '%s\n' "$HTTP_BODY" | jq '.'; exit 0; fi
printf '%s' "$HTTP_BODY" | jq -r '
.keys // [] | .[]
| [ .id, (.description//"-"),
((.capabilities.devices.create.tags//[])|join(",") // "-"),
((.capabilities.devices.create.reusable//false)|tostring),
(.created//"?"), (.expires//"?") ] | @tsv' \
| { echo -e "KEYID\tDESC\tTAGS\tREUSABLE\tCREATED\tEXPIRES"; cat; } | column -t -s $'\t'
;;
create-key)
TAGS_JSON="$(printf '%s\n' "${TAGS[@]:-}" | jq -R . | jq -s 'map(select(length>0))')"
EXPIRY_SECONDS=$(( EXPIRY_DAYS * 86400 ))
BODY="$(jq -n \
--argjson reusable "$REUSABLE" --argjson ephemeral "$EPHEMERAL" --argjson preauth "$PREAUTH" \
--argjson expiry "$EXPIRY_SECONDS" --arg desc "$DESC" --argjson tags "$TAGS_JSON" \
'{capabilities:{devices:{create:{reusable:$reusable,ephemeral:$ephemeral,preauthorized:$preauth,tags:$tags}}},expirySeconds:$expiry,description:$desc}')"
if [ "$CONFIRM" != 1 ]; then
echo "[BLOCKED] would CREATE auth key on tailnet $TAILNET with:"
printf '%s\n' "$BODY" | jq '.'
echo " re-run with --confirm to create."
exit 3
fi
api POST "tailnet/$TAILNET/keys" "$BODY"; api_ok || { api_fail "create key"; exit 2; }
KID="$(printf '%s' "$HTTP_BODY" | jq -r '.id // "?"')"
echo "[OK] created auth key $KID (tags: $(printf '%s' "$TAGS_JSON" | jq -r 'join(",")'))"
echo "[WARNING] the key secret is shown ONCE below — store it in the vault, never in chat/commit:"
printf '%s' "$HTTP_BODY" | jq -r '.key // "(no key field returned)"'
alert "[TAILSCALE] created auth key $KID on tailnet $TAILNET (tags: $(printf '%s' "$TAGS_JSON" | jq -r 'join(",")'))"
;;
delete-key)
KID="${POS[1]:?usage: delete-key <keyId> [--confirm]}"
if [ "$CONFIRM" != 1 ]; then
echo "[BLOCKED] would DELETE/revoke auth key $KID on tailnet $TAILNET"
echo " re-run with --confirm to revoke."
exit 3
fi
api DELETE "tailnet/$TAILNET/keys/$KID"; api_ok || { api_fail "delete key"; exit 2; }
echo "[OK] revoked auth key $KID"
alert "[TAILSCALE] revoked auth key $KID on tailnet $TAILNET"
;;
*)
echo "[ERROR] unknown subcommand: $SUB"
usage 2
;;
esac