🚧 Add CA and server cert management
This commit is contained in:
+14
-5
@@ -5,6 +5,7 @@ import { ClientsPage } from "./pages/Clients";
|
||||
import { RolesPage } from "./pages/Roles";
|
||||
import { GroupsPage } from "./pages/Groups";
|
||||
import { BrokerConfigPage } from "./pages/BrokerConfig";
|
||||
import { CertificatesPage } from "./pages/Certificates";
|
||||
|
||||
// Navigation structure per spec section 57. Entries with a `to` route are
|
||||
// implemented; entries without one are planned pages, rendered greyed out
|
||||
@@ -36,11 +37,11 @@ const NAV_SECTIONS: {
|
||||
{
|
||||
title: "Certificates",
|
||||
items: [
|
||||
{ label: "Authorities", hint: "PKI (Release 2)" },
|
||||
{ label: "Server Certificates", hint: "PKI (Release 2)" },
|
||||
{ label: "Client Certificates", hint: "PKI (Release 2)" },
|
||||
{ label: "CSRs", hint: "CSR signing (Release 2)" },
|
||||
{ label: "Revocations", hint: "CRL management (Release 2)" },
|
||||
{ label: "Authorities", to: "/certificates" },
|
||||
{ label: "Server Certificates", to: "/certificates" },
|
||||
{ label: "Client Certificates", hint: "Client certs + one-time download (Release 2, later)" },
|
||||
{ label: "CSRs", hint: "CSR signing (Release 2, later)" },
|
||||
{ label: "Revocations", hint: "CRL management (Release 2, later)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -149,6 +150,14 @@ export function App() {
|
||||
</Protected>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/certificates"
|
||||
element={
|
||||
<Protected>
|
||||
<CertificatesPage />
|
||||
</Protected>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to="/clients" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -162,6 +162,41 @@ export interface PasswordReset {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface Certificate {
|
||||
id: string;
|
||||
common_name: string;
|
||||
is_ca: boolean;
|
||||
parent_id: string | null;
|
||||
key_algorithm: string;
|
||||
serial_number: string;
|
||||
dns_sans: string[];
|
||||
ip_sans: string[];
|
||||
fingerprint: string;
|
||||
status: string;
|
||||
expiry_date: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CaCreate {
|
||||
common_name: string;
|
||||
validity_days: number;
|
||||
key_algorithm: "rsa3072" | "rsa4096" | "ecdsa-p256" | "ecdsa-p384";
|
||||
organization?: string | null;
|
||||
organizational_unit?: string | null;
|
||||
country?: string | null;
|
||||
}
|
||||
|
||||
export interface ServerCertCreate {
|
||||
ca_id: string;
|
||||
common_name: string;
|
||||
validity_days: number;
|
||||
dns_sans: string[];
|
||||
ip_sans: string[];
|
||||
organization?: string | null;
|
||||
organizational_unit?: string | null;
|
||||
country?: string | null;
|
||||
}
|
||||
|
||||
export interface Listener {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, type Certificate } from "../api";
|
||||
|
||||
const KEY_ALGORITHMS = ["rsa3072", "rsa4096", "ecdsa-p256", "ecdsa-p384"] as const;
|
||||
|
||||
// --- CA creation form ---------------------------------------------------------
|
||||
|
||||
function CaForm({ pending }: { pending: boolean }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [cn, setCn] = useState("");
|
||||
const [days, setDays] = useState(3650);
|
||||
const [algorithm, setAlgorithm] = useState<(typeof KEY_ALGORITHMS)[number]>("ecdsa-p256");
|
||||
const [org, setOrg] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
api.post<Certificate>("/pki/ca", {
|
||||
common_name: cn,
|
||||
validity_days: days,
|
||||
key_algorithm: algorithm,
|
||||
organization: org || null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setCn("");
|
||||
setError(null);
|
||||
queryClient.invalidateQueries({ queryKey: ["cas"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["certificates"] });
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (cn) create.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="stack" onSubmit={submit}>
|
||||
{error && <div className="banner error">{error}</div>}
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "flex-end" }}>
|
||||
<div>
|
||||
<label>Common name</label>
|
||||
<input
|
||||
value={cn}
|
||||
onChange={(e) => setCn(e.target.value)}
|
||||
placeholder="MQTT Platform Root CA"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Validity (days)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={days}
|
||||
min={1}
|
||||
max={36500}
|
||||
onChange={(e) => setDays(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Key algorithm</label>
|
||||
<select value={algorithm} onChange={(e) => setAlgorithm(e.target.value as (typeof KEY_ALGORITHMS)[number])}>
|
||||
{KEY_ALGORITHMS.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{a}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Organization</label>
|
||||
<input value={org} onChange={(e) => setOrg(e.target.value)} placeholder="optional" />
|
||||
</div>
|
||||
<button className="primary" disabled={!cn || pending} type="submit">
|
||||
{pending ? "Generating…" : "Create internal CA"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Server certificate issuance form ------------------------------------------
|
||||
|
||||
function ServerCertForm({
|
||||
cas,
|
||||
pending,
|
||||
}: {
|
||||
cas: Certificate[];
|
||||
pending: boolean;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [caId, setCaId] = useState("");
|
||||
const [cn, setCn] = useState("");
|
||||
const [days, setDays] = useState(365);
|
||||
const [dns, setDns] = useState("");
|
||||
const [ips, setIps] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const issue = useMutation({
|
||||
mutationFn: () =>
|
||||
api.post<Certificate>("/pki/certificates", {
|
||||
ca_id: caId,
|
||||
common_name: cn,
|
||||
validity_days: days,
|
||||
dns_sans: dns.split(",").map((s) => s.trim()).filter(Boolean),
|
||||
ip_sans: ips.split(",").map((s) => s.trim()).filter(Boolean),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setCn("");
|
||||
setDns("");
|
||||
setIps("");
|
||||
setError(null);
|
||||
queryClient.invalidateQueries({ queryKey: ["certificates"] });
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (cn && caId) issue.mutate();
|
||||
};
|
||||
|
||||
if (cas.length === 0) {
|
||||
return (
|
||||
<p style={{ color: "#52606d" }}>
|
||||
No CA exists yet — create a certificate authority first; server
|
||||
certificates are signed by one.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="stack" onSubmit={submit}>
|
||||
{error && <div className="banner error">{error}</div>}
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "flex-end" }}>
|
||||
<div>
|
||||
<label>Signing CA</label>
|
||||
<select value={caId} onChange={(e) => setCaId(e.target.value)}>
|
||||
<option value="">select CA…</option>
|
||||
{cas.map((ca) => (
|
||||
<option key={ca.id} value={ca.id}>
|
||||
{ca.common_name} ({ca.key_algorithm})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Common name</label>
|
||||
<input
|
||||
value={cn}
|
||||
onChange={(e) => setCn(e.target.value)}
|
||||
placeholder="mqtt.example.local"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Validity (days)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={days}
|
||||
min={1}
|
||||
max={36500}
|
||||
onChange={(e) => setDays(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>DNS SANs (comma-separated)</label>
|
||||
<input value={dns} onChange={(e) => setDns(e.target.value)} placeholder="mqtt, mqtt.local" />
|
||||
</div>
|
||||
<div>
|
||||
<label>IP SANs (comma-separated)</label>
|
||||
<input value={ips} onChange={(e) => setIps(e.target.value)} placeholder="10.20.30.40" />
|
||||
</div>
|
||||
<button className="primary" disabled={!cn || !caId || pending} type="submit">
|
||||
{pending ? "Issuing…" : "Issue server certificate"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Inventory table -----------------------------------------------------------
|
||||
|
||||
function expiryClass(expiry: string): string | null {
|
||||
const days = (new Date(expiry).getTime() - Date.now()) / 86400000;
|
||||
if (days < 0) return "banner error";
|
||||
if (days < 30) return "banner warn";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function CertificatesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const cas = useQuery({
|
||||
queryKey: ["cas"],
|
||||
queryFn: () => api.get<Certificate[]>("/pki/ca"),
|
||||
});
|
||||
const certificates = useQuery({
|
||||
queryKey: ["certificates"],
|
||||
queryFn: () => api.get<Certificate[]>("/pki/certificates"),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/pki/certificates/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["cas"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["certificates"] });
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const caList = cas.data ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Certificate authorities</h2>
|
||||
<p>
|
||||
Internal CAs sign all platform-issued certificates. Private keys are
|
||||
stored encrypted (AES-256-GCM) and never leave the backend; only the
|
||||
public certificate is downloadable.
|
||||
</p>
|
||||
{(cas.error || certificates.error) && (
|
||||
<div className="banner error">
|
||||
{((cas.error ?? certificates.error) as Error).message}
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="banner error">{error}</div>}
|
||||
|
||||
<CaForm pending={cas.isFetching && false} />
|
||||
|
||||
{caList.length === 0 ? (
|
||||
<p style={{ color: "#52606d" }}>No CA yet — create one above.</p>
|
||||
) : (
|
||||
<table style={{ marginTop: "1.5rem" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Common name</th>
|
||||
<th>Algorithm</th>
|
||||
<th>Serial</th>
|
||||
<th>Expires</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{caList.map((ca) => (
|
||||
<tr key={ca.id}>
|
||||
<td>{ca.common_name}</td>
|
||||
<td className="mono">{ca.key_algorithm}</td>
|
||||
<td className="mono">{ca.serial_number.slice(0, 16)}…</td>
|
||||
<td>{new Date(ca.expiry_date).toLocaleDateString()}</td>
|
||||
<td>
|
||||
<a href={`/api/v1/pki/certificates/${ca.id}/download`}>Download PEM</a>{" "}
|
||||
<button
|
||||
className="danger"
|
||||
onClick={() => {
|
||||
if (confirm(`Delete CA ${ca.common_name} and its stored key?`))
|
||||
remove.mutate(ca.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<h3 style={{ marginTop: "2rem" }}>Server certificates</h3>
|
||||
<ServerCertForm cas={caList} pending={certificates.isFetching && false} />
|
||||
|
||||
<table style={{ marginTop: "1.5rem" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Common name</th>
|
||||
<th>Signed by</th>
|
||||
<th>SANs</th>
|
||||
<th>Status</th>
|
||||
<th>Expires</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(certificates.data ?? [])
|
||||
.filter((c) => !c.is_ca)
|
||||
.map((c) => {
|
||||
const signer = caList.find((ca) => ca.id === c.parent_id);
|
||||
return (
|
||||
<tr key={c.id}>
|
||||
<td>{c.common_name}</td>
|
||||
<td>{signer ? signer.common_name : "—"}</td>
|
||||
<td className="mono">
|
||||
{[...c.dns_sans, ...c.ip_sans].join(", ") || "—"}
|
||||
</td>
|
||||
<td>{c.status}</td>
|
||||
<td className={expiryClass(c.expiry_date) ?? undefined}>
|
||||
{new Date(c.expiry_date).toLocaleDateString()}
|
||||
</td>
|
||||
<td>
|
||||
<a href={`/api/v1/pki/certificates/${c.id}/download`}>
|
||||
Download PEM
|
||||
</a>{" "}
|
||||
<button
|
||||
className="danger"
|
||||
onClick={() => {
|
||||
if (confirm(`Delete certificate ${c.common_name}?`))
|
||||
remove.mutate(c.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{(certificates.data ?? []).filter((c) => !c.is_ca).length === 0 && (
|
||||
<p style={{ color: "#52606d" }}>
|
||||
No server certificates yet. Issue one above, then bind it to a TLS
|
||||
listener in Broker configuration.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user