Created
October 14, 2025 09:31
-
-
Save dzogrim/274adbb2563427fb6bb3942e7cd0496a to your computer and use it in GitHub Desktop.
Lists tailnet devices, skips keyExpiryDisabled, flags expired or inactive (lastSeen <= now-<days>, default 120), prints name/lastSeen/expires, deletes each via DELETE /api/v2/device/{id} (interactive or --force), portable across macOS BSD date and Linux GNU date with a double-guard against expiry-disabled
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -Eeuo pipefail | |
| # tailscale-prune-devices.sh description: | |
| # • Fetches all devices from your tailnet via GET /api/v2/tailnet/{tailnet}/devices. | |
| # • Filters out devices with `keyExpiryDisabled=true`. | |
| # • Flags devices whose key has expired (expires <= now) | |
| # or that have been inactive since a cutoff (lastSeen <= now-<days>; default 120). | |
| # • Shows name, lastSeen, and expires for each candidate. | |
| # • Deletes each candidate via DELETE /api/v2/device/{id}: | |
| # • interactively (ask per device), or | |
| # • non-interactively with --force (cron-friendly). | |
| # • Works on macOS (BSD date) and Linux (GNU date), and | |
| # avoids deleting “expiry disabled” devices by design (double-check). | |
| usage() { | |
| cat <<'EOF' | |
| Usage: tailscale-prune-devices.sh -t <tailnet> [-k <api_key_file>|-K <api_key>] [-d <days>] [--force] | |
| -t Tailnet/org name | |
| -k File containing API key (default: ~/.config/tailscale/api.key) | |
| -K API key value (overrides -k) | |
| -d Cutoff in days since lastSeen (default: 120) | |
| --force Delete without confirmation (non-interactive) | |
| EOF | |
| } | |
| TAILNET="" | |
| APIKEY_FILE="${HOME}/.config/tailscale/api.key" | |
| APIKEY_VALUE="" | |
| DAYS=120 | |
| FORCE=0 | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| -t) TAILNET="$2"; shift 2 ;; | |
| -k) APIKEY_FILE="$2"; shift 2 ;; | |
| -K) APIKEY_VALUE="$2"; shift 2 ;; | |
| -d) DAYS="$2"; shift 2 ;; | |
| --force) FORCE=1; shift ;; | |
| -h|--help) usage; exit 0 ;; | |
| *) echo "Unknown arg: $1" >&2; usage; exit 1 ;; | |
| esac | |
| done | |
| [[ -n "$TAILNET" ]] || { echo "Missing -t <tailnet>" >&2; exit 1; } | |
| if [[ -z "$APIKEY_VALUE" ]]; then | |
| [[ -r "$APIKEY_FILE" ]] || { echo "API key file not readable: $APIKEY_FILE" >&2; exit 1; } | |
| APIKEY_VALUE="$(<"$APIKEY_FILE")" | |
| fi | |
| if command -v gdate >/dev/null 2>&1; then DATECMD="gdate"; else DATECMD="date"; fi | |
| if $DATECMD -u -d "now" >/dev/null 2>&1; then | |
| CUTOFF="$($DATECMD -u -d "$DAYS days ago" +"%Y-%m-%dT%H:%M:%SZ")" | |
| else | |
| CUTOFF="$($DATECMD -u -v-"$DAYS"d +"%Y-%m-%dT%H:%M:%SZ")" | |
| fi | |
| API="https://api.tailscale.com/api/v2" | |
| AUTH_HEADER="Authorization: Bearer ${APIKEY_VALUE}" | |
| echo "Fetching device list for tailnet: $TAILNET ..." | |
| JSON="$(curl -fsS -H "$AUTH_HEADER" "$API/tailnet/${TAILNET}/devices")" | |
| # Pre-filter: | |
| # - EXCLUDE any device with keyExpiryDisabled==true (never suggest/delete) | |
| # - INCLUDE if (expires <= now) OR (lastSeen <= cutoff) | |
| NOW="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" | |
| mapfile -t CANDIDATES < <(jq -r --arg now "$NOW" --arg cutoff "$CUTOFF" ' | |
| def ts($s): (try ($s|fromdateiso8601) catch 0); | |
| .devices[] | |
| | . as $d | |
| | select(($d.keyExpiryDisabled|not)) # hard exclude | |
| | ($d.expires // "1970-01-01T00:00:00Z") as $exp | |
| | ($d.lastSeen // "1970-01-01T00:00:00Z") as $ls | |
| | ( (ts($exp) > 0 and ts($exp) <= ts($now)) or (ts($ls) > 0 and ts($ls) <= ts($cutoff)) ) as $candidate | |
| | select($candidate) | |
| | [ .id, .name, $ls, $exp, (.keyExpiryDisabled // false) ] | |
| | @tsv | |
| ' <<<"$JSON") | |
| if (( ${#CANDIDATES[@]} == 0 )); then | |
| echo "No deletable devices (cutoff: $CUTOFF)." | |
| exit 0 | |
| fi | |
| for line in "${CANDIDATES[@]}"; do | |
| IFS=$'\t' read -r ID NAME LASTSEEN EXPIRES KEYDIS <<<"$line" | |
| # Safety double-check: skip if expiry disabled (even if somehow passed filter) | |
| if [[ "$KEYDIS" == "true" ]]; then | |
| printf "\nDevice: %-35s lastSeen=%s expires=%s → skip (expiry disabled)\n" "$NAME" "$LASTSEEN" "$EXPIRES" | |
| continue | |
| fi | |
| printf "\nDevice: %-35s lastSeen=%s expires=%s\n" "$NAME" "$LASTSEEN" "$EXPIRES" | |
| if (( FORCE )); then | |
| echo "→ Deleting $NAME ($ID)..." | |
| curl -fsS -X DELETE -H "$AUTH_HEADER" "$API/device/$ID" >/dev/null \ | |
| && echo "✓ deleted" || echo "✗ error deleting" | |
| else | |
| read -r -p "Delete this device? [y/N] " ans | |
| if [[ "$ans" =~ ^[Yy]$ ]]; then | |
| curl -fsS -X DELETE -H "$AUTH_HEADER" "$API/device/$ID" >/dev/null \ | |
| && echo "✓ deleted" || echo "✗ error deleting" | |
| else | |
| echo "→ skipped" | |
| fi | |
| fi | |
| done | |
| echo | |
| echo "Cleanup complete." |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment