From c0af546d08b599ef237f59a51ea9cd916ba5c0f0 Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Thu, 17 Sep 2026 18:49:42 +0200 Subject: [PATCH] Show Tailscale device key expiry Tailscale node keys expire (180 days by default unless disabled per device) and an expired key drops the device off the tailnet until re-authenticated -- worth surfacing before it happens. adapter.listDevices() now reads `expires` and `keyExpiryDisabled` from the device list API (`?fields=all`, already being fetched). Go's zero time ("0001-01-01T00:00:00Z") is what Tailscale returns for "no real expiry set" and is treated as null rather than shown as a bogus 1AD date. New "Key expiry" column on the Tailscale page: "Never" when disabled, otherwise the date plus a badge (green/yellow/red matching the Secrets module's ok/expiring/expired convention) using the same 30-day warning window as that module's default. The devices summary gained `expiringSoon` (<=30 days left, including already-expired), surfaced in the page header and as a new warning line on the Dashboard's Tailscale widget, alongside the existing "awaiting authorization" one. Verified the parsing (real expiry, disabled/zero-time, already-expired, far-future) against the compiled adapter with fetch calls to api.tailscale.com redirected to a local mock server, since this sandbox can't reach the user's real tailnet. Co-Authored-By: Claude Sonnet 5 --- server/src/integrations/tailscale/adapter.ts | 10 ++++++++++ server/src/routes/integrations.ts | 8 ++++++++ web/src/api/client.ts | 4 +++- web/src/pages/Dashboard.tsx | 3 +++ web/src/pages/Tailscale.tsx | 21 +++++++++++++++++++- 5 files changed, 44 insertions(+), 2 deletions(-) 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 && (
{tailscaleSummary!.unauthorized} awaiting authorization
)} + {tailscaleSummary!.expiringSoon > 0 && ( +
{tailscaleSummary!.expiringSoon} key(s) expiring soon
+ )} ) : isLiveGitea ? (
diff --git a/web/src/pages/Tailscale.tsx b/web/src/pages/Tailscale.tsx index bc6ea75..88b2a2b 100644 --- a/web/src/pages/Tailscale.tsx +++ b/web/src/pages/Tailscale.tsx @@ -9,6 +9,22 @@ import { } from "../api/client"; import { formatDateTime } from "../utils/date"; +const KEY_EXPIRY_WARN_DAYS = 30; + +function keyExpiryBadge(device: TailscaleDevice) { + if (device.keyExpiryDisabled || !device.keyExpiry) { + return Never; + } + const daysLeft = (new Date(device.keyExpiry).getTime() - Date.now()) / 86_400_000; + const style = + daysLeft < 0 ? "bg-red-lt text-red" : daysLeft <= KEY_EXPIRY_WARN_DAYS ? "bg-yellow-lt text-yellow" : "bg-green-lt text-green"; + return ( + <> + {formatDateTime(new Date(device.keyExpiry))} {daysLeft < 0 ? "Expired" : `${Math.floor(daysLeft)}d left`} + + ); +} + export default function Tailscale({ user }: { user: CurrentUser }) { const canEdit = user.role === "admin" || user.role === "operator"; @@ -120,6 +136,7 @@ export default function Tailscale({ user }: { user: CurrentUser }) { {data.summary.online}/{data.summary.total} online {data.summary.unauthorized > 0 && `, ${data.summary.unauthorized} awaiting authorization`} + {data.summary.expiringSoon > 0 && `, ${data.summary.expiringSoon} key(s) expiring soon`} )} @@ -142,6 +159,7 @@ export default function Tailscale({ user }: { user: CurrentUser }) { OS Status Last seen + Key expiry {canEdit && Actions} @@ -163,6 +181,7 @@ export default function Tailscale({ user }: { user: CurrentUser }) { {!d.authorized && Unauthorized} {d.lastSeen ? formatDateTime(new Date(d.lastSeen)) : "—"} + {keyExpiryBadge(d)} {canEdit && (
@@ -185,7 +204,7 @@ export default function Tailscale({ user }: { user: CurrentUser }) { ))} {data?.devices.length === 0 && ( - + No devices in this tailnet.