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:
bobbanandClaude Sonnet 5 committed 2026-09-25 23:41:29 +02:00
1 parent 0da73711d9
commit 7e81306aa7
11 files changed
+1555 -24

No files matched your search

+6 -1
View File
@@ -34,7 +34,12 @@ All modules from the original plan are built:
troubleshooting connectivity issues (ported from Sloth Manager's troubleshooting connectivity issues (ported from Sloth Manager's
provider-diagnostics log, generalized to cover every integration this app provider-diagnostics log, generalized to cover every integration this app
has, not just DNS) has, not just DNS)
- **Secrets** — expiry tracking for API tokens/certs/passwords - **Secrets** — expiry tracking for API tokens/certs/passwords. An SSL
certificate can optionally be given a host:port to watch: the app opens a
real TLS connection (daily, and on demand via "Check now"), reads the
certificate's actual expiry, and keeps the date current — so a renewed cert
is picked up automatically and an unreachable host is flagged instead of
silently going stale
- **IP Addresses (IPAM)** — inventory of IPs across vendors/locations, - **IP Addresses (IPAM)** — inventory of IPs across vendors/locations,
with "Sync from Tailscale" and "Sync from Proxmox" actions to pull in with "Sync from Tailscale" and "Sync from Proxmox" actions to pull in
tailnet device IPs and VM/LXC IPs (never overwrites a tailnet device IPs and VM/LXC IPs (never overwrites a
+4
View File
@@ -0,0 +1,4 @@
ALTER TABLE `secrets` ADD `check_host` text;--> statement-breakpoint
ALTER TABLE `secrets` ADD `check_port` integer;--> statement-breakpoint
ALTER TABLE `secrets` ADD `last_checked_at` text;--> statement-breakpoint
ALTER TABLE `secrets` ADD `last_check_error` text;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -50,6 +50,13 @@
"when": 1790103102037, "when": 1790103102037,
"tag": "0006_aberrant_darkhawk", "tag": "0006_aberrant_darkhawk",
"breakpoints": true "breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1790372043652,
"tag": "0007_secret_madripoor",
"breakpoints": true
} }
] ]
} }
+5
View File
@@ -82,6 +82,11 @@ export const secrets = sqliteTable("secrets", {
expiryDate: text("expiry_date").notNull(), // ISO date, e.g. 2026-03-01 expiryDate: text("expiry_date").notNull(), // ISO date, e.g. 2026-03-01
warnDays: integer("warn_days").notNull().default(30), warnDays: integer("warn_days").notNull().default(30),
notes: text("notes"), notes: text("notes"),
// ssl_certificate secrets only: when set, expiryDate is read from the live certificate on this host:port instead of typed in.
checkHost: text("check_host"),
checkPort: integer("check_port"),
lastCheckedAt: text("last_checked_at"),
lastCheckError: text("last_check_error"),
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
+87 -7
View File
@@ -6,6 +6,7 @@ import { secrets, secretTypes } from "../db/schema.js";
import { requireAuth, requireRole } from "../auth/middleware.js"; import { requireAuth, requireRole } from "../auth/middleware.js";
import { recordAudit } from "../services/audit.js"; import { recordAudit } from "../services/audit.js";
import { computeSecretStatus } from "../services/secretStatus.js"; import { computeSecretStatus } from "../services/secretStatus.js";
import { fetchCertExpiry, refreshTlsSecret } from "../services/tlsCheck.js";
import { asyncHandler } from "../utils/asyncHandler.js"; import { asyncHandler } from "../utils/asyncHandler.js";
export const secretsRouter = Router(); export const secretsRouter = Router();
@@ -23,9 +24,12 @@ const secretInput = z.object({
name: z.string().min(1).max(200), name: z.string().min(1).max(200),
type: z.enum(secretTypes), type: z.enum(secretTypes),
description: z.string().max(2000).optional(), 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), warnDays: z.number().int().min(0).max(3650).default(30),
notes: z.string().max(4000).optional(), 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) => { secretsRouter.post("/", requireRole("operator"), asyncHandler(async (req, res) => {
@@ -33,8 +37,37 @@ secretsRouter.post("/", requireRole("operator"), asyncHandler(async (req, res) =
if (!parsed.success) { if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); 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({ await recordAudit({
actor: req.currentUser!, actor: req.currentUser!,
@@ -60,11 +93,38 @@ secretsRouter.patch("/:id", requireRole("operator"), asyncHandler(async (req, re
return res.status(404).json({ error: "not_found" }); return res.status(404).json({ error: "not_found" });
} }
const [updated] = await db const { checkHost, checkPort, ...rest } = parsed.data;
.update(secrets) const nextType = rest.type ?? existing.type;
.set({ ...parsed.data, updatedAt: new Date().toISOString() }) if (checkHost && nextType !== "ssl_certificate") {
.where(eq(secrets.id, id)) return res.status(400).json({ error: "invalid_body", message: "Only SSL certificates can be checked against a host." });
.returning(); }
// 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({ await recordAudit({
actor: req.currentUser!, 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) } }); 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) => { secretsRouter.delete("/:id", requireRole("operator"), asyncHandler(async (req, res) => {
const id = Number(req.params.id); const id = Number(req.params.id);
const [existing] = await db.select().from(secrets).where(eq(secrets.id, id)).limit(1); const [existing] = await db.select().from(secrets).where(eq(secrets.id, id)).limit(1);
+12 -4
View File
@@ -219,14 +219,22 @@ export async function notifyDnsRecordDeleted(
export async function notifySecretExpiry( export async function notifySecretExpiry(
expiring: { name: string; status: "expired" | "expiring"; daysLeft: number }[], expiring: { name: string; status: "expired" | "expiring"; daysLeft: number }[],
checkFailures: { name: string; host: string; port: number; error?: string }[] = [],
): Promise<void> { ): Promise<void> {
if (expiring.length === 0) return; if (expiring.length === 0 && checkFailures.length === 0) return;
if (!(await eventEnabled("secretCheck"))) return; if (!(await eventEnabled("secretCheck"))) return;
const sections: string[] = [];
if (expiring.length > 0) {
const lines = expiring.map((s) => (s.status === "expired" ? `✕ EXPIRED — ${s.name}` : `⚠ ${s.daysLeft}d left — ${s.name}`)); const lines = expiring.map((s) => (s.status === "expired" ? `✕ EXPIRED — ${s.name}` : `⚠ ${s.daysLeft}d left — ${s.name}`));
await notify( sections.push(`${expiring.length} secret${expiring.length !== 1 ? "s" : ""} need attention:\n\n${lines.join("\n")}`);
"Homelab Manager — Secrets Alert", }
`${expiring.length} secret${expiring.length !== 1 ? "s" : ""} need attention:\n\n${lines.join("\n")}`, if (checkFailures.length > 0) {
const lines = checkFailures.map((f) => `⚠ ${f.name} (${f.host}:${f.port}) — ${f.error ?? "check failed"}`);
sections.push(
`Couldn't read the live certificate for ${checkFailures.length} monitored secret${checkFailures.length !== 1 ? "s" : ""} — the expiry shown may be stale:\n\n${lines.join("\n")}`,
); );
}
await notify("Homelab Manager — Secrets Alert", sections.join("\n\n"));
} }
export async function notifyDockerUpdates( export async function notifyDockerUpdates(
+17 -4
View File
@@ -3,16 +3,31 @@ import { db } from "../db/client.js";
import { secrets } from "../db/schema.js"; import { secrets } from "../db/schema.js";
import { computeSecretStatus } from "./secretStatus.js"; import { computeSecretStatus } from "./secretStatus.js";
import { notifySecretExpiry } from "./notify.js"; import { notifySecretExpiry } from "./notify.js";
import { refreshTlsSecrets, type TlsCheckResult } from "./tlsCheck.js";
import { getSettings, getInternalFlag, setInternalFlag } from "./settingsStore.js"; import { getSettings, getInternalFlag, setInternalFlag } from "./settingsStore.js";
const LAST_RUN_FLAG = "secretCheckLastRunDate"; 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> { 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 rows = await db.select().from(secrets);
const expiring = rows const expiring = rows
.map((s) => ({ name: s.name, ...computeSecretStatus(s.expiryDate, s.warnDays) })) .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"); .filter((s): s is typeof s & { status: "expired" | "expiring" } => s.status === "expired" || s.status === "expiring");
await notifySecretExpiry(expiring); await notifySecretExpiry(expiring, checkFailures);
} }
async function checkSecretExpiryOnce(): Promise<void> { async function checkSecretExpiryOnce(): Promise<void> {
@@ -39,9 +54,7 @@ export async function scheduleSecretExpiryCheck(): Promise<void> {
const { notifications } = await getSettings(); const { notifications } = await getSettings();
currentJob = schedule.scheduleJob({ rule: cronFromTime(notifications.secretCheckTime), tz: notifications.timezone }, () => { currentJob = schedule.scheduleJob({ rule: cronFromTime(notifications.secretCheckTime), tz: notifications.timezone }, () => {
setInternalFlag(LAST_RUN_FLAG, "").catch(() => {}); setInternalFlag(LAST_RUN_FLAG, "").catch(() => {});
getSettings().then(({ notifications: n }) => { checkSecretExpiry().catch((err) => console.error("[secretExpiry] check failed:", err));
if (n.secretCheck) checkSecretExpiry().catch((err) => console.error("[secretExpiry] check failed:", err));
});
}); });
console.log(`Secret expiry check scheduled at ${notifications.secretCheckTime} (${notifications.timezone})`); console.log(`Secret expiry check scheduled at ${notifications.secretCheckTime} (${notifications.timezone})`);
} }
+89
View File
@@ -0,0 +1,89 @@
import * as tls from "node:tls";
import { isIP } from "node:net";
import { eq, isNotNull } from "drizzle-orm";
import { db } from "../db/client.js";
import { secrets } from "../db/schema.js";
/**
* Opens a real TLS connection and returns the server certificate's expiry as
* a UTC "YYYY-MM-DD" date. Certificate verification is deliberately off
* (rejectUnauthorized: false): the point is to read the expiry of whatever
* certificate the service is actually serving, and homelab services very
* commonly present self-signed or internal-CA certificates that a verifying
* connection would refuse before ever exposing the dates — including an
* already-expired one, which is exactly the case worth reporting.
*/
export function fetchCertExpiry(host: string, port: number, timeoutMs = 10_000): Promise<string> {
return new Promise((resolve, reject) => {
const socket = tls.connect(
{
host,
port,
// SNI must be a hostname — Node warns (and some servers reject) when an IP is sent as the server name.
servername: isIP(host) === 0 ? host : undefined,
rejectUnauthorized: false,
},
() => {
const cert = socket.getPeerCertificate();
socket.end();
if (!cert || !cert.valid_to) return reject(new Error("The server presented no certificate"));
const validTo = new Date(cert.valid_to);
if (Number.isNaN(validTo.getTime())) return reject(new Error(`Couldn't parse the certificate expiry "${cert.valid_to}"`));
resolve(validTo.toISOString().slice(0, 10));
},
);
socket.setTimeout(timeoutMs, () => {
socket.destroy();
reject(new Error(`Timed out after ${timeoutMs / 1000}s connecting to ${host}:${port}`));
});
socket.on("error", (err) => {
// The most likely mistake is pointing at a plain-HTTP (or other non-TLS) port; OpenSSL's own message for that is opaque.
if (/wrong version number|packet length too long/i.test(err.message)) {
return reject(new Error(`${host}:${port} isn't speaking TLS — is that the right port?`));
}
reject(err);
});
});
}
export interface TlsCheckResult {
id: number;
name: string;
host: string;
port: number;
ok: boolean;
expiryDate?: string;
error?: string;
}
/** Checks one monitored secret and records the outcome on its row — a successful check overwrites expiryDate, a failed one keeps the last known date and records why. */
export async function refreshTlsSecret(id: number): Promise<TlsCheckResult | null> {
const [row] = await db.select().from(secrets).where(eq(secrets.id, id)).limit(1);
if (!row || !row.checkHost) return null;
const port = row.checkPort ?? 443;
const now = new Date().toISOString();
try {
const expiryDate = await fetchCertExpiry(row.checkHost, port);
await db
.update(secrets)
.set({
expiryDate,
lastCheckedAt: now,
lastCheckError: null,
...(expiryDate !== row.expiryDate ? { updatedAt: now } : {}),
})
.where(eq(secrets.id, id));
return { id, name: row.name, host: row.checkHost, port, ok: true, expiryDate };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
await db.update(secrets).set({ lastCheckedAt: now, lastCheckError: error }).where(eq(secrets.id, id));
return { id, name: row.name, host: row.checkHost, port, ok: false, error };
}
}
/** Checks every monitored secret (all at once — a homelab has a handful, and each is bounded by its own timeout). */
export async function refreshTlsSecrets(): Promise<TlsCheckResult[]> {
const rows = await db.select({ id: secrets.id }).from(secrets).where(isNotNull(secrets.checkHost));
const results = await Promise.all(rows.map((r) => refreshTlsSecret(r.id)));
return results.filter((r): r is TlsCheckResult => r !== null);
}
+14 -1
View File
@@ -72,15 +72,23 @@ export interface SecretRecord {
updatedAt: string; updatedAt: string;
status: SecretStatus; status: SecretStatus;
daysLeft: number; daysLeft: number;
/** ssl_certificate only — when set, expiryDate is read from the live certificate here rather than typed in. */
checkHost: string | null;
checkPort: number | null;
lastCheckedAt: string | null;
lastCheckError: string | null;
} }
export interface SecretInput { export interface SecretInput {
name: string; name: string;
type: SecretType; type: SecretType;
description?: string; description?: string;
expiryDate: string; /** Omitted when a host to check is given — the date then comes from the live certificate. */
expiryDate?: string;
warnDays: number; warnDays: number;
notes?: string; notes?: string;
checkHost?: string | null;
checkPort?: number | null;
} }
export interface IpamEntry { export interface IpamEntry {
@@ -663,6 +671,11 @@ export const api = {
body: JSON.stringify(data), body: JSON.stringify(data),
}), }),
remove: (id: number) => request<void>(`/api/secrets/${id}`, { method: "DELETE" }), remove: (id: number) => request<void>(`/api/secrets/${id}`, { method: "DELETE" }),
checkTls: (id: number) =>
request<{ secret: SecretRecord; result: { ok: boolean; expiryDate?: string; error?: string } }>(
`/api/secrets/${id}/check-tls`,
{ method: "POST" },
),
}, },
ipam: { ipam: {
list: () => request<{ entries: IpamEntry[] }>("/api/ipam"), list: () => request<{ entries: IpamEntry[] }>("/api/ipam"),
+101 -7
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { api, type CurrentUser, type SecretInput, type SecretRecord, type SecretStatus } from "../api/client"; import { api, type CurrentUser, type SecretInput, type SecretRecord, type SecretStatus } from "../api/client";
import { downloadCsv } from "../utils/csv"; import { downloadCsv } from "../utils/csv";
import { formatDateTime } from "../utils/date";
import { useSortable } from "../hooks/useSortable"; import { useSortable } from "../hooks/useSortable";
import SortableTh from "../components/SortableTh"; import SortableTh from "../components/SortableTh";
import { usePagination } from "../hooks/usePagination"; import { usePagination } from "../hooks/usePagination";
@@ -28,8 +29,25 @@ const emptyForm: SecretInput = {
expiryDate: "", expiryDate: "",
warnDays: 30, warnDays: 30,
notes: "", notes: "",
checkHost: "",
checkPort: 443,
}; };
/** The API client throws `Request failed (400): {json}` — pull the server's own message out of that when there is one. */
function readableError(err: unknown): string {
const text = err instanceof Error ? err.message : String(err);
const match = text.match(/^Request failed \(\d+\): (.*)$/s);
if (match) {
try {
const body = JSON.parse(match[1]);
if (typeof body.message === "string") return body.message;
} catch {
// not JSON — fall through to the raw text
}
}
return text;
}
export default function Secrets({ user }: { user: CurrentUser }) { export default function Secrets({ user }: { user: CurrentUser }) {
const canEdit = user.role === "admin" || user.role === "operator"; const canEdit = user.role === "admin" || user.role === "operator";
const [secrets, setSecrets] = useState<SecretRecord[] | null>(null); const [secrets, setSecrets] = useState<SecretRecord[] | null>(null);
@@ -42,6 +60,7 @@ export default function Secrets({ user }: { user: CurrentUser }) {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const selection = useSelection<number>(); const selection = useSelection<number>();
const [bulkDeleting, setBulkDeleting] = useState(false); const [bulkDeleting, setBulkDeleting] = useState(false);
const [checkingId, setCheckingId] = useState<number | null>(null);
function load() { function load() {
api.secrets api.secrets
@@ -77,6 +96,8 @@ export default function Secrets({ user }: { user: CurrentUser }) {
expiryDate: s.expiryDate, expiryDate: s.expiryDate,
warnDays: s.warnDays, warnDays: s.warnDays,
notes: s.notes ?? "", notes: s.notes ?? "",
checkHost: s.checkHost ?? "",
checkPort: s.checkPort ?? 443,
}); });
} }
@@ -90,20 +111,42 @@ export default function Secrets({ user }: { user: CurrentUser }) {
setError(null); setError(null);
setSaving(true); setSaving(true);
try { try {
// A host only means something for a certificate; when one is set the date comes from the live cert, so it isn't sent.
const host = form.type === "ssl_certificate" ? (form.checkHost ?? "").trim() : "";
const payload: SecretInput = {
...form,
expiryDate: host ? undefined : form.expiryDate,
checkHost: host || null,
checkPort: host ? form.checkPort || 443 : null,
};
if (editingId) { if (editingId) {
await api.secrets.update(editingId, form); await api.secrets.update(editingId, payload);
} else { } else {
await api.secrets.create(form); await api.secrets.create(payload);
} }
cancelEdit(); cancelEdit();
load(); load();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(readableError(err));
} finally { } finally {
setSaving(false); setSaving(false);
} }
} }
async function checkNow(s: SecretRecord) {
setError(null);
setCheckingId(s.id);
try {
const res = await api.secrets.checkTls(s.id);
if (!res.result.ok) setError(`Couldn't read the certificate for "${s.name}": ${res.result.error}`);
load();
} catch (err) {
setError(readableError(err));
} finally {
setCheckingId(null);
}
}
async function remove(s: SecretRecord) { async function remove(s: SecretRecord) {
if (!confirm(`Delete secret "${s.name}"?`)) return; if (!confirm(`Delete secret "${s.name}"?`)) return;
setError(null); setError(null);
@@ -138,7 +181,7 @@ export default function Secrets({ user }: { user: CurrentUser }) {
function exportCsv() { function exportCsv() {
downloadCsv( downloadCsv(
"secrets.csv", "secrets.csv",
["Name", "Type", "Description", "Expiry Date", "Warn Days", "Status", "Days Left", "Notes"], ["Name", "Type", "Description", "Expiry Date", "Warn Days", "Status", "Days Left", "Notes", "Monitored Host"],
(sorted ?? []).map((s) => [ (sorted ?? []).map((s) => [
s.name, s.name,
TYPE_LABELS[s.type], TYPE_LABELS[s.type],
@@ -148,10 +191,13 @@ export default function Secrets({ user }: { user: CurrentUser }) {
s.status, s.status,
s.daysLeft, s.daysLeft,
s.notes ?? "", s.notes ?? "",
s.checkHost ? `${s.checkHost}:${s.checkPort ?? 443}` : "",
]), ]),
); );
} }
const monitoredInForm = form.type === "ssl_certificate" && !!(form.checkHost ?? "").trim();
return ( return (
<> <>
<h2 className="page-title mb-3">Secrets</h2> <h2 className="page-title mb-3">Secrets</h2>
@@ -192,11 +238,39 @@ export default function Secrets({ user }: { user: CurrentUser }) {
<input <input
type="date" type="date"
className="form-control" className="form-control"
required required={!monitoredInForm}
value={form.expiryDate} disabled={monitoredInForm}
value={form.expiryDate ?? ""}
onChange={(e) => setForm({ ...form, expiryDate: e.target.value })} onChange={(e) => setForm({ ...form, expiryDate: e.target.value })}
/> />
{monitoredInForm && <div className="form-hint">Read from the live certificate.</div>}
</div> </div>
{form.type === "ssl_certificate" && (
<>
<div className="col-md-4">
<label className="form-label">Auto-check host (optional)</label>
<input
className="form-control"
placeholder="nas.example.lan"
value={form.checkHost ?? ""}
onChange={(e) => setForm({ ...form, checkHost: e.target.value })}
/>
<div className="form-hint">Connects over TLS and reads the certificate's real expiry, checked daily.</div>
</div>
<div className="col-md-2">
<label className="form-label">Port</label>
<input
type="number"
min={1}
max={65535}
className="form-control"
disabled={!monitoredInForm}
value={form.checkPort ?? 443}
onChange={(e) => setForm({ ...form, checkPort: Number(e.target.value) })}
/>
</div>
</>
)}
<div className="col-md-3"> <div className="col-md-3">
<label className="form-label">Warn (days before)</label> <label className="form-label">Warn (days before)</label>
<input <input
@@ -331,7 +405,22 @@ export default function Secrets({ user }: { user: CurrentUser }) {
)} )}
<td>{s.name}</td> <td>{s.name}</td>
<td>{TYPE_LABELS[s.type]}</td> <td>{TYPE_LABELS[s.type]}</td>
<td>{s.expiryDate}</td> <td>
{s.expiryDate}
{s.checkHost && (
<span
className="badge bg-cyan-lt ms-2"
title={`Read from ${s.checkHost}:${s.checkPort ?? 443}${s.lastCheckedAt ? ` — checked ${formatDateTime(new Date(s.lastCheckedAt))}` : ""}`}
>
Auto
</span>
)}
{s.lastCheckError && (
<span className="badge bg-red-lt text-red ms-1" title={`${s.lastCheckError} — showing the last known date`}>
Check failed
</span>
)}
</td>
<td>{s.daysLeft < 0 ? `${Math.abs(s.daysLeft)}d ago` : `${s.daysLeft}d`}</td> <td>{s.daysLeft < 0 ? `${Math.abs(s.daysLeft)}d ago` : `${s.daysLeft}d`}</td>
<td> <td>
<span className={`badge ${STATUS_BADGE[s.status]}`}>{s.status}</span> <span className={`badge ${STATUS_BADGE[s.status]}`}>{s.status}</span>
@@ -340,6 +429,11 @@ export default function Secrets({ user }: { user: CurrentUser }) {
{canEdit && ( {canEdit && (
<td> <td>
<div className="btn-list flex-nowrap"> <div className="btn-list flex-nowrap">
{s.checkHost && (
<button className="btn btn-sm" onClick={() => checkNow(s)} disabled={checkingId === s.id}>
{checkingId === s.id ? "Checking…" : "Check now"}
</button>
)}
<button className="btn btn-sm" onClick={() => startEdit(s)}> <button className="btn btn-sm" onClick={() => startEdit(s)}>
Edit Edit
</button> </button>