Read SSL certificate expiry from the live server instead of trusting a typed-in date
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>
This commit is contained in:
1 parent
0da73711d9
commit
7e81306aa7
11 files changed
+1557
-26
No files matched your search
@@ -6,6 +6,7 @@ import { secrets, secretTypes } from "../db/schema.js";
|
||||
import { requireAuth, requireRole } from "../auth/middleware.js";
|
||||
import { recordAudit } from "../services/audit.js";
|
||||
import { computeSecretStatus } from "../services/secretStatus.js";
|
||||
import { fetchCertExpiry, refreshTlsSecret } from "../services/tlsCheck.js";
|
||||
import { asyncHandler } from "../utils/asyncHandler.js";
|
||||
|
||||
export const secretsRouter = Router();
|
||||
@@ -23,9 +24,12 @@ const secretInput = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
type: z.enum(secretTypes),
|
||||
description: z.string().max(2000).optional(),
|
||||
expiryDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected YYYY-MM-DD"),
|
||||
// Optional only because a monitored certificate gets its date from the live cert; validated below.
|
||||
expiryDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected YYYY-MM-DD").optional(),
|
||||
warnDays: z.number().int().min(0).max(3650).default(30),
|
||||
notes: z.string().max(4000).optional(),
|
||||
checkHost: z.string().min(1).max(253).regex(/^[A-Za-z0-9._:-]+$/, "Invalid host").nullable().optional(),
|
||||
checkPort: z.number().int().min(1).max(65535).nullable().optional(),
|
||||
});
|
||||
|
||||
secretsRouter.post("/", requireRole("operator"), asyncHandler(async (req, res) => {
|
||||
@@ -33,8 +37,37 @@ secretsRouter.post("/", requireRole("operator"), asyncHandler(async (req, res) =
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
|
||||
}
|
||||
const { checkHost, checkPort, expiryDate: manualExpiry, ...rest } = parsed.data;
|
||||
|
||||
const [created] = await db.insert(secrets).values(parsed.data).returning();
|
||||
if (checkHost && rest.type !== "ssl_certificate") {
|
||||
return res.status(400).json({ error: "invalid_body", message: "Only SSL certificates can be checked against a host." });
|
||||
}
|
||||
|
||||
let expiryDate = manualExpiry;
|
||||
let lastCheckedAt: string | null = null;
|
||||
let lastCheckError: string | null = null;
|
||||
if (checkHost) {
|
||||
lastCheckedAt = new Date().toISOString();
|
||||
try {
|
||||
expiryDate = await fetchCertExpiry(checkHost, checkPort ?? 443);
|
||||
} catch (err) {
|
||||
lastCheckError = err instanceof Error ? err.message : String(err);
|
||||
if (!manualExpiry) {
|
||||
return res.status(400).json({
|
||||
error: "tls_check_failed",
|
||||
message: `Couldn't read the certificate from ${checkHost}:${checkPort ?? 443} (${lastCheckError}). Fix the host, or enter an expiry date manually.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!expiryDate) {
|
||||
return res.status(400).json({ error: "invalid_body", message: "An expiry date is required unless a host to check is given." });
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(secrets)
|
||||
.values({ ...rest, expiryDate, checkHost: checkHost ?? null, checkPort: checkHost ? (checkPort ?? 443) : null, lastCheckedAt, lastCheckError })
|
||||
.returning();
|
||||
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
@@ -60,11 +93,38 @@ secretsRouter.patch("/:id", requireRole("operator"), asyncHandler(async (req, re
|
||||
return res.status(404).json({ error: "not_found" });
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(secrets)
|
||||
.set({ ...parsed.data, updatedAt: new Date().toISOString() })
|
||||
.where(eq(secrets.id, id))
|
||||
.returning();
|
||||
const { checkHost, checkPort, ...rest } = parsed.data;
|
||||
const nextType = rest.type ?? existing.type;
|
||||
if (checkHost && nextType !== "ssl_certificate") {
|
||||
return res.status(400).json({ error: "invalid_body", message: "Only SSL certificates can be checked against a host." });
|
||||
}
|
||||
// Changing a monitored secret to a non-certificate type quietly ends the monitoring rather than leaving a stale check behind.
|
||||
const nextHost = nextType !== "ssl_certificate" ? null : checkHost !== undefined ? checkHost : existing.checkHost;
|
||||
const nextPort = checkPort !== undefined ? checkPort : existing.checkPort;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const changes: Partial<typeof secrets.$inferInsert> = { ...rest, updatedAt: now };
|
||||
if (!nextHost) {
|
||||
Object.assign(changes, { checkHost: null, checkPort: null, lastCheckedAt: null, lastCheckError: null });
|
||||
} else {
|
||||
const port = nextPort ?? 443;
|
||||
Object.assign(changes, { checkHost: nextHost, checkPort: port });
|
||||
if (nextHost !== existing.checkHost || port !== (existing.checkPort ?? 443)) {
|
||||
changes.lastCheckedAt = now;
|
||||
try {
|
||||
changes.expiryDate = await fetchCertExpiry(nextHost, port);
|
||||
changes.lastCheckError = null;
|
||||
} catch (err) {
|
||||
// Keep whatever date we already have (a manually supplied one, else the existing one) and record why the check failed.
|
||||
changes.lastCheckError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
} else {
|
||||
// Same host as before: the live certificate stays the source of truth, so ignore a hand-typed date.
|
||||
delete changes.expiryDate;
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await db.update(secrets).set(changes).where(eq(secrets.id, id)).returning();
|
||||
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
@@ -78,6 +138,26 @@ secretsRouter.patch("/:id", requireRole("operator"), asyncHandler(async (req, re
|
||||
res.json({ secret: { ...updated, ...computeSecretStatus(updated.expiryDate, updated.warnDays) } });
|
||||
}));
|
||||
|
||||
secretsRouter.post("/:id/check-tls", requireRole("operator"), asyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const result = await refreshTlsSecret(id);
|
||||
if (!result) {
|
||||
return res.status(400).json({ error: "not_monitored", message: "This secret has no host to check." });
|
||||
}
|
||||
const [updated] = await db.select().from(secrets).where(eq(secrets.id, id)).limit(1);
|
||||
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "secret",
|
||||
action: "check_tls",
|
||||
targetType: "secret",
|
||||
targetId: id,
|
||||
detail: { name: result.name, host: result.host, port: result.port, ok: result.ok, error: result.error },
|
||||
});
|
||||
|
||||
res.json({ secret: { ...updated, ...computeSecretStatus(updated.expiryDate, updated.warnDays) }, result });
|
||||
}));
|
||||
|
||||
secretsRouter.delete("/:id", requireRole("operator"), asyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const [existing] = await db.select().from(secrets).where(eq(secrets.id, id)).limit(1);
|
||||
|
||||
Reference in new issue
Block a user