From da34be08d54caec3d38edeb7a24ffec6b5518c30 Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Tue, 15 Sep 2026 01:09:27 +0200 Subject: [PATCH] Fix Tailscale online/exit-node detection against real API data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/src/integrations/tailscale/adapter.ts | 45 +++++++++++++------- web/src/api/client.ts | 2 +- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/server/src/integrations/tailscale/adapter.ts b/server/src/integrations/tailscale/adapter.ts index 69df01f..9d2b9aa 100644 --- a/server/src/integrations/tailscale/adapter.ts +++ b/server/src/integrations/tailscale/adapter.ts @@ -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 { - 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 { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index b994289..d0fb469 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -190,7 +190,7 @@ export interface TailscaleDevice { lastSeen: string | null; isExitNode: boolean; authorized: boolean; - online: boolean | null; + online: boolean; } export interface TailscaleDevicesResponse {