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
+1555
-24
No files matched your search
@@ -34,7 +34,12 @@ All modules from the original plan are built:
|
||||
troubleshooting connectivity issues (ported from Sloth Manager's
|
||||
provider-diagnostics log, generalized to cover every integration this app
|
||||
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,
|
||||
with "Sync from Tailscale" and "Sync from Proxmox" actions to pull in
|
||||
tailnet device IPs and VM/LXC IPs (never overwrites a
|
||||
|
||||
@@ -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
@@ -50,6 +50,13 @@
|
||||
"when": 1790103102037,
|
||||
"tag": "0006_aberrant_darkhawk",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1790372043652,
|
||||
"tag": "0007_secret_madripoor",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -82,6 +82,11 @@ export const secrets = sqliteTable("secrets", {
|
||||
expiryDate: text("expiry_date").notNull(), // ISO date, e.g. 2026-03-01
|
||||
warnDays: integer("warn_days").notNull().default(30),
|
||||
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")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -219,15 +219,23 @@ export async function notifyDnsRecordDeleted(
|
||||
|
||||
export async function notifySecretExpiry(
|
||||
expiring: { name: string; status: "expired" | "expiring"; daysLeft: number }[],
|
||||
checkFailures: { name: string; host: string; port: number; error?: string }[] = [],
|
||||
): Promise<void> {
|
||||
if (expiring.length === 0) return;
|
||||
if (expiring.length === 0 && checkFailures.length === 0) 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}`));
|
||||
await notify(
|
||||
"Homelab Manager — Secrets Alert",
|
||||
`${expiring.length} secret${expiring.length !== 1 ? "s" : ""} need attention:\n\n${lines.join("\n")}`,
|
||||
sections.push(`${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(
|
||||
updatesAvailable: { integrationName: string; containerName: string; environmentName: string; newerVersion: string | null }[],
|
||||
|
||||
@@ -3,16 +3,31 @@ 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);
|
||||
await notifySecretExpiry(expiring, checkFailures);
|
||||
}
|
||||
|
||||
async function checkSecretExpiryOnce(): Promise<void> {
|
||||
@@ -39,9 +54,7 @@ export async function scheduleSecretExpiryCheck(): Promise<void> {
|
||||
const { notifications } = await getSettings();
|
||||
currentJob = schedule.scheduleJob({ rule: cronFromTime(notifications.secretCheckTime), tz: notifications.timezone }, () => {
|
||||
setInternalFlag(LAST_RUN_FLAG, "").catch(() => {});
|
||||
getSettings().then(({ notifications: n }) => {
|
||||
if (n.secretCheck) checkSecretExpiry().catch((err) => console.error("[secretExpiry] check failed:", err));
|
||||
});
|
||||
checkSecretExpiry().catch((err) => console.error("[secretExpiry] check failed:", err));
|
||||
});
|
||||
console.log(`Secret expiry check scheduled at ${notifications.secretCheckTime} (${notifications.timezone})`);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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