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
+14
-1
@@ -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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user