Notify on expiring Tailscale device keys

The Tailscale page already showed per-device key expiry; extend the
existing daily-reminder infrastructure (currently only for Secrets)
to push it out through the configured notification channels too, the
same way expiring secrets already are.

New tailscaleKeyExpiryScheduler.ts mirrors secretExpiryScheduler.ts:
runs once at startup (skipped if already run today) and daily
thereafter, checking every enabled Tailscale integration's devices for
keys expiring within the warning window and calling notify() with the
results. Reuses the exact same daily time/timezone setting as the
secret-expiry check (one "Daily reminder time" control, two
independent on/off toggles) rather than adding a second schedule for
users to configure.

Centralized the expiring-soon threshold and check (previously only
duplicated in the /synology and /tailscale route summaries) into
adapter.ts as `KEY_EXPIRY_WARN_DAYS` / `isKeyExpiringSoon()`, and
updated the devices route to use it instead of its own inline copy.

New `tailscaleKeyCheck` notification-event toggle (default on) in
settings, alongside the existing secret-expiry one.

Verified end-to-end against a temp SQLite DB + real migrations: a
tailscale integration pointed at a mock Tailscale API (one device
expiring in 10 days, one with key-expiry disabled) with the webhook
channel enabled and pointed at a mock receiver — confirmed the
scheduler's startup check queries the DB correctly, decrypts the
integration's credential, calls the adapter, filters out the
disabled-expiry device, and delivers a webhook payload naming only
the expiring device.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-17 18:59:27 +02:00
1 parent c0af546d08
commit 1ff59afb40
10 files changed
+122 -14

No files matched your search

