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 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-17 18:49:42 +02:00
1 parent a781df9c51
commit c0af546d08
5 files changed
+44 -2

No files matched your search

@@ -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,
};
});
}
+8
View File
@@ -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) {