Skip to content

Instantly share code, notes, and snippets.

@maazghani
Last active July 27, 2025 22:01
Show Gist options
  • Save maazghani/c7e41d545a6256faaa7af87c67369caa to your computer and use it in GitHub Desktop.
Save maazghani/c7e41d545a6256faaa7af87c67369caa to your computer and use it in GitHub Desktop.
usage: cleanup-old-tailscale.py [-h] [-k API_KEY] [-d DATE] [-n] [-v] [--debug]

Cleanup old Tailscale devices

optional arguments:
  -h, --help            show this help message and exit
  -k API_KEY, --api-key API_KEY
                        Tailscale API key (or set TAILSCALE_API_KEY env var)
  -d DATE, --date DATE  Cutoff date in YYYY-MM-DD format (default: 2025-05-01)
  -n, --dry-run         Show what would be deleted without actually deleting
  -v, --verbose         Verbose output
  --debug               Debug mode - show raw API responses

Examples:
  cleanup-old-tailscale.py -k tskey-api-xxx -d 2025-04-01 --dry-run
  cleanup-old-tailscale.py --api-key tskey-api-xxx --verbose
  
Required: Set TAILSCALE_API_KEY environment variable or use -k option
Get your API key from: https://login.tailscale.com/admin/settings/keys
#!/usr/bin/env python3
"""
Tailscale Device Cleanup Script
Script to remove Tailscale devices last seen before a specific date
"""
import argparse
import json
import os
import sys
from datetime import datetime, timezone
from typing import Dict, List, Optional
import requests
class TailscaleCleanup:
def __init__(self, api_key: str, dry_run: bool = False, verbose: bool = False, debug: bool = False):
self.api_key = api_key
self.dry_run = dry_run
self.verbose = verbose
self.debug = debug
self.base_url = "https://api.tailscale.com/api/v2"
self.session = requests.Session()
self.session.headers.update({
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
})
def log(self, level: str, message: str):
"""Log messages with color coding"""
colors = {
"INFO": "\033[0;34m",
"WARN": "\033[1;33m",
"ERROR": "\033[0;31m",
"SUCCESS": "\033[0;32m",
"DEBUG": "\033[1;35m"
}
reset = "\033[0m"
if level == "DEBUG" and not self.debug:
return
color = colors.get(level, "")
print(f"{color}[{level}]{reset} {message}")
def parse_datetime(self, date_str: str) -> Optional[datetime]:
"""Parse various datetime formats from Tailscale API"""
if not date_str or date_str == "null":
return None
# Common formats from Tailscale API
formats = [
"%Y-%m-%dT%H:%M:%SZ", # 2025-04-15T10:30:00Z
"%Y-%m-%dT%H:%M:%S.%fZ", # 2025-04-15T10:30:00.123456Z
"%Y-%m-%dT%H:%M:%S%z", # 2025-04-15T10:30:00+00:00
"%Y-%m-%d %H:%M:%S", # 2025-04-15 10:30:00
"%Y-%m-%d", # 2025-04-15
]
for fmt in formats:
try:
dt = datetime.strptime(date_str, fmt)
# If no timezone info, assume UTC
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
except ValueError:
continue
self.log("WARN", f"Could not parse date: {date_str}")
return None
def get_devices(self) -> List[Dict]:
"""Fetch all devices from Tailscale API"""
self.log("INFO", "Fetching devices from Tailscale API...")
try:
response = self.session.get(f"{self.base_url}/tailnet/-/devices")
self.log("DEBUG", f"HTTP Status Code: {response.status_code}")
self.log("DEBUG", f"Response headers: {dict(response.headers)}")
if response.status_code != 200:
self.log("ERROR", f"API request failed with status {response.status_code}")
self.log("ERROR", f"Response: {response.text}")
return []
data = response.json()
self.log("DEBUG", f"Raw API response: {json.dumps(data, indent=2)}")
if "devices" not in data:
self.log("ERROR", "No 'devices' field in API response")
self.log("DEBUG", f"Available fields: {list(data.keys())}")
return []
devices = data["devices"]
self.log("INFO", f"Found {len(devices)} total devices")
return devices
except requests.RequestException as e:
self.log("ERROR", f"Failed to connect to Tailscale API: {e}")
return []
except json.JSONDecodeError as e:
self.log("ERROR", f"Invalid JSON response: {e}")
self.log("DEBUG", f"Raw response: {response.text}")
return []
def delete_device(self, device_id: str, device_name: str) -> bool:
"""Delete a device by ID"""
if self.dry_run:
self.log("INFO", f"DRY RUN: Would delete device '{device_name}' (ID: {device_id})")
return True
self.log("INFO", f"Deleting device '{device_name}' (ID: {device_id})...")
try:
response = self.session.delete(f"{self.base_url}/device/{device_id}")
if response.status_code in [200, 204]:
self.log("SUCCESS", f"Successfully deleted device '{device_name}'")
return True
else:
self.log("ERROR", f"Failed to delete device '{device_name}' (HTTP: {response.status_code})")
self.log("DEBUG", f"Delete response: {response.text}")
return False
except requests.RequestException as e:
self.log("ERROR", f"Failed to delete device '{device_name}': {e}")
return False
def cleanup_devices(self, cutoff_date: str) -> Dict[str, int]:
"""Main cleanup function"""
# Parse cutoff date
try:
cutoff_dt = datetime.strptime(cutoff_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
except ValueError:
self.log("ERROR", f"Invalid cutoff date format: {cutoff_date}. Use YYYY-MM-DD")
return {"total": 0, "old": 0, "deleted": 0, "failed": 0}
self.log("INFO", f"Starting cleanup of devices last seen before {cutoff_date}")
if self.dry_run:
self.log("WARN", "DRY RUN MODE - No devices will actually be deleted")
# Get all devices
devices = self.get_devices()
if not devices:
self.log("ERROR", "No devices found or failed to fetch devices")
return {"total": 0, "old": 0, "deleted": 0, "failed": 0}
# Process devices
stats = {"total": len(devices), "old": 0, "deleted": 0, "failed": 0}
self.log("INFO", "Processing devices...")
for device in devices:
device_id = device.get("id", "unknown")
device_name = device.get("name") or device.get("hostname", "unknown")
last_seen_str = device.get("lastSeen")
created_str = device.get("created")
authorized = device.get("authorized", False)
self.log("DEBUG", f"Processing device: ID={device_id}, Name={device_name}, LastSeen={last_seen_str}")
# Skip if no lastSeen data
if not last_seen_str:
if self.verbose:
self.log("INFO", f"Skipping device '{device_name}' - no lastSeen data")
continue
# Parse lastSeen date
last_seen_dt = self.parse_datetime(last_seen_str)
if not last_seen_dt:
self.log("WARN", f"Could not parse lastSeen date for device '{device_name}': {last_seen_str}")
continue
# Check if device is old enough to delete
if last_seen_dt < cutoff_dt:
stats["old"] += 1
if self.verbose:
self.log("INFO", f"Found old device: '{device_name}' (last seen: {last_seen_str}, authorized: {authorized})")
# Delete the device
if self.delete_device(device_id, device_name):
stats["deleted"] += 1
else:
stats["failed"] += 1
else:
if self.verbose:
self.log("INFO", f"Keeping device '{device_name}' (last seen: {last_seen_str})")
return stats
def print_summary(self, stats: Dict[str, int], cutoff_date: str):
"""Print cleanup summary"""
print()
self.log("INFO", "Cleanup Summary:")
print(f" Total devices found: {stats['total']}")
print(f" Devices last seen before {cutoff_date}: {stats['old']}")
if self.dry_run:
print(f" Devices that would be deleted: {stats['old']}")
else:
print(f" Devices successfully deleted: {stats['deleted']}")
print(f" Failed deletions: {stats['failed']}")
if self.dry_run:
self.log("WARN", "This was a dry run. To actually delete devices, run without --dry-run")
def main():
parser = argparse.ArgumentParser(
description="Cleanup old Tailscale devices",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s -k tskey-api-xxx -d 2025-04-01 --dry-run
%(prog)s --api-key tskey-api-xxx --verbose
Required: Set TAILSCALE_API_KEY environment variable or use -k option
Get your API key from: https://login.tailscale.com/admin/settings/keys
"""
)
parser.add_argument(
"-k", "--api-key",
help="Tailscale API key (or set TAILSCALE_API_KEY env var)"
)
parser.add_argument(
"-d", "--date",
default="2025-05-01",
help="Cutoff date in YYYY-MM-DD format (default: 2025-05-01)"
)
parser.add_argument(
"-n", "--dry-run",
action="store_true",
help="Show what would be deleted without actually deleting"
)
parser.add_argument(
"-v", "--verbose",
action="store_true",
help="Verbose output"
)
parser.add_argument(
"--debug",
action="store_true",
help="Debug mode - show raw API responses"
)
args = parser.parse_args()
# Get API key
api_key = args.api_key or os.environ.get("TAILSCALE_API_KEY")
if not api_key:
print("Error: Tailscale API key is required")
print("Set TAILSCALE_API_KEY environment variable or use -k option")
print("Get your API key from: https://login.tailscale.com/admin/settings/keys")
sys.exit(1)
# Validate API key format
if not api_key.startswith("tskey-"):
print("Warning: API key doesn't start with 'tskey-'. Please verify your API key.")
# Check if requests is available
try:
import requests
except ImportError:
print("Error: 'requests' library is required. Install with: pip install requests")
sys.exit(1)
# Create cleanup instance and run
cleanup = TailscaleCleanup(
api_key=api_key,
dry_run=args.dry_run,
verbose=args.verbose,
debug=args.debug
)
stats = cleanup.cleanup_devices(args.date)
cleanup.print_summary(stats, args.date)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment