← neuralinsight.net
neuralinsight.net  /  api  /  tailscale   ·   September 10, 2026

Tailscale device audit: scripting the admin console

A zero-dependency Python script that audits a Tailscale tailnet via the API, flags stale nodes, outdated clients, and missing ACL tags, with remediation steps built into the output.

The problem

If you manage a Tailscale tailnet with more than a handful of nodes, you've probably opened the admin console and eyeballed the machine list looking for something offline or out of date. That works fine for five nodes. It doesn't scale, and more importantly it doesn't give you a repeatable, documentable process.

I built tailscale_audit.py to solve that in a way that mirrors how a support engineer would approach the problem: connect to the API, pull device data, check against known-good criteria, and surface findings with actionable remediation steps. The goal wasn't just a useful script; it was to build something structured enough that it could live in a runbook.

What it checks

The script pulls every device from the Tailscale API and evaluates three things per node. All configuration is via environment variables, nothing is hardcoded.

Check Controlled by Default
Stale / offline nodes STALE_HOURS 24 hours
Outdated client version updateAvailable (API field) always on
Missing ACL tags REQUIRED_TAGS disabled
Hostname exclusion filter EXCLUDE_PATTERN disabled

Running it requires only an API access token from the Tailscale admin console and your tailnet name. No pip install, no virtual environment. It runs anywhere Python 3.7 is present, including a bare Raspberry Pi.

bash
$ export TAILSCALE_API_KEY="tskey-api-..." $ export TAILSCALE_TAILNET="your-tailnet-name" $ python3 tailscale_audit.py Fetching device list for tailnet 'your-tailnet-name'... Retrieved 4 device(s). Running audit...

Sample report output

tailscale_audit.py — report output
Tailscale Device Audit Report Tailnet : your-tailnet Generated : 2026-09-10 02:17 UTC Nodes : 4 Stale if : not seen in 24h ================================================================ SUMMARY ================================================================ [OK] Stale / offline nodes : 0 [!!] Outdated client version : 1 ================================================================ ALL NODES ================================================================ HOSTNAME IPv4 VERSION LAST SEEN ONLINE ---------------------------------------------------------------- OPNsense 100.x.x.x 1.102.2 0s ago yes desktop 100.x.x.x 1.102.2 2m ago yes dryer 100.x.x.x 1.102.2 0s ago yes roberts-s23 100.x.x.x 1.98.8 11m ago no ================================================================ OUTDATED CLIENT VERSIONS (1) ================================================================ The following nodes have updates available (reported by Tailscale API): HOSTNAME CURRENT VERSION IPv4 ---------------------------------------------------------------- roberts-s23 1.98.8 100.x.x.x Recommended actions: - Android: update via Google Play Store

Two things I learned from the API

Building against a real tailnet surfaced two behaviors that aren't obvious from the Tailscale documentation.

connectedToControl, not online

The Tailscale v2 API does not return an online field on device objects. The correct field is connectedToControl, which reflects whether the node is currently connected to the Tailscale coordination server. I found this the hard way: my dryer node showed as offline in the initial script output while the admin console showed it connected. A quick curl against the raw API response confirmed the field name mismatch.

bash — raw API inspection
$ curl -H "Authorization: Bearer ${TAILSCALE_API_KEY}" \ "https://api.tailscale.com/api/v2/tailnet/${TAILSCALE_TAILNET}/devices" \ | python3 -m json.tool | grep -A 5 "dryer" "hostname": "dryer", "clientVersion": "1.102.2-t6cac91817", "connectedToControl": true, "updateAvailable": true, "online": null # field absent in v2 — do not rely on this
NOTE 01

The Tailscale admin console uses a live WebSocket connection to determine node status, so it can differ slightly from what the polled API returns. Use connectedToControl for API-based status checks, not online, which is absent from v2 device objects.

updateAvailable beats version string comparison

The first implementation compared version strings numerically to determine whether a node was outdated. That works, but it has edge cases: pre-release builds, platform-specific suffixes, release candidates. The API already knows whether a node needs an update and exposes it as a boolean updateAvailable field. Trusting Tailscale's own release logic is more reliable than reimplementing it.

NOTE 02

Using updateAvailable directly means the script stays correct across version naming changes without any code updates. The API knows its own release cadence better than a version string parser does.

The GitHub runner problem

When I first ran the script against my tailnet, 14 of 17 flagged nodes were stale GitHub Actions runner VMs, ephemeral machines that had spun up for CI jobs and been destroyed without being removed from the tailnet. They were flooding the report with noise.

The fix was an EXCLUDE_PATTERN environment variable that filters nodes by hostname substring. It supports comma-separated patterns for excluding multiple classes of ephemeral nodes at once.

bash
$ export EXCLUDE_PATTERN="github-runner,ephemeral" $ python3 tailscale_audit.py Fetching device list for tailnet 'your-tailnet'... Retrieved 17 device(s). Running audit... Excluding : github-runner, ephemeral Nodes : 3 (14 excluded by pattern)
NOTE 03

This is the kind of real-world edge case that only shows up when you run a tool against actual data. Ephemeral CI nodes that aren't cleaned up from the tailnet will flood any audit report. The pattern filter keeps the report focused on persistent infrastructure.

What this looks like as a support tool

The framing I had in mind while building this: what would a Tailscale support engineer reach for when a customer says "I think some of my nodes are out of date and one stopped checking in"?

The answer isn't "log into the admin console and click through 30 machines." It's a script that audits the tailnet in seconds and produces a structured report the customer can read and act on. The remediation steps in each section (the exact apt, dnf, and journalctl commands) are there because a good support artifact doesn't just identify the problem, it hands the next step to the person reading it.

It can also be scheduled as a cron job on any node in the tailnet, run nightly, and appended to a log file, turning a one-off diagnostic into a standing audit.

crontab — nightly audit at 06:00
0 6 * * * TAILSCALE_API_KEY="..." TAILSCALE_TAILNET="..." \ python3 /home/pi/tailscale_audit.py >> /var/log/tailscale_audit.log