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 {