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:
1 parent
4ed1ac8cad
commit
da34be08d5
2 files changed
+31
-16
No files matched your search
@@ -3,6 +3,18 @@
|
|||||||
* Requires config: tailnet, apiKey
|
* Requires config: tailnet, apiKey
|
||||||
*
|
*
|
||||||
* API docs: https://tailscale.com/api
|
* 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";
|
const BASE = "https://api.tailscale.com";
|
||||||
@@ -23,7 +35,7 @@ export interface TailscaleDevice {
|
|||||||
lastSeen: string | null;
|
lastSeen: string | null;
|
||||||
isExitNode: boolean;
|
isExitNode: boolean;
|
||||||
authorized: boolean;
|
authorized: boolean;
|
||||||
online: boolean | null;
|
online: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TailscaleAdapter {
|
export interface TailscaleAdapter {
|
||||||
@@ -61,20 +73,23 @@ export function createTailscaleAdapter(config: TailscaleConfig): TailscaleAdapte
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function listDevices(): Promise<TailscaleDevice[]> {
|
async function listDevices(): Promise<TailscaleDevice[]> {
|
||||||
const data = await api("GET", `/api/v2/tailnet/${tailnetPath()}/devices`);
|
const data = await api("GET", `/api/v2/tailnet/${tailnetPath()}/devices?fields=all`);
|
||||||
return (data.devices || []).map((d: any) => ({
|
return (data.devices || []).map((d: any) => {
|
||||||
id: d.id,
|
const enabledRoutes: string[] = d.enabledRoutes || [];
|
||||||
nodeId: d.nodeId || d.id,
|
return {
|
||||||
hostname: d.hostname || "",
|
id: d.id,
|
||||||
label: d.displayName || d.hostname || "",
|
nodeId: d.nodeId || d.id,
|
||||||
addresses: d.addresses || [],
|
hostname: d.hostname || "",
|
||||||
primaryAddress: d.addresses?.[0] || "",
|
label: d.displayName || d.hostname || "",
|
||||||
os: d.os || "",
|
addresses: d.addresses || [],
|
||||||
lastSeen: d.lastSeen || null,
|
primaryAddress: d.addresses?.[0] || "",
|
||||||
isExitNode: !!d.isExitNode,
|
os: d.os || "",
|
||||||
authorized: !!d.authorized,
|
lastSeen: d.lastSeen || null,
|
||||||
online: d.online ?? 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> {
|
async function deleteDevice(deviceId: string): Promise<void> {
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ export interface TailscaleDevice {
|
|||||||
lastSeen: string | null;
|
lastSeen: string | null;
|
||||||
isExitNode: boolean;
|
isExitNode: boolean;
|
||||||
authorized: boolean;
|
authorized: boolean;
|
||||||
online: boolean | null;
|
online: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TailscaleDevicesResponse {
|
export interface TailscaleDevicesResponse {
|
||||||
|
|||||||
Reference in new issue
Block a user