Skip to content

Instantly share code, notes, and snippets.

@dzogrim
Created October 14, 2025 09:31
Show Gist options
  • Save dzogrim/274adbb2563427fb6bb3942e7cd0496a to your computer and use it in GitHub Desktop.
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
#!/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