diff --git a/server/src/integrations/tailscale/adapter.ts b/server/src/integrations/tailscale/adapter.ts index 9d2b9aa..f3f0be3 100644 --- a/server/src/integrations/tailscale/adapter.ts +++ b/server/src/integrations/tailscale/adapter.ts @@ -36,6 +36,8 @@ export interface TailscaleDevice { isExitNode: boolean; authorized: boolean; online: boolean; + keyExpiry: string | null; + keyExpiryDisabled: boolean; } export interface TailscaleAdapter { @@ -76,6 +78,12 @@ export function createTailscaleAdapter(config: TailscaleConfig): TailscaleAdapte const data = await api("GET", `/api/v2/tailnet/${tailnetPath()}/devices?fields=all`); return (data.devices || []).map((d: any) => { const enabledRoutes: string[] = d.enabledRoutes || []; + // Tailscale returns Go's zero time ("0001-01-01T00:00:00Z") for + // `expires` when a device has no expiry set (distinct from + // keyExpiryDisabled, which is the explicit "never expire" override) — + // treat both as "no expiry" rather than showing a bogus 1AD date. + const expires: string | undefined = d.expires; + const hasRealExpiry = !!expires && !expires.startsWith("0001-01-01"); return { id: d.id, nodeId: d.nodeId || d.id, @@ -88,6 +96,8 @@ export function createTailscaleAdapter(config: TailscaleConfig): TailscaleAdapte isExitNode: enabledRoutes.includes("0.0.0.0/0") && enabledRoutes.includes("::/0"), authorized: !!d.authorized, online: !!d.connectedToControl, + keyExpiry: hasRealExpiry ? expires! : null, + keyExpiryDisabled: !!d.keyExpiryDisabled, }; }); } diff --git a/server/src/routes/integrations.ts b/server/src/routes/integrations.ts index 5cca002..f346007 100644 --- a/server/src/routes/integrations.ts +++ b/server/src/routes/integrations.ts @@ -315,18 +315,26 @@ async function requireTailscaleAdapter(req: Request, res: Response) { return { integration: loaded.integration, adapter: createTailscaleAdapter(loaded.config as any) }; } +const KEY_EXPIRY_WARN_DAYS = 30; + integrationsRouter.get("/:id/tailscale/devices", asyncHandler(async (req, res) => { const found = await requireTailscaleAdapter(req, res); if (!found) return; try { const devices = await found.adapter.listDevices(); + const now = Date.now(); res.json({ devices, summary: { total: devices.length, online: devices.filter((d) => d.online).length, unauthorized: devices.filter((d) => !d.authorized).length, + expiringSoon: devices.filter((d) => { + if (d.keyExpiryDisabled || !d.keyExpiry) return false; + const daysLeft = (new Date(d.keyExpiry).getTime() - now) / 86_400_000; + return daysLeft <= KEY_EXPIRY_WARN_DAYS; + }).length, }, }); } catch (err) { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 5cba1e7..7b1f8e7 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -310,11 +310,13 @@ export interface TailscaleDevice { isExitNode: boolean; authorized: boolean; online: boolean; + keyExpiry: string | null; + keyExpiryDisabled: boolean; } export interface TailscaleDevicesResponse { devices: TailscaleDevice[]; - summary: { total: number; online: number; unauthorized: number }; + summary: { total: number; online: number; unauthorized: number; expiringSoon: number }; } export interface GiteaWorkflowRun { diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index ef7d115..f31065e 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -181,6 +181,9 @@ export default function Dashboard({ user }: { user: CurrentUser }) { {tailscaleSummary!.unauthorized > 0 && (