#!/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 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 [--json] device detail # delete-device [--confirm] DELETE a device (GATED) # authorize [--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 [--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 [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 }" 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 [--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 [--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 [--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