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:
1 parent
a781df9c51
commit
c0af546d08
5 files changed
+44
-2
No files matched your search
@@ -36,6 +36,8 @@ export interface TailscaleDevice {
|
|||||||
isExitNode: boolean;
|
isExitNode: boolean;
|
||||||
authorized: boolean;
|
authorized: boolean;
|
||||||
online: boolean;
|
online: boolean;
|
||||||
|
keyExpiry: string | null;
|
||||||
|
keyExpiryDisabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TailscaleAdapter {
|
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`);
|
const data = await api("GET", `/api/v2/tailnet/${tailnetPath()}/devices?fields=all`);
|
||||||
return (data.devices || []).map((d: any) => {
|
return (data.devices || []).map((d: any) => {
|
||||||
const enabledRoutes: string[] = d.enabledRoutes || [];
|
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 {
|
return {
|
||||||
id: d.id,
|
id: d.id,
|
||||||
nodeId: d.nodeId || 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"),
|
isExitNode: enabledRoutes.includes("0.0.0.0/0") && enabledRoutes.includes("::/0"),
|
||||||
authorized: !!d.authorized,
|
authorized: !!d.authorized,
|
||||||
online: !!d.connectedToControl,
|
online: !!d.connectedToControl,
|
||||||
|
keyExpiry: hasRealExpiry ? expires! : null,
|
||||||
|
keyExpiryDisabled: !!d.keyExpiryDisabled,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -315,18 +315,26 @@ async function requireTailscaleAdapter(req: Request, res: Response) {
|
|||||||
return { integration: loaded.integration, adapter: createTailscaleAdapter(loaded.config as any) };
|
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) => {
|
integrationsRouter.get("/:id/tailscale/devices", asyncHandler(async (req, res) => {
|
||||||
const found = await requireTailscaleAdapter(req, res);
|
const found = await requireTailscaleAdapter(req, res);
|
||||||
if (!found) return;
|
if (!found) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const devices = await found.adapter.listDevices();
|
const devices = await found.adapter.listDevices();
|
||||||
|
const now = Date.now();
|
||||||
res.json({
|
res.json({
|
||||||
devices,
|
devices,
|
||||||
summary: {
|
summary: {
|
||||||
total: devices.length,
|
total: devices.length,
|
||||||
online: devices.filter((d) => d.online).length,
|
online: devices.filter((d) => d.online).length,
|
||||||
unauthorized: devices.filter((d) => !d.authorized).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) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -310,11 +310,13 @@ export interface TailscaleDevice {
|
|||||||
isExitNode: boolean;
|
isExitNode: boolean;
|
||||||
authorized: boolean;
|
authorized: boolean;
|
||||||
online: boolean;
|
online: boolean;
|
||||||
|
keyExpiry: string | null;
|
||||||
|
keyExpiryDisabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TailscaleDevicesResponse {
|
export interface TailscaleDevicesResponse {
|
||||||
devices: TailscaleDevice[];
|
devices: TailscaleDevice[];
|
||||||
summary: { total: number; online: number; unauthorized: number };
|
summary: { total: number; online: number; unauthorized: number; expiringSoon: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GiteaWorkflowRun {
|
export interface GiteaWorkflowRun {
|
||||||
|
|||||||
@@ -181,6 +181,9 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
|||||||
{tailscaleSummary!.unauthorized > 0 && (
|
{tailscaleSummary!.unauthorized > 0 && (
|
||||||
<div className="text-yellow small mt-1">{tailscaleSummary!.unauthorized} awaiting authorization</div>
|
<div className="text-yellow small mt-1">{tailscaleSummary!.unauthorized} awaiting authorization</div>
|
||||||
)}
|
)}
|
||||||
|
{tailscaleSummary!.expiringSoon > 0 && (
|
||||||
|
<div className="text-yellow small mt-1">{tailscaleSummary!.expiringSoon} key(s) expiring soon</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : isLiveGitea ? (
|
) : isLiveGitea ? (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
|
|||||||
@@ -9,6 +9,22 @@ import {
|
|||||||
} from "../api/client";
|
} from "../api/client";
|
||||||
import { formatDateTime } from "../utils/date";
|
import { formatDateTime } from "../utils/date";
|
||||||
|
|
||||||
|
const KEY_EXPIRY_WARN_DAYS = 30;
|
||||||
|
|
||||||
|
function keyExpiryBadge(device: TailscaleDevice) {
|
||||||
|
if (device.keyExpiryDisabled || !device.keyExpiry) {
|
||||||
|
return <span className="text-secondary">Never</span>;
|
||||||
|
}
|
||||||
|
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))} <span className={`badge ${style} ms-1`}>{daysLeft < 0 ? "Expired" : `${Math.floor(daysLeft)}d left`}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Tailscale({ user }: { user: CurrentUser }) {
|
export default function Tailscale({ user }: { user: CurrentUser }) {
|
||||||
const canEdit = user.role === "admin" || user.role === "operator";
|
const canEdit = user.role === "admin" || user.role === "operator";
|
||||||
|
|
||||||
@@ -120,6 +136,7 @@ export default function Tailscale({ user }: { user: CurrentUser }) {
|
|||||||
<span className="text-secondary fw-normal ms-2">
|
<span className="text-secondary fw-normal ms-2">
|
||||||
{data.summary.online}/{data.summary.total} online
|
{data.summary.online}/{data.summary.total} online
|
||||||
{data.summary.unauthorized > 0 && `, ${data.summary.unauthorized} awaiting authorization`}
|
{data.summary.unauthorized > 0 && `, ${data.summary.unauthorized} awaiting authorization`}
|
||||||
|
{data.summary.expiringSoon > 0 && `, ${data.summary.expiringSoon} key(s) expiring soon`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -142,6 +159,7 @@ export default function Tailscale({ user }: { user: CurrentUser }) {
|
|||||||
<th>OS</th>
|
<th>OS</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Last seen</th>
|
<th>Last seen</th>
|
||||||
|
<th>Key expiry</th>
|
||||||
{canEdit && <th className="w-1">Actions</th>}
|
{canEdit && <th className="w-1">Actions</th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -163,6 +181,7 @@ export default function Tailscale({ user }: { user: CurrentUser }) {
|
|||||||
{!d.authorized && <span className="badge bg-yellow-lt text-yellow">Unauthorized</span>}
|
{!d.authorized && <span className="badge bg-yellow-lt text-yellow">Unauthorized</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-secondary">{d.lastSeen ? formatDateTime(new Date(d.lastSeen)) : "—"}</td>
|
<td className="text-secondary">{d.lastSeen ? formatDateTime(new Date(d.lastSeen)) : "—"}</td>
|
||||||
|
<td>{keyExpiryBadge(d)}</td>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<td>
|
<td>
|
||||||
<div className="btn-list flex-nowrap">
|
<div className="btn-list flex-nowrap">
|
||||||
@@ -185,7 +204,7 @@ export default function Tailscale({ user }: { user: CurrentUser }) {
|
|||||||
))}
|
))}
|
||||||
{data?.devices.length === 0 && (
|
{data?.devices.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={canEdit ? 6 : 5} className="text-secondary text-center">
|
<td colSpan={canEdit ? 7 : 6} className="text-secondary text-center">
|
||||||
No devices in this tailnet.
|
No devices in this tailnet.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
Reference in new issue
Block a user