🚧 Update certificate management
This commit is contained in:
@@ -39,7 +39,7 @@ const NAV_SECTIONS: {
|
||||
items: [
|
||||
{ label: "Authorities", to: "/certificates" },
|
||||
{ label: "Server Certificates", to: "/certificates" },
|
||||
{ label: "Client Certificates", hint: "Client certs + one-time download (Release 2, later)" },
|
||||
{ label: "Client Certificates", to: "/certificates#client-certificates" },
|
||||
{ label: "CSRs", hint: "CSR signing (Release 2, later)" },
|
||||
{ label: "Revocations", hint: "CRL management (Release 2, later)" },
|
||||
],
|
||||
|
||||
@@ -93,6 +93,11 @@ export const api = {
|
||||
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
||||
};
|
||||
|
||||
/** Build a text/plain download URL for a PEM payload (one-time bundles). */
|
||||
export function pemDataUrl(pem: string): string {
|
||||
return `data:application/x-pem-file;charset=utf-8,${encodeURIComponent(pem)}`;
|
||||
}
|
||||
|
||||
// --- Assignment operations (spec sections 11-12) ---------------------------
|
||||
|
||||
export const assignClientRole = (username: string, rolename: string, priority = -1) =>
|
||||
@@ -167,6 +172,8 @@ export interface Certificate {
|
||||
common_name: string;
|
||||
is_ca: boolean;
|
||||
parent_id: string | null;
|
||||
cert_type: string;
|
||||
username: string | null;
|
||||
key_algorithm: string;
|
||||
serial_number: string;
|
||||
dns_sans: string[];
|
||||
@@ -177,6 +184,21 @@ export interface Certificate {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CertificateBundle {
|
||||
certificate_id: string;
|
||||
username: string;
|
||||
certificate_pem: string;
|
||||
private_key_pem: string;
|
||||
ca_chain_pem: string;
|
||||
download_hint: string;
|
||||
}
|
||||
|
||||
export interface ClientCertCreate {
|
||||
username: string;
|
||||
ca_id: string;
|
||||
validity_days: number;
|
||||
}
|
||||
|
||||
export interface CaCreate {
|
||||
common_name: string;
|
||||
validity_days: number;
|
||||
@@ -210,6 +232,7 @@ export interface Listener {
|
||||
tls_min_version: string | null;
|
||||
require_certificate: boolean;
|
||||
use_identity_as_username: boolean;
|
||||
cert_id: string | null;
|
||||
}
|
||||
|
||||
export interface Revision {
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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={() => {
|
||||
|
||||
Reference in New Issue
Block a user