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
+1557 -26

No files matched your search

+14 -1
View File
@@ -72,15 +72,23 @@ export interface SecretRecord {
updatedAt: string;
status: SecretStatus;
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 {
name: string;
type: SecretType;
description?: string;
expiryDate: string;
/** Omitted when a host to check is given — the date then comes from the live certificate. */
expiryDate?: string;
warnDays: number;
notes?: string;
checkHost?: string | null;
checkPort?: number | null;
}
export interface IpamEntry {
@@ -663,6 +671,11 @@ export const api = {
body: JSON.stringify(data),
}),
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: {
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 { api, type CurrentUser, type SecretInput, type SecretRecord, type SecretStatus } from "../api/client";
import { downloadCsv } from "../utils/csv";
import { formatDateTime } from "../utils/date";
import { useSortable } from "../hooks/useSortable";
import SortableTh from "../components/SortableTh";
import { usePagination } from "../hooks/usePagination";
@@ -28,8 +29,25 @@ const emptyForm: SecretInput = {
expiryDate: "",
warnDays: 30,
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 }) {
const canEdit = user.role === "admin" || user.role === "operator";
const [secrets, setSecrets] = useState<SecretRecord[] | null>(null);
@@ -42,6 +60,7 @@ export default function Secrets({ user }: { user: CurrentUser }) {
const [saving, setSaving] = useState(false);
const selection = useSelection<number>();
const [bulkDeleting, setBulkDeleting] = useState(false);
const [checkingId, setCheckingId] = useState<number | null>(null);
function load() {
api.secrets
@@ -77,6 +96,8 @@ export default function Secrets({ user }: { user: CurrentUser }) {
expiryDate: s.expiryDate,
warnDays: s.warnDays,
notes: s.notes ?? "",
checkHost: s.checkHost ?? "",
checkPort: s.checkPort ?? 443,
});
}
@@ -90,20 +111,42 @@ export default function Secrets({ user }: { user: CurrentUser }) {
setError(null);
setSaving(true);
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) {
await api.secrets.update(editingId, form);
await api.secrets.update(editingId, payload);
} else {
await api.secrets.create(form);
await api.secrets.create(payload);
}
cancelEdit();
load();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setError(readableError(err));
} finally {
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) {
if (!confirm(`Delete secret "${s.name}"?`)) return;
setError(null);
@@ -138,7 +181,7 @@ export default function Secrets({ user }: { user: CurrentUser }) {
function exportCsv() {
downloadCsv(
"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) => [
s.name,
TYPE_LABELS[s.type],
@@ -148,10 +191,13 @@ export default function Secrets({ user }: { user: CurrentUser }) {
s.status,
s.daysLeft,
s.notes ?? "",
s.checkHost ? `${s.checkHost}:${s.checkPort ?? 443}` : "",
]),
);
}
const monitoredInForm = form.type === "ssl_certificate" && !!(form.checkHost ?? "").trim();
return (
<>
<h2 className="page-title mb-3">Secrets</h2>
@@ -192,11 +238,39 @@ export default function Secrets({ user }: { user: CurrentUser }) {
<input
type="date"
className="form-control"
required
value={form.expiryDate}
required={!monitoredInForm}
disabled={monitoredInForm}
value={form.expiryDate ?? ""}
onChange={(e) => setForm({ ...form, expiryDate: e.target.value })}
/>
{monitoredInForm && <div className="form-hint">Read from the live certificate.</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">
<label className="form-label">Warn (days before)</label>
<input
@@ -331,7 +405,22 @@ export default function Secrets({ user }: { user: CurrentUser }) {
)}
<td>{s.name}</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>
<span className={`badge ${STATUS_BADGE[s.status]}`}>{s.status}</span>
@@ -340,6 +429,11 @@ export default function Secrets({ user }: { user: CurrentUser }) {
{canEdit && (
<td>
<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)}>
Edit
</button>