diff --git a/README.md b/README.md index 97845b9..43e7fb9 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/server/src/index.ts b/server/src/index.ts index b357766..bbac88c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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"); diff --git a/server/src/integrations/tailscale/adapter.ts b/server/src/integrations/tailscale/adapter.ts index f3f0be3..db14c3e 100644 --- a/server/src/integrations/tailscale/adapter.ts +++ b/server/src/integrations/tailscale/adapter.ts @@ -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, 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; diff --git a/server/src/routes/integrations.ts b/server/src/routes/integrations.ts index f346007..4ac7bd8 100644 --- a/server/src/routes/integrations.ts +++ b/server/src/routes/integrations.ts @@ -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) { diff --git a/server/src/routes/settings.ts b/server/src/routes/settings.ts index 282798a..342ad88 100644 --- a/server/src/routes/settings.ts +++ b/server/src/routes/settings.ts @@ -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({ diff --git a/server/src/services/notify.ts b/server/src/services/notify.ts index 24279af..04466da 100644 --- a/server/src/services/notify.ts +++ b/server/src/services/notify.ts @@ -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 { + 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")}`, + ); +} diff --git a/server/src/services/settingsStore.ts b/server/src/services/settingsStore.ts index 662fcc1..a6d7d61 100644 --- a/server/src/services/settingsStore.ts +++ b/server/src/services/settingsStore.ts @@ -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", }, diff --git a/server/src/services/tailscaleKeyExpiryScheduler.ts b/server/src/services/tailscaleKeyExpiryScheduler.ts new file mode 100644 index 0000000..142fd23 --- /dev/null +++ b/server/src/services/tailscaleKeyExpiryScheduler.ts @@ -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 { + 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 { + 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 { + 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 { + await checkTailscaleKeyExpiryOnce(); + await scheduleTailscaleKeyExpiryCheck(); +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 7b1f8e7..787eb85 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -124,6 +124,7 @@ export interface NotificationEvents { dnsUpdate: boolean; dnsDelete: boolean; secretCheck: boolean; + tailscaleKeyCheck: boolean; secretCheckTime: string; timezone: string; } diff --git a/web/src/pages/settings/NotificationSettings.tsx b/web/src/pages/settings/NotificationSettings.tsx index 95d165a..9d20085 100644 --- a/web/src/pages/settings/NotificationSettings.tsx +++ b/web/src/pages/settings/NotificationSettings.tsx @@ -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 }) => ( ))} -
-
- - setNotifications((n) => ({ ...n, secretCheckTime: e.target.value }))} - /> -
-
- - -
-
+ {(() => { + const dailyChecksEnabled = notifications.secretCheck || notifications.tailscaleKeyCheck; + return ( +
+
+ + setNotifications((n) => ({ ...n, secretCheckTime: e.target.value }))} + /> +
Shared by the secret and Tailscale key expiry reminders above.
+
+
+ + +
+
+ ); + })()}