🚧 Update certificate management

This commit is contained in:
Jonas Bengtsson
2026-09-19 17:12:58 +02:00
parent 111d6cc390
commit a16a01b1e7
21 changed files with 1128 additions and 37 deletions
+88 -1
View File
@@ -1,6 +1,6 @@
import { FormEvent, useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type Listener, type Revision } from "../api";
import { api, type Certificate, type Listener, type Revision } from "../api";
interface ConfigPreview {
config_text: string;
@@ -25,6 +25,7 @@ const EMPTY_LISTENER: Omit<Listener, "id"> = {
tls_min_version: null,
require_certificate: false,
use_identity_as_username: false,
cert_id: null,
};
// Curated subset of spec section 31; anything else can be added later
@@ -276,6 +277,15 @@ function ListenerForm({
const set = (patch: Partial<Listener>) => setDraft((d) => ({ ...d, ...patch }));
const valid = draft.name.trim() !== "" && draft.port > 0 && draft.port <= 65535;
// Server certificates for the certfile/keyfile/cafile binding.
const serverCerts = useQuery({
queryKey: ["certificates"],
queryFn: () => api.get<Certificate[]>("/pki/certificates"),
});
const certOptions = (serverCerts.data ?? []).filter(
(c) => !c.is_ca && c.cert_type === "server",
);
const submit = (e: FormEvent) => {
e.preventDefault();
if (valid) {
@@ -346,6 +356,83 @@ function ListenerForm({
/>
</div>
</div>
<div
style={{
display: "flex",
gap: "0.5rem",
flexWrap: "wrap",
alignItems: "flex-end",
marginTop: "0.5rem",
borderTop: "1px solid #e4e7eb",
paddingTop: "0.5rem",
}}
>
<div>
<label>TLS</label>
<select
value={draft.tls_enabled ? "true" : "false"}
onChange={(e) => set({ tls_enabled: e.target.value === "true" })}
>
<option value="false">disabled</option>
<option value="true">enabled</option>
</select>
</div>
{draft.tls_enabled && (
<>
<div>
<label>Server certificate</label>
<select
value={draft.cert_id ?? ""}
onChange={(e) => set({ cert_id: e.target.value || null })}
>
<option value="">
{certOptions.length === 0 ? "none issued yet…" : "select certificate…"}
</option>
{certOptions.map((c) => (
<option key={c.id} value={c.id}>
{c.common_name} ({new Date(c.expiry_date).toLocaleDateString()})
</option>
))}
</select>
</div>
<div>
<label>Min TLS version</label>
<select
value={draft.tls_min_version ?? ""}
onChange={(e) => set({ tls_min_version: e.target.value || null })}
>
<option value="">broker default</option>
<option value="tlsv1.2">tlsv1.2</option>
<option value="tlsv1.3">tlsv1.3</option>
</select>
</div>
<div>
<label>Client certificates</label>
<select
value={draft.require_certificate ? "true" : "false"}
onChange={(e) => set({ require_certificate: e.target.value === "true" })}
>
<option value="false">not required</option>
<option value="true">required (mTLS)</option>
</select>
</div>
{draft.require_certificate && (
<div>
<label>Cert CN as username</label>
<select
value={draft.use_identity_as_username ? "true" : "false"}
onChange={(e) =>
set({ use_identity_as_username: e.target.value === "true" })
}
>
<option value="false">no</option>
<option value="true">yes — CN must match a client username</option>
</select>
</div>
)}
</>
)}
</div>
<p />
<button className="primary" disabled={!valid || pending}>
{pending ? (initial ? "Saving…" : "Adding…") : initial ? "Save changes" : "Add listener"}
+71 -2
View File
@@ -268,6 +268,10 @@ export function CertificatesPage() {
)}
<h3 style={{ marginTop: "2rem" }}>Server certificates</h3>
<p style={{ color: "#52606d" }}>
Bound to TLS listeners (certfile/keyfile). Issue one, then select it
in the listener form under Broker configuration.
</p>
<ServerCertForm cas={caList} pending={certificates.isFetching && false} />
<table style={{ marginTop: "1.5rem" }}>
@@ -283,7 +287,7 @@ export function CertificatesPage() {
</thead>
<tbody>
{(certificates.data ?? [])
.filter((c) => !c.is_ca)
.filter((c) => c.cert_type === "server")
.map((c) => {
const signer = caList.find((ca) => ca.id === c.parent_id);
return (
@@ -316,12 +320,77 @@ export function CertificatesPage() {
})}
</tbody>
</table>
{(certificates.data ?? []).filter((c) => !c.is_ca).length === 0 && (
{(certificates.data ?? []).filter((c) => c.cert_type === "server").length === 0 && (
<p style={{ color: "#52606d" }}>
No server certificates yet. Issue one above, then bind it to a TLS
listener in Broker configuration.
</p>
)}
<h3 id="client-certificates" style={{ marginTop: "2rem" }}>
Client certificates
</h3>
<p style={{ color: "#52606d" }}>
Authenticate MQTT devices (mTLS): the certificate CN is the MQTT
username, so with use_identity_as_username the broker maps the
certificate to that client. Issue them from the Clients page — the
private key is shown exactly once there.
</p>
<table style={{ marginTop: "1.5rem" }}>
<thead>
<tr>
<th>MQTT client</th>
<th>Signed by</th>
<th>Status</th>
<th>Expires</th>
<th></th>
</tr>
</thead>
<tbody>
{(certificates.data ?? [])
.filter((c) => c.cert_type === "client")
.map((c) => {
const signer = caList.find((ca) => ca.id === c.parent_id);
return (
<tr key={c.id}>
<td className="mono">{c.username ?? c.common_name}</td>
<td>{signer ? signer.common_name : "—"}</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={() => {
const target = c.username ?? c.common_name;
if (
confirm(
`Delete the certificate for ${target}? The client ` +
"will no longer be able to authenticate with it.",
)
)
remove.mutate(c.id);
}}
>
Delete
</button>
</td>
</tr>
);
})}
</tbody>
</table>
{(certificates.data ?? []).filter((c) => c.cert_type === "client").length === 0 && (
<p style={{ color: "#52606d" }}>
No client certificates yet. Open Clients and use “Issue certificate”
on a client to provision a device for mTLS.
</p>
)}
</div>
);
}
+131 -4
View File
@@ -4,8 +4,11 @@ import {
api,
assignClientRole,
assignGroupMember,
pemDataUrl,
removeClientRole,
removeGroupMember,
type Certificate,
type CertificateBundle,
type MqttClient,
type PasswordReset,
type Role,
@@ -54,6 +57,7 @@ function AssignmentChips({
export function ClientsPage() {
const queryClient = useQueryClient();
const [newUsername, setNewUsername] = useState("");
const [newClientId, setNewClientId] = useState("");
const [oncePassword, setOncePassword] = useState<PasswordReset | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -77,16 +81,37 @@ export function ClientsPage() {
};
const create = useMutation({
mutationFn: (username: string) =>
api.post<PasswordReset>("/mqtt/clients", { username }),
mutationFn: ({ username, clientid }: { username: string; clientid: string | null }) =>
api.post<PasswordReset>("/mqtt/clients", {
username,
clientid: clientid || null,
}),
onSuccess: (result) => {
setOncePassword(result);
setNewUsername("");
setNewClientId("");
refresh();
},
onError: (e: Error) => setError(e.message),
});
const bindClientId = useMutation({
mutationFn: ({ username, clientid }: { username: string; clientid: string }) =>
api.put(`/mqtt/clients/${username}/clientid`, { clientid }),
onSuccess: refresh,
onError: (e: Error) => setError(e.message),
});
const setClientIdFor = (c: MqttClient) => {
const current = c.clientid ?? "";
const answer = prompt(
`Expected client ID for ${c.username} (empty = any client id may connect):`,
current,
);
if (answer === null) return; // cancelled
bindClientId.mutate({ username: c.username, clientid: answer.trim() });
};
const disable = useMutation({
mutationFn: (username: string) => api.post(`/mqtt/clients/${username}/disable`),
onSuccess: refresh,
@@ -110,6 +135,50 @@ export function ClientsPage() {
onError: (e: Error) => setError(e.message),
});
const [certBundle, setCertBundle] = useState<CertificateBundle | null>(null);
const cas = useQuery({
queryKey: ["cas"],
queryFn: () => api.get<Certificate[]>("/pki/ca"),
});
const issueCert = useMutation({
mutationFn: ({
username,
caId,
days,
}: {
username: string;
caId: string;
days: number;
}) =>
api.post<CertificateBundle>("/pki/certificates/client", {
username,
ca_id: caId,
validity_days: days,
}),
onSuccess: (result) => setCertBundle(result),
onError: (e: Error) => setError(e.message),
});
const issueClientCertificate = (username: string) => {
const caList = cas.data ?? [];
if (caList.length === 0) {
setError(
"No certificate authority exists yet — create one under Certificates first.",
);
return;
}
const daysStr = prompt("Validity in days (e.g. 365):", "365");
if (!daysStr) return;
const days = Number(daysStr);
if (!Number.isFinite(days) || days <= 0) {
setError("Validity must be a positive number of days.");
return;
}
issueCert.mutate({ username, caId: caList[0].id, days });
};
const assignRole = useMutation({
mutationFn: ({ username, rolename }: { username: string; rolename: string }) =>
assignClientRole(username, rolename),
@@ -141,7 +210,8 @@ export function ClientsPage() {
const submit = (e: FormEvent) => {
e.preventDefault();
setError(null);
if (newUsername) create.mutate(newUsername);
if (newUsername)
create.mutate({ username: newUsername, clientid: newClientId.trim() });
};
const roleNames = (roles.data ?? []).map((r) => r.rolename);
@@ -163,6 +233,40 @@ export function ClientsPage() {
<div className="password-once mono">{oncePassword.password}</div>
</div>
)}
{certBundle && (
<div className="banner warn">
<strong>
Certificate bundle for {certBundle.username} — save these files now,
the private key is shown only once:
</strong>
<p style={{ margin: "0.4rem 0 0.2rem", fontSize: "0.85rem" }}>
<a href={pemDataUrl(certBundle.certificate_pem)} download={`${certBundle.username}.crt`}>
Download {certBundle.username}.crt
</a>{" "}
<a href={pemDataUrl(certBundle.private_key_pem)} download={`${certBundle.username}.key`}>
Download {certBundle.username}.key
</a>{" "}
<a href={pemDataUrl(certBundle.ca_chain_pem)} download="ca.crt">
Download ca.crt (issuing CA)
</a>
</p>
<p style={{ margin: "0.4rem 0 0.2rem", fontSize: "0.85rem" }}>
or copy manually — <span className="mono">{certBundle.username}.crt</span>:
</p>
<pre className="config mono">{certBundle.certificate_pem}</pre>
<p style={{ margin: "0.4rem 0 0.2rem", fontSize: "0.85rem" }}>
<span className="mono">{certBundle.username}.key</span>:
</p>
<pre className="config mono">{certBundle.private_key_pem}</pre>
<p style={{ margin: "0.4rem 0 0.2rem", fontSize: "0.85rem" }}>
<span className="mono">ca.crt</span> (issuing CA):
</p>
<pre className="config mono">{certBundle.ca_chain_pem}</pre>
<button onClick={() => setCertBundle(null)}>
I have saved the files — dismiss
</button>
</div>
)}
{error && <div className="banner error">{error}</div>}
{(clients.error || roles.error || groups.error) && (
<div className="banner error">
@@ -179,6 +283,15 @@ export function ClientsPage() {
onChange={(e) => setNewUsername(e.target.value)}
placeholder="e.g. ps042"
/>
<label htmlFor="new-client-id">
Expected client ID (optional — binds the credential to one device)
</label>
<input
id="new-client-id"
value={newClientId}
onChange={(e) => setNewClientId(e.target.value)}
placeholder="e.g. device-ps042"
/>
<p />
<button className="primary" disabled={!newUsername || create.isPending}>
{create.isPending ? "Creating…" : "Create client (random password)"}
@@ -213,7 +326,15 @@ export function ClientsPage() {
return (
<tr key={c.username}>
<td className="mono">{c.username}</td>
<td className="mono">{c.clientid ?? "—"}</td>
<td className="mono">
{c.clientid ?? "—"}{" "}
<button
onClick={() => setClientIdFor(c)}
title="Bind an expected client ID so this credential can only be used by that device"
>
Set
</button>
</td>
<td>{c.enabled ? "yes" : "no"}</td>
<td>
<AssignmentChips
@@ -291,6 +412,12 @@ export function ClientsPage() {
>
Reset password
</button>{" "}
<button
onClick={() => issueClientCertificate(c.username)}
title="Issue a client certificate (CN = username) for mTLS"
>
Issue certificate
</button>{" "}
<button
className="danger"
onClick={() => {