Last active
October 24, 2025 17:02
-
-
Save Sn0wo2/111fa61db8f2996d79baf0ea5e9d18dc to your computer and use it in GitHub Desktop.
Batch delete unauthorized tailscale machines
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
| # tailscale找了一圈没有批量删除所有未授权设备的地方然后写了个脚本 | |
| # docker配置失误发现给我创建了几百个未授权machines... | |
| import requests, ssl, threading, time, queue, sys | |
| from requests.adapters import HTTPAdapter | |
| from urllib3 import Retry | |
| API_TOKEN = "REPLACE_YOUR_API_KEY" | |
| HEADERS = {"Authorization": f"Bearer {API_TOKEN}", "Connection": "close"} | |
| BASE_URL = "https://api.tailscale.com/api/v2" | |
| THREADS = 1 | |
| session = requests.Session() | |
| retries = Retry( | |
| total=3, | |
| backoff_factor=1, | |
| status_forcelist=[429, 500, 502, 503, 504], | |
| allowed_methods=["GET", "DELETE"], | |
| ) | |
| adapter = HTTPAdapter(max_retries=retries, pool_connections=THREADS, pool_maxsize=THREADS) | |
| session.mount("https://", adapter) | |
| session.verify = True | |
| def safe_request(method, url, **kwargs): | |
| for attempt in range(3): | |
| try: | |
| return session.request(method, url, headers=HEADERS, timeout=(5, 10), **kwargs) | |
| except (ssl.SSLEOFError, requests.RequestException) as e: | |
| print(f"[retry {attempt+1}] {e}", flush=True) | |
| time.sleep(1) | |
| return None | |
| def worker(q): | |
| while True: | |
| try: | |
| device = q.get_nowait() | |
| except queue.Empty: | |
| break | |
| r = safe_request("DELETE", f"{BASE_URL}/device/{device['id']}") | |
| code = r.status_code if r else "fail" | |
| device_name = device.get('name') or device.get('hostname') or device.get('id') | |
| print(f"Deleted: {device_name} ({r.status_code if r else 'fail'})") | |
| q.task_done() | |
| def interactive_delete(): | |
| resp = safe_request("GET", f"{BASE_URL}/tailnet/-/devices") | |
| if not resp: | |
| print("Failed to fetch device list.") | |
| return | |
| devices = resp.json().get("devices", []) | |
| print(f"Fetched {len(devices)} devices.") | |
| for i, d in enumerate(devices, 1): | |
| s = "Authorized" if d.get("authorized") else "Unauthorized" | |
| print(f"{i:3d}. {d['name']:30} ({s}) - {d.get('user', 'N/A')}") | |
| print("\nEnter machine numbers (comma separated), or 'unauthorized' to delete all unauthorized devices:") | |
| choice = input().strip() | |
| if choice.lower() == "unauthorized": | |
| targets = [d for d in devices if not d.get("authorized")] | |
| else: | |
| idx = [int(x.strip()) - 1 for x in choice.split(",")] | |
| targets = [devices[i] for i in idx if 0 <= i < len(devices)] | |
| print(f"\nConfirm deleting {len(targets)} devices? (Y/N): ", end="") | |
| sys.stdout.flush() | |
| if input().strip().upper() != "Y": | |
| print("Canceled.") | |
| return | |
| q = queue.Queue() | |
| for d in targets: | |
| q.put(d) | |
| threads = [threading.Thread(target=worker, args=(q,), daemon=True) for _ in range(THREADS)] | |
| for t in threads: | |
| t.start() | |
| q.join() | |
| print("All task done!", flush=True) | |
| if __name__ == "__main__": | |
| interactive_delete() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment