Fix Tailscale online/exit-node detection against real API data

Found while verifying against the user's real tailnet (25 devices): the
Tailscale device object has no "online" or "isExitNode" field at all — this
was inherited from Sloth Manager's original adapter, which apparently never
had this checked against live data either. With the old mapping, every
device showed online:null and isExitNode was always false regardless of
reality.

Fixed by deriving both from fields that actually exist:
- online <- connectedToControl (whether the device currently has an active
  session with Tailscale's control plane)
- isExitNode <- enabledRoutes containing both 0.0.0.0/0 and ::/0 (a device
  can *advertise* exit-node routes without them being approved; checking
  enabledRoutes instead of advertisedRoutes reflects whether it's actually
  acting as one right now)

Both fields come back from the list endpoint via `?fields=all`, so this
adds no extra requests.

Verified against the real tailnet: 25 devices, 24 online / 1 offline (a
phone last seen 9 days ago — correct), and the one device actually
configured as an exit node identified correctly. This is the last of the
five previously-unverified integrations now confirmed against real
infrastructure; combined with the earlier Synology (HTTP vs HTTPS) and
Proxmox (async task timing) findings, every integration has now had at
least one real bug shaken out by testing against the user's actual homelab
rather than mocks alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-15 01:09:27 +02:00
1 parent 4ed1ac8cad
commit da34be08d5
2 files changed
+31 -16

No files matched your search

+30 -15
View File
@@ -3,6 +3,18 @@
* Requires config: tailnet, apiKey
*
* API docs: https://tailscale.com/api
*
* Note: the real device object has no "online" or "isExitNode" field at all
* (verified against a live tailnet — this diverges from what Sloth Manager's
* original adapter assumed). "Online" is derived from `connectedToControl`
* (whether the device currently has an active session with Tailscale's
* control plane); "is an exit node" is derived from `enabledRoutes`
* containing both default routes (0.0.0.0/0 and ::/0) — a device can
* *advertise* those routes without them being approved, so `enabledRoutes`
* (not `advertisedRoutes`) is the correct "is actually acting as an exit
* node right now" signal. `?fields=all` on the list endpoint returns both
* in the same call as the basic fields, so no per-device follow-up is
* needed.
*/
const BASE = "https://api.tailscale.com";
@@ -23,7 +35,7 @@ export interface TailscaleDevice {
lastSeen: string | null;
isExitNode: boolean;
authorized: boolean;
online: boolean | null;
online: boolean;
}
export interface TailscaleAdapter {
@@ -61,20 +73,23 @@ export function createTailscaleAdapter(config: TailscaleConfig): TailscaleAdapte
}
async function listDevices(): Promise<TailscaleDevice[]> {
const data = await api("GET", `/api/v2/tailnet/${tailnetPath()}/devices`);
return (data.devices || []).map((d: any) => ({
id: d.id,
nodeId: d.nodeId || d.id,
hostname: d.hostname || "",
label: d.displayName || d.hostname || "",
addresses: d.addresses || [],
primaryAddress: d.addresses?.[0] || "",
os: d.os || "",
lastSeen: d.lastSeen || null,
isExitNode: !!d.isExitNode,
authorized: !!d.authorized,
online: d.online ?? null,
}));
const data = await api("GET", `/api/v2/tailnet/${tailnetPath()}/devices?fields=all`);
return (data.devices || []).map((d: any) => {
const enabledRoutes: string[] = d.enabledRoutes || [];
return {
id: d.id,
nodeId: d.nodeId || d.id,
hostname: d.hostname || "",
label: d.displayName || d.hostname || "",
addresses: d.addresses || [],
primaryAddress: d.addresses?.[0] || "",
os: d.os || "",
lastSeen: d.lastSeen || null,
isExitNode: enabledRoutes.includes("0.0.0.0/0") && enabledRoutes.includes("::/0"),
authorized: !!d.authorized,
online: !!d.connectedToControl,
};
});
}
async function deleteDevice(deviceId: string): Promise<void> {
+1 -1
View File
@@ -190,7 +190,7 @@ export interface TailscaleDevice {
lastSeen: string | null;
isExitNode: boolean;
authorized: boolean;
online: boolean | null;
online: boolean;
}
export interface TailscaleDevicesResponse {