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:
1 parent
c0af546d08
commit
1ff59afb40
10 files changed
+147
-39
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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")}`,
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,33 +500,39 @@ export default function NotificationSettings() {
|
||||
<span className="form-check-label">{label}</span>
|
||||
</label>
|
||||
))}
|
||||
<div className="row g-2 mt-2">
|
||||
<div className="col-6">
|
||||
<label className="form-label">Daily reminder time</label>
|
||||
<input
|
||||
type="time"
|
||||
className="form-control"
|
||||
value={notifications.secretCheckTime}
|
||||
disabled={!notifications.secretCheck}
|
||||
onChange={(e) => setNotifications((n) => ({ ...n, secretCheckTime: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-6">
|
||||
<label className="form-label">Timezone</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={notifications.timezone}
|
||||
disabled={!notifications.secretCheck}
|
||||
onChange={(e) => setNotifications((n) => ({ ...n, timezone: e.target.value }))}
|
||||
>
|
||||
{TIMEZONES.map((tz) => (
|
||||
<option key={tz} value={tz}>
|
||||
{tz}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{(() => {
|
||||
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>
|
||||
<input
|
||||
type="time"
|
||||
className="form-control"
|
||||
value={notifications.secretCheckTime}
|
||||
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={!dailyChecksEnabled}
|
||||
onChange={(e) => setNotifications((n) => ({ ...n, timezone: e.target.value }))}
|
||||
>
|
||||
{TIMEZONES.map((tz) => (
|
||||
<option key={tz} value={tz}>
|
||||
{tz}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user