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
+122
-14
No files matched your search
@@ -62,8 +62,9 @@ All modules from the original plan are built:
|
|||||||
under Integrations → Manage integrations.
|
under Integrations → Manage integrations.
|
||||||
- **Settings** (admin-only) — notification channels (Gotify, ntfy, SMTP,
|
- **Settings** (admin-only) — notification channels (Gotify, ntfy, SMTP,
|
||||||
generic webhook) with per-channel test buttons, per-event toggles (DNS
|
generic webhook) with per-channel test buttons, per-event toggles (DNS
|
||||||
record added/updated/deleted, daily secret-expiry reminder with a
|
record added/updated/deleted, daily secret-expiry reminder and daily
|
||||||
configurable time/timezone), badge-color customization for both DNS
|
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
|
providers and integration types, and a date/time display format
|
||||||
(date order, 12/24-hour clock) applied consistently to every table in
|
(date order, 12/24-hour clock) applied consistently to every table in
|
||||||
the app.
|
the app.
|
||||||
|
|||||||
@@ -21,10 +21,12 @@ import { agentReportRouter } from "./routes/agentReport.js";
|
|||||||
import { integrationsRouter } from "./routes/integrations.js";
|
import { integrationsRouter } from "./routes/integrations.js";
|
||||||
import { settingsRouter } from "./routes/settings.js";
|
import { settingsRouter } from "./routes/settings.js";
|
||||||
import { initSecretExpiryScheduler } from "./services/secretExpiryScheduler.js";
|
import { initSecretExpiryScheduler } from "./services/secretExpiryScheduler.js";
|
||||||
|
import { initTailscaleKeyExpiryScheduler } from "./services/tailscaleKeyExpiryScheduler.js";
|
||||||
|
|
||||||
warnIfAuthNotConfigured();
|
warnIfAuthNotConfigured();
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
await initSecretExpiryScheduler();
|
await initSecretExpiryScheduler();
|
||||||
|
await initTailscaleKeyExpiryScheduler();
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const webDist = join(__dirname, "..", "..", "web", "dist");
|
const webDist = join(__dirname, "..", "..", "web", "dist");
|
||||||
|
|||||||
@@ -19,6 +19,15 @@
|
|||||||
|
|
||||||
const BASE = "https://api.tailscale.com";
|
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 {
|
export interface TailscaleConfig {
|
||||||
tailnet: string;
|
tailnet: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
} from "../integrations/fieldSchemas.js";
|
} from "../integrations/fieldSchemas.js";
|
||||||
import { createIntegrationAdapter } from "../integrations/registry.js";
|
import { createIntegrationAdapter } from "../integrations/registry.js";
|
||||||
import { loadIntegrationConfig } from "../integrations/loadIntegration.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 { createGiteaAdapter } from "../integrations/gitea/adapter.js";
|
||||||
import { createDockhandAdapter } from "../integrations/dockhand/adapter.js";
|
import { createDockhandAdapter } from "../integrations/dockhand/adapter.js";
|
||||||
import { createSemaphoreAdapter } from "../integrations/semaphore/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) };
|
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) => {
|
expiringSoon: devices.filter((d) => isKeyExpiringSoon(d)).length,
|
||||||
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) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { requireAuth, requireRole } from "../auth/middleware.js";
|
|||||||
import { recordAudit } from "../services/audit.js";
|
import { recordAudit } from "../services/audit.js";
|
||||||
import { getSettings, updateSettings } from "../services/settingsStore.js";
|
import { getSettings, updateSettings } from "../services/settingsStore.js";
|
||||||
import { scheduleSecretExpiryCheck } from "../services/secretExpiryScheduler.js";
|
import { scheduleSecretExpiryCheck } from "../services/secretExpiryScheduler.js";
|
||||||
|
import { scheduleTailscaleKeyExpiryCheck } from "../services/tailscaleKeyExpiryScheduler.js";
|
||||||
import { testGotify, testNtfy, testSmtp, testWebhook } from "../services/notify.js";
|
import { testGotify, testNtfy, testSmtp, testWebhook } from "../services/notify.js";
|
||||||
import { asyncHandler } from "../utils/asyncHandler.js";
|
import { asyncHandler } from "../utils/asyncHandler.js";
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ const updateSchema = z.object({
|
|||||||
dnsUpdate: z.boolean(),
|
dnsUpdate: z.boolean(),
|
||||||
dnsDelete: z.boolean(),
|
dnsDelete: z.boolean(),
|
||||||
secretCheck: z.boolean(),
|
secretCheck: z.boolean(),
|
||||||
|
tailscaleKeyCheck: z.boolean(),
|
||||||
secretCheckTime: z.string().regex(/^\d{2}:\d{2}$/),
|
secretCheckTime: z.string().regex(/^\d{2}:\d{2}$/),
|
||||||
timezone: z.string(),
|
timezone: z.string(),
|
||||||
})
|
})
|
||||||
@@ -77,6 +79,7 @@ settingsRouter.put("/", requireRole("admin"), asyncHandler(async (req, res) => {
|
|||||||
|
|
||||||
if (parsed.data.notifications) {
|
if (parsed.data.notifications) {
|
||||||
await scheduleSecretExpiryCheck();
|
await scheduleSecretExpiryCheck();
|
||||||
|
await scheduleTailscaleKeyExpiryCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
await recordAudit({
|
await recordAudit({
|
||||||
|
|||||||
@@ -177,3 +177,17 @@ export async function notifySecretExpiry(
|
|||||||
`${expiring.length} secret${expiring.length !== 1 ? "s" : ""} need attention:\n\n${lines.join("\n")}`,
|
`${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;
|
dnsUpdate: boolean;
|
||||||
dnsDelete: boolean;
|
dnsDelete: boolean;
|
||||||
secretCheck: 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;
|
timezone: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +76,7 @@ const DEFAULTS: AppSettings = {
|
|||||||
dnsUpdate: true,
|
dnsUpdate: true,
|
||||||
dnsDelete: true,
|
dnsDelete: true,
|
||||||
secretCheck: true,
|
secretCheck: true,
|
||||||
|
tailscaleKeyCheck: true,
|
||||||
secretCheckTime: "08:00",
|
secretCheckTime: "08:00",
|
||||||
timezone: "UTC",
|
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;
|
dnsUpdate: boolean;
|
||||||
dnsDelete: boolean;
|
dnsDelete: boolean;
|
||||||
secretCheck: boolean;
|
secretCheck: boolean;
|
||||||
|
tailscaleKeyCheck: boolean;
|
||||||
secretCheckTime: string;
|
secretCheckTime: string;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const DEFAULT_NOTIFICATIONS: NotificationEvents = {
|
|||||||
dnsUpdate: true,
|
dnsUpdate: true,
|
||||||
dnsDelete: true,
|
dnsDelete: true,
|
||||||
secretCheck: true,
|
secretCheck: true,
|
||||||
|
tailscaleKeyCheck: true,
|
||||||
secretCheckTime: "08:00",
|
secretCheckTime: "08:00",
|
||||||
timezone: "UTC",
|
timezone: "UTC",
|
||||||
};
|
};
|
||||||
@@ -487,6 +488,7 @@ export default function NotificationSettings() {
|
|||||||
{ key: "dnsUpdate" as const, label: "DNS record updated" },
|
{ key: "dnsUpdate" as const, label: "DNS record updated" },
|
||||||
{ key: "dnsDelete" as const, label: "DNS record deleted" },
|
{ key: "dnsDelete" as const, label: "DNS record deleted" },
|
||||||
{ key: "secretCheck" as const, label: "Secret expiry reminder" },
|
{ key: "secretCheck" as const, label: "Secret expiry reminder" },
|
||||||
|
{ key: "tailscaleKeyCheck" as const, label: "Tailscale key expiry reminder" },
|
||||||
].map(({ key, label }) => (
|
].map(({ key, label }) => (
|
||||||
<label key={key} className="form-check form-check-single mb-2">
|
<label key={key} className="form-check form-check-single mb-2">
|
||||||
<input
|
<input
|
||||||
@@ -498,6 +500,9 @@ export default function NotificationSettings() {
|
|||||||
<span className="form-check-label">{label}</span>
|
<span className="form-check-label">{label}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
|
{(() => {
|
||||||
|
const dailyChecksEnabled = notifications.secretCheck || notifications.tailscaleKeyCheck;
|
||||||
|
return (
|
||||||
<div className="row g-2 mt-2">
|
<div className="row g-2 mt-2">
|
||||||
<div className="col-6">
|
<div className="col-6">
|
||||||
<label className="form-label">Daily reminder time</label>
|
<label className="form-label">Daily reminder time</label>
|
||||||
@@ -505,16 +510,17 @@ export default function NotificationSettings() {
|
|||||||
type="time"
|
type="time"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
value={notifications.secretCheckTime}
|
value={notifications.secretCheckTime}
|
||||||
disabled={!notifications.secretCheck}
|
disabled={!dailyChecksEnabled}
|
||||||
onChange={(e) => setNotifications((n) => ({ ...n, secretCheckTime: e.target.value }))}
|
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>
|
||||||
<div className="col-6">
|
<div className="col-6">
|
||||||
<label className="form-label">Timezone</label>
|
<label className="form-label">Timezone</label>
|
||||||
<select
|
<select
|
||||||
className="form-select"
|
className="form-select"
|
||||||
value={notifications.timezone}
|
value={notifications.timezone}
|
||||||
disabled={!notifications.secretCheck}
|
disabled={!dailyChecksEnabled}
|
||||||
onChange={(e) => setNotifications((n) => ({ ...n, timezone: e.target.value }))}
|
onChange={(e) => setNotifications((n) => ({ ...n, timezone: e.target.value }))}
|
||||||
>
|
>
|
||||||
{TIMEZONES.map((tz) => (
|
{TIMEZONES.map((tz) => (
|
||||||
@@ -525,6 +531,8 @@ export default function NotificationSettings() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user