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
Last active
July 27, 2025 22:01
-
-
Save maazghani/c7e41d545a6256faaa7af87c67369caa to your computer and use it in GitHub Desktop.
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 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