A certificate secret's expiry was only ever what someone typed in, so a renewed cert (or a wrong date) meant the app's reminders were silently wrong. A certificate secret can now be given a host:port; the app opens a real TLS connection and reads the certificate's actual expiry — on create/edit (if the host changes), daily, and via a per-row "Check now" — and keeps expiryDate in sync. Because the daily refresh runs before the existing expiry check, the reminder is always computed from what's actually being served. Verification is deliberately off for the connection: homelab services routinely serve self-signed/internal-CA certs, and an already-expired one is exactly the case worth reporting, which a verifying connection would refuse before exposing the dates. Failure handling avoids the silent-staleness this is meant to fix: a failed check keeps the last known date, records why on the row (shown as a "Check failed" badge), and is listed in the daily secrets notification. Creating a monitored secret whose host can't be reached and with no manual date is rejected with the reason rather than saved blank. A non-TLS port (the likeliest typo) gets a plain-language error instead of raw OpenSSL output. Server-side connections to a user-supplied host:port need the same operator role that already gates editing secrets (and running Semaphore templates, which is strictly more powerful); the host is validated against a strict character set before any connection is made. New nullable secrets columns (check_host, check_port, last_checked_at, last_check_error) via migration 0007; existing rows are unaffected. Verified against real TLS servers (openssl-generated certs) and the real secrets router with a stubbed session: a live 45-day cert read back as the correct date via both an IP host (no SNI) and a hostname; an already-expired cert reported its past date and shows as expired; refused connections, a server that accepts but never answers (times out), and a plain non-TLS server each produced a descriptive error rather than a hang or crash. Through the router: create with a host and no date reads the date; unreachable host with no date -> 400 with the reason; unreachable with a manual date -> saved with the error recorded; host on a non-certificate type and an invalid host string -> 400; a hand-typed date on a monitored secret is ignored; changing the host re-checks immediately; changing the type away from certificate ends monitoring; a viewer gets 403 on Check now. 23 checks, all passing (a first re-run showed 2 spurious failures that were leftover rows from the previous run's scratch database, confirmed by a clean re-run). Real dev database mtime untouched throughout. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
67 lines
2.8 KiB
TypeScript
67 lines
2.8 KiB
TypeScript
import schedule from "node-schedule";
|
|
import { db } from "../db/client.js";
|
|
import { secrets } from "../db/schema.js";
|
|
import { computeSecretStatus } from "./secretStatus.js";
|
|
import { notifySecretExpiry } from "./notify.js";
|
|
import { refreshTlsSecrets, type TlsCheckResult } from "./tlsCheck.js";
|
|
import { getSettings, getInternalFlag, setInternalFlag } from "./settingsStore.js";
|
|
|
|
const LAST_RUN_FLAG = "secretCheckLastRunDate";
|
|
|
|
/**
|
|
* Refreshes every monitored certificate's expiry from the live server first,
|
|
* so the alert below is computed from what's actually being served rather
|
|
* than a stale date. This runs on every scheduled pass regardless of the
|
|
* "secret expiry reminder" toggle — that toggle only controls whether a
|
|
* notification is sent (notifySecretExpiry checks it itself).
|
|
*/
|
|
async function checkSecretExpiry(): Promise<void> {
|
|
let checkFailures: TlsCheckResult[] = [];
|
|
try {
|
|
checkFailures = (await refreshTlsSecrets()).filter((r) => !r.ok);
|
|
} catch (err) {
|
|
console.error("[secretExpiry] TLS refresh failed:", err);
|
|
}
|
|
|
|
const rows = await db.select().from(secrets);
|
|
const expiring = rows
|
|
.map((s) => ({ name: s.name, ...computeSecretStatus(s.expiryDate, s.warnDays) }))
|
|
.filter((s): s is typeof s & { status: "expired" | "expiring" } => s.status === "expired" || s.status === "expiring");
|
|
await notifySecretExpiry(expiring, checkFailures);
|
|
}
|
|
|
|
async function checkSecretExpiryOnce(): 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 checkSecretExpiry();
|
|
}
|
|
|
|
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 secret-expiry check per the current notification settings. Call again after settings change. */
|
|
export async function scheduleSecretExpiryCheck(): 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(() => {});
|
|
checkSecretExpiry().catch((err) => console.error("[secretExpiry] check failed:", err));
|
|
});
|
|
console.log(`Secret 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 initSecretExpiryScheduler(): Promise<void> {
|
|
await checkSecretExpiryOnce();
|
|
await scheduleSecretExpiryCheck();
|
|
}
|