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

+3 -1
View File
@@ -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 {
+3
View File
@@ -181,6 +181,9 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
{tailscaleSummary!.unauthorized > 0 && (
<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>
) : isLiveGitea ? (
<div className="mt-2">
+20 -1
View File
@@ -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 <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 }) {
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">
{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`}
</span>
)}
</h3>
@@ -142,6 +159,7 @@ export default function Tailscale({ user }: { user: CurrentUser }) {
<th>OS</th>
<th>Status</th>
<th>Last seen</th>
<th>Key expiry</th>
{canEdit && <th className="w-1">Actions</th>}
</tr>
</thead>
@@ -163,6 +181,7 @@ export default function Tailscale({ user }: { user: CurrentUser }) {
{!d.authorized && <span className="badge bg-yellow-lt text-yellow">Unauthorized</span>}
</td>
<td className="text-secondary">{d.lastSeen ? formatDateTime(new Date(d.lastSeen)) : "—"}</td>
<td>{keyExpiryBadge(d)}</td>
{canEdit && (
<td>
<div className="btn-list flex-nowrap">
@@ -185,7 +204,7 @@ export default function Tailscale({ user }: { user: CurrentUser }) {
))}
{data?.devices.length === 0 && (
<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.
</td>
</tr>