+3 -2
View File
@@ -62,8 +62,9 @@ All modules from the original plan are built:
under Integrations → Manage integrations.
- **Settings** (admin-only) — notification channels (Gotify, ntfy, SMTP,
generic webhook) with per-channel test buttons, per-event toggles (DNS
record added/updated/deleted, daily secret-expiry reminder with a
configurable time/timezone), badge-color customization for both DNS
record added/updated/deleted, daily secret-expiry reminder and daily
Tailscale key-expiry reminder — both sharing one configurable
time/timezone), badge-color customization for both DNS
providers and integration types, and a date/time display format
(date order, 12/24-hour clock) applied consistently to every table in
the app.
+2
View File
@@ -21,10 +21,12 @@ import { agentReportRouter } from "./routes/agentReport.js";
import { integrationsRouter } from "./routes/integrations.js";
import { settingsRouter } from "./routes/settings.js";
import { initSecretExpiryScheduler } from "./services/secretExpiryScheduler.js";
import { initTailscaleKeyExpiryScheduler } from "./services/tailscaleKeyExpiryScheduler.js";
warnIfAuthNotConfigured();
await runMigrations();
await initSecretExpiryScheduler();
await initTailscaleKeyExpiryScheduler();
const __dirname = dirname(fileURLToPath(import.meta.url));
const webDist = join(__dirname, "..", "..", "web", "dist");
@@ -19,6 +19,15 @@
const BASE = "https://api.tailscale.com";
export const KEY_EXPIRY_WARN_DAYS = 30;
/** True if a device's key has already expired or expires within the warning window. */
export function isKeyExpiringSoon(device: Pick<TailscaleDevice, "keyExpiry" | "keyExpiryDisabled">, now = Date.now()): boolean {
if (device.keyExpiryDisabled || !device.keyExpiry) return false;
const daysLeft = (new Date(device.keyExpiry).getTime() - now) / 86_400_000;
return daysLeft <= KEY_EXPIRY_WARN_DAYS;
}
export interface TailscaleConfig {
tailnet: string;
apiKey: string;
+2 -9
View File
@@ -14,7 +14,7 @@ import {
} from "../integrations/fieldSchemas.js";
import { createIntegrationAdapter } from "../integrations/registry.js";
import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
import { createTailscaleAdapter } from "../integrations/tailscale/adapter.js";
import { createTailscaleAdapter, isKeyExpiringSoon } from "../integrations/tailscale/adapter.js";
import { createGiteaAdapter } from "../integrations/gitea/adapter.js";
import { createDockhandAdapter } from "../integrations/dockhand/adapter.js";
import { createSemaphoreAdapter } from "../integrations/semaphore/adapter.js";
@@ -315,26 +315,19 @@ 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,
expiringSoon: devices.filter((d) => isKeyExpiringSoon(d)).length,
},
});
} catch (err) {
+3
View File
@@ -4,6 +4,7 @@ import { requireAuth, requireRole } from "../auth/middleware.js";
import { recordAudit } from "../services/audit.js";
import { getSettings, updateSettings } from "../services/settingsStore.js";
import { scheduleSecretExpiryCheck } from "../services/secretExpiryScheduler.js";
import { scheduleTailscaleKeyExpiryCheck } from "../services/tailscaleKeyExpiryScheduler.js";
import { testGotify, testNtfy, testSmtp, testWebhook } from "../services/notify.js";
import { asyncHandler } from "../utils/asyncHandler.js";
@@ -57,6 +58,7 @@ const updateSchema = z.object({
dnsUpdate: z.boolean(),
dnsDelete: z.boolean(),
secretCheck: z.boolean(),
tailscaleKeyCheck: z.boolean(),
secretCheckTime: z.string().regex(/^\d{2}:\d{2}$/),
timezone: z.string(),
})
@@ -77,6 +79,7 @@ settingsRouter.put("/", requireRole("admin"), asyncHandler(async (req, res) => {
if (parsed.data.notifications) {
await scheduleSecretExpiryCheck();
await scheduleTailscaleKeyExpiryCheck();
}
await recordAudit({
+14
View File
@@ -177,3 +177,17 @@ export async function notifySecretExpiry(
`${expiring.length} secret${expiring.length !== 1 ? "s" : ""} need attention:\n\n${lines.join("\n")}`,
);
}
export async function notifyTailscaleKeyExpiry(
expiring: { integrationName: string; deviceLabel: string; daysLeft: number }[],
): Promise<void> {
if (expiring.length === 0) return;
if (!(await eventEnabled("tailscaleKeyCheck"))) return;
const lines = expiring.map(
(d) => `${d.daysLeft < 0 ? "✕ EXPIRED" : `⚠ ${d.daysLeft}d left`} — ${d.deviceLabel} [${d.integrationName}]`,
);
await notify(
"Homelab Manager — Tailscale Key Expiry",
`${expiring.length} device key${expiring.length !== 1 ? "s" : ""} need attention:\n\n${lines.join("\n")}`,
);
}
+3 -1
View File
@@ -39,7 +39,8 @@ export interface NotificationEvents {
dnsUpdate: boolean;
dnsDelete: boolean;
secretCheck: boolean;
secretCheckTime: string; // "HH:MM"
tailscaleKeyCheck: boolean;
secretCheckTime: string; // "HH:MM" — shared by the secret-expiry and Tailscale key-expiry checks
timezone: string;
}
@@ -75,6 +76,7 @@ const DEFAULTS: AppSettings = {
dnsUpdate: true,
dnsDelete: true,
secretCheck: true,
tailscaleKeyCheck: true,
secretCheckTime: "08:00",
timezone: "UTC",
},
@@ -0,0 +1,75 @@
import schedule from "node-schedule";
import { and, eq } from "drizzle-orm";
import { db } from "../db/client.js";
import { integrations } from "../db/schema.js";
import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
import { createTailscaleAdapter, isKeyExpiringSoon } from "../integrations/tailscale/adapter.js";
import { notifyTailscaleKeyExpiry } from "./notify.js";
import { getSettings, getInternalFlag, setInternalFlag } from "./settingsStore.js";
const LAST_RUN_FLAG = "tailscaleKeyCheckLastRunDate";
async function checkTailscaleKeyExpiry(): Promise<void> {
const rows = await db
.select({ id: integrations.id, name: integrations.name })
.from(integrations)
.where(and(eq(integrations.type, "tailscale"), eq(integrations.enabled, true)));
const expiring: { integrationName: string; deviceLabel: string; daysLeft: number }[] = [];
const now = Date.now();
for (const row of rows) {
try {
const loaded = await loadIntegrationConfig(row.id);
if (!loaded) continue;
const adapter = createTailscaleAdapter(loaded.config as any);
const devices = await adapter.listDevices();
for (const d of devices) {
if (!isKeyExpiringSoon(d, now)) continue;
const daysLeft = Math.floor((new Date(d.keyExpiry!).getTime() - now) / 86_400_000);
expiring.push({ integrationName: row.name, deviceLabel: d.label || d.hostname, daysLeft });
}
} catch (err) {
console.error(`[tailscaleKeyExpiry] check failed for integration ${row.id}:`, err);
}
}
await notifyTailscaleKeyExpiry(expiring);
}
async function checkTailscaleKeyExpiryOnce(): Promise<void> {
const today = new Date().toDateString();
const lastRun = await getInternalFlag(LAST_RUN_FLAG);
if (lastRun === today) return;
await setInternalFlag(LAST_RUN_FLAG, today);
await checkTailscaleKeyExpiry();
}
function cronFromTime(time: string): string {
const [h, m] = time.split(":").map(Number);
return `${Number.isFinite(m) ? m : 0} ${Number.isFinite(h) ? h : 8} * * *`;
}
let currentJob: schedule.Job | null = null;
/** (Re)schedules the daily Tailscale key-expiry check per the current notification settings. Call again after settings change. */
export async function scheduleTailscaleKeyExpiryCheck(): Promise<void> {
if (currentJob) {
currentJob.cancel();
currentJob = null;
}
const { notifications } = await getSettings();
currentJob = schedule.scheduleJob({ rule: cronFromTime(notifications.secretCheckTime), tz: notifications.timezone }, () => {
setInternalFlag(LAST_RUN_FLAG, "").catch(() => {});
getSettings().then(({ notifications: n }) => {
if (n.tailscaleKeyCheck) checkTailscaleKeyExpiry().catch((err) => console.error("[tailscaleKeyExpiry] check failed:", err));
});
});
console.log(`Tailscale key expiry check scheduled at ${notifications.secretCheckTime} (${notifications.timezone})`);
}
/** Runs once at startup (skipped if already run today), then arms the daily schedule. */
export async function initTailscaleKeyExpiryScheduler(): Promise<void> {
await checkTailscaleKeyExpiryOnce();
await scheduleTailscaleKeyExpiryCheck();
}
+1
View File
@@ -124,6 +124,7 @@ export interface NotificationEvents {
dnsUpdate: boolean;
dnsDelete: boolean;
secretCheck: boolean;
tailscaleKeyCheck: boolean;
secretCheckTime: string;
timezone: string;
}
@@ -41,6 +41,7 @@ const DEFAULT_NOTIFICATIONS: NotificationEvents = {
dnsUpdate: true,
dnsDelete: true,
secretCheck: true,
tailscaleKeyCheck: true,
secretCheckTime: "08:00",
timezone: "UTC",
};
@@ -487,6 +488,7 @@ export default function NotificationSettings() {
{ key: "dnsUpdate" as const, label: "DNS record updated" },
{ key: "dnsDelete" as const, label: "DNS record deleted" },
{ key: "secretCheck" as const, label: "Secret expiry reminder" },
{ key: "tailscaleKeyCheck" as const, label: "Tailscale key expiry reminder" },
].map(({ key, label }) => (
<label key={key} className="form-check form-check-single mb-2">
<input
@@ -498,6 +500,9 @@ export default function NotificationSettings() {
<span className="form-check-label">{label}</span>
</label>
))}
{(() => {
const dailyChecksEnabled = notifications.secretCheck || notifications.tailscaleKeyCheck;
return (
<div className="row g-2 mt-2">
<div className="col-6">
<label className="form-label">Daily reminder time</label>
@@ -505,16 +510,17 @@ export default function NotificationSettings() {
type="time"
className="form-control"
value={notifications.secretCheckTime}
disabled={!notifications.secretCheck}
disabled={!dailyChecksEnabled}
onChange={(e) => setNotifications((n) => ({ ...n, secretCheckTime: e.target.value }))}
/>
<div className="form-hint">Shared by the secret and Tailscale key expiry reminders above.</div>
</div>
<div className="col-6">
<label className="form-label">Timezone</label>
<select
className="form-select"
value={notifications.timezone}
disabled={!notifications.secretCheck}
disabled={!dailyChecksEnabled}
onChange={(e) => setNotifications((n) => ({ ...n, timezone: e.target.value }))}
>
{TIMEZONES.map((tz) => (
@@ -525,6 +531,8 @@ export default function NotificationSettings() {
</select>
</div>
</div>
);
})()}
</div>
</div>
</div>