Added issued-client-cert tracking and revocation for mTLS

This commit is contained in:
fuomag9
2026-03-06 14:53:17 +01:00
parent 6acd51b578
commit 044f012dd0
11 changed files with 523 additions and 46 deletions
@@ -4,6 +4,9 @@ import {
Alert,
Box,
Button,
Card,
CardContent,
Chip,
Dialog,
DialogActions,
DialogContent,
@@ -19,13 +22,16 @@ import {
Typography,
} from "@mui/material";
import DownloadIcon from "@mui/icons-material/Download";
import { useTransition, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState, useTransition } from "react";
import type { CaCertificate } from "@/src/lib/models/ca-certificates";
import type { IssuedClientCertificate } from "@/src/lib/models/issued-client-certificates";
import {
createCaCertificateAction,
deleteCaCertificateAction,
generateCaCertificateAction,
issueClientCertificateAction,
revokeIssuedClientCertificateAction,
updateCaCertificateAction,
} from "@/app/(dashboard)/certificates/ca-actions";
@@ -53,6 +59,14 @@ function sanitizeFilenameSegment(value: string): string {
return value.trim().replace(/[^a-z0-9._-]+/gi, "_").replace(/^_+|_+$/g, "") || "client";
}
function formatDateTime(value: string): string {
return new Date(value).toLocaleString();
}
function formatFingerprint(value: string): string {
return value.match(/.{1,2}/g)?.join(":") ?? value;
}
export function CreateCaCertDialog({
open,
onClose,
@@ -240,6 +254,7 @@ export function IssueClientCertDialog({
cert: CaCertificate;
onClose: () => void;
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [issued, setIssued] = useState<{
pkcs12Base64: string;
@@ -267,6 +282,7 @@ export function IssueClientCertDialog({
...result,
name: sanitizeFilenameSegment(String(formData.get("common_name") ?? "client")),
});
router.refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to issue certificate");
}
@@ -360,6 +376,133 @@ export function IssueClientCertDialog({
);
}
export function ManageIssuedClientCertsDialog({
open,
cert,
issuedCerts,
onClose,
}: {
open: boolean;
cert: CaCertificate;
issuedCerts: IssuedClientCertificate[];
onClose: () => void;
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [items, setItems] = useState<IssuedClientCertificate[]>(issuedCerts);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) {
return;
}
setItems(issuedCerts);
setError(null);
}, [issuedCerts, open]);
function handleRevoke(id: number) {
setError(null);
startTransition(async () => {
try {
const result = await revokeIssuedClientCertificateAction(id);
setItems((current) =>
current.map((item) =>
item.id === id ? { ...item, revoked_at: result.revokedAt, updated_at: result.revokedAt } : item
)
);
router.refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to revoke certificate");
}
});
}
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>Issued Client Certificates</DialogTitle>
<DialogContent>
<Stack spacing={2} mt={1}>
<Alert severity="info">
Revoking a client certificate removes it from the trusted mTLS client certificate pool for hosts using{" "}
<strong>{cert.name}</strong>.
</Alert>
{error && <Typography color="error" variant="body2">{error}</Typography>}
{items.length === 0 ? (
<Typography color="text.secondary" variant="body2">
No issued client certificates are currently tracked for this CA. Certificates issued from this UI will
appear here and can then be revoked individually.
</Typography>
) : (
items.map((item) => {
const expired = new Date(item.valid_to).getTime() < Date.now();
return (
<Card key={item.id} variant="outlined">
<CardContent>
<Stack spacing={1.5}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={2}>
<Box>
<Typography variant="h6" fontWeight={600}>
{item.common_name}
</Typography>
<Typography variant="body2" color="text.secondary">
Serial {item.serial_number}
</Typography>
</Box>
<Stack direction="row" spacing={1} flexWrap="wrap" justifyContent="flex-end">
<Chip
label={item.revoked_at ? "Revoked" : "Active"}
color={item.revoked_at ? "default" : "success"}
size="small"
/>
<Chip
label={expired ? `Expired ${formatDateTime(item.valid_to)}` : `Expires ${formatDateTime(item.valid_to)}`}
color={expired ? "error" : "default"}
size="small"
variant="outlined"
/>
</Stack>
</Stack>
<Typography variant="body2" color="text.secondary">
Issued {formatDateTime(item.created_at)}
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ fontFamily: "monospace", wordBreak: "break-all" }}
>
SHA-256 {formatFingerprint(item.fingerprint_sha256)}
</Typography>
{item.revoked_at ? (
<Typography variant="body2" color="text.secondary">
Revoked {formatDateTime(item.revoked_at)}
</Typography>
) : (
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button
variant="outlined"
color="error"
disabled={isPending}
onClick={() => handleRevoke(item.id)}
>
{isPending ? "Revoking..." : "Revoke"}
</Button>
</Box>
)}
</Stack>
</CardContent>
</Card>
);
})
)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={isPending}>Close</Button>
</DialogActions>
</Dialog>
);
}
export function DeleteCaCertDialog({
open,
cert,
+46 -26
View File
@@ -1,4 +1,4 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { mkdirSync } from "node:fs";
import { Resolver } from "node:dns/promises";
import { join } from "node:path";
import { isIP } from "node:net";
@@ -6,6 +6,7 @@ import crypto from "node:crypto";
import http from "node:http";
import https from "node:https";
import db, { nowIso } from "./db";
import { isNull } from "drizzle-orm";
import { config } from "./config";
import {
getCloudflareSettings,
@@ -28,6 +29,7 @@ import {
accessListEntries,
certificates,
caCertificates,
issuedClientCertificates,
proxyHosts
} from "./db/schema";
import { type GeoBlockMode, type WafHostConfig, type MtlsConfig } from "./models/proxy-hosts";
@@ -677,17 +679,6 @@ async function resolveUpstreamDials(
};
}
function writeCertificateFiles(cert: CertificateRow) {
if (cert.type !== "imported" || !cert.certificate_pem || !cert.private_key_pem) {
return null;
}
const certPath = join(CERTS_DIR, `certificate-${cert.id}.pem`);
const keyPath = join(CERTS_DIR, `certificate-${cert.id}.key.pem`);
writeFileSync(certPath, cert.certificate_pem, { encoding: "utf-8", mode: 0o600 });
writeFileSync(keyPath, cert.private_key_pem, { encoding: "utf-8", mode: 0o600 });
return { certificate_file: certPath, key_file: keyPath };
}
function pemToBase64Der(pem: string): string {
// Strip PEM headers/footers and whitespace — what remains is the base64-encoded DER
return pem
@@ -1039,7 +1030,7 @@ async function buildProxyRoutes(
let outpostRoute: CaddyHttpRoute | null = null;
if (authentik) {
// Parse the outpost upstream URL to extract host:port for Caddy's dial field
let outpostDial = authentik.outpostUpstream;
let outpostDial: string;
try {
const url = new URL(authentik.outpostUpstream);
const port = url.port || (url.protocol === "https:" ? "443" : "80");
@@ -1113,7 +1104,7 @@ async function buildProxyRoutes(
if (loadBalancing) {
reverseProxyHandler.load_balancing = loadBalancing;
}
const healthChecks = buildHealthChecksConfig(lbConfig, dnsConfig);
const healthChecks = buildHealthChecksConfig(lbConfig);
if (healthChecks) {
reverseProxyHandler.health_checks = healthChecks;
}
@@ -1323,9 +1314,12 @@ async function buildProxyRoutes(
function buildClientAuthentication(
domains: string[],
mTlsDomainMap: Map<string, number[]>,
caCertMap: Map<number, { id: number; certificatePem: string }>
caCertMap: Map<number, { id: number; certificatePem: string }>,
issuedClientCertMap: Map<number, string[]>
): Record<string, unknown> | null {
// Collect all CA cert IDs for any domain in this policy that has mTLS
// Collect all CA cert IDs for any domain in this policy that has mTLS.
// If a CA has managed issued client certs, trust only the active leaf certs
// for that CA so revocation can be enforced by removing them from the pool.
const caCertIds = new Set<number>();
for (const domain of domains) {
const ids = mTlsDomainMap.get(domain.toLowerCase());
@@ -1337,6 +1331,14 @@ function buildClientAuthentication(
const derCerts: string[] = [];
for (const id of caCertIds) {
const issuedLeafCerts = issuedClientCertMap.get(id) ?? [];
if (issuedLeafCerts.length > 0) {
for (const certPem of issuedLeafCerts) {
derCerts.push(pemToBase64Der(certPem));
}
continue;
}
const ca = caCertMap.get(id);
if (!ca) continue;
derCerts.push(pemToBase64Der(ca.certificatePem));
@@ -1354,7 +1356,8 @@ function buildTlsConnectionPolicies(
managedCertificatesWithAutomation: Set<number>,
autoManagedDomains: Set<string>,
mTlsDomainMap: Map<string, number[]>,
caCertMap: Map<number, { id: number; certificatePem: string }>
caCertMap: Map<number, { id: number; certificatePem: string }>,
issuedClientCertMap: Map<number, string[]>
) {
const policies: Record<string, unknown>[] = [];
const readyCertificates = new Set<number>();
@@ -1363,7 +1366,7 @@ function buildTlsConnectionPolicies(
// Add policy for auto-managed domains (certificate_id = null)
if (autoManagedDomains.size > 0) {
const domains = Array.from(autoManagedDomains);
const clientAuth = buildClientAuthentication(domains, mTlsDomainMap, caCertMap);
const clientAuth = buildClientAuthentication(domains, mTlsDomainMap, caCertMap, issuedClientCertMap);
if (clientAuth) {
// Split: mTLS domains get their own policy, non-mTLS get another
@@ -1371,7 +1374,7 @@ function buildTlsConnectionPolicies(
const nonMTlsDomains = domains.filter(d => !mTlsDomainMap.has(d));
if (mTlsDomains.length > 0) {
const mTlsAuth = buildClientAuthentication(mTlsDomains, mTlsDomainMap, caCertMap);
const mTlsAuth = buildClientAuthentication(mTlsDomains, mTlsDomainMap, caCertMap, issuedClientCertMap);
policies.push({
match: { sni: mTlsDomains },
client_authentication: mTlsAuth
@@ -1406,7 +1409,7 @@ function buildTlsConnectionPolicies(
const nonMTlsDomains = domains.filter(d => !mTlsDomainMap.has(d));
if (mTlsDomains.length > 0) {
const mTlsAuth = buildClientAuthentication(mTlsDomains, mTlsDomainMap, caCertMap);
const mTlsAuth = buildClientAuthentication(mTlsDomains, mTlsDomainMap, caCertMap, issuedClientCertMap);
policies.push({
match: { sni: mTlsDomains },
...(mTlsAuth ? { client_authentication: mTlsAuth } : {})
@@ -1429,7 +1432,7 @@ function buildTlsConnectionPolicies(
const nonMTlsDomains = domains.filter(d => !mTlsDomainMap.has(d));
if (mTlsDomains.length > 0) {
const mTlsAuth = buildClientAuthentication(mTlsDomains, mTlsDomainMap, caCertMap);
const mTlsAuth = buildClientAuthentication(mTlsDomains, mTlsDomainMap, caCertMap, issuedClientCertMap);
policies.push({
match: { sni: mTlsDomains },
...(mTlsAuth ? { client_authentication: mTlsAuth } : {})
@@ -1587,7 +1590,7 @@ async function buildTlsAutomation(
}
async function buildCaddyDocument() {
const [proxyHostRecords, certRows, accessListEntryRecords, caCertRows] = await Promise.all([
const [proxyHostRecords, certRows, accessListEntryRecords, caCertRows, issuedClientCertRows] = await Promise.all([
db
.select({
id: proxyHosts.id,
@@ -1630,7 +1633,14 @@ async function buildCaddyDocument() {
id: caCertificates.id,
certificatePem: caCertificates.certificatePem
})
.from(caCertificates)
.from(caCertificates),
db
.select({
caCertificateId: issuedClientCertificates.caCertificateId,
certificatePem: issuedClientCertificates.certificatePem
})
.from(issuedClientCertificates)
.where(isNull(issuedClientCertificates.revokedAt))
]);
const proxyHostRows: ProxyHostRow[] = proxyHostRecords.map((h) => ({
@@ -1669,6 +1679,12 @@ async function buildCaddyDocument() {
const certificateMap = new Map(certRowsMapped.map((cert) => [cert.id, cert]));
const caCertMap = new Map(caCertRows.map((ca) => [ca.id, ca]));
const issuedClientCertMap = issuedClientCertRows.reduce<Map<number, string[]>>((map, record) => {
const current = map.get(record.caCertificateId) ?? [];
current.push(record.certificatePem);
map.set(record.caCertificateId, current);
return map;
}, new Map());
const accessMap = accessListEntryRows.reduce<Map<number, AccessListEntryRow[]>>((map, entry) => {
if (!map.has(entry.access_list_id)) {
map.set(entry.access_list_id, []);
@@ -1706,7 +1722,8 @@ async function buildCaddyDocument() {
managedCertificateIds,
autoManagedDomains,
mTlsDomainMap,
caCertMap
caCertMap,
issuedClientCertMap
);
const httpRoutes: CaddyHttpRoute[] = await buildProxyRoutes(
@@ -1863,7 +1880,10 @@ export async function applyCaddyConfig() {
const causeCode = err?.cause?.code;
if (causeCode === "ENOTFOUND" || causeCode === "ECONNREFUSED") {
throw new Error(`Unable to reach Caddy API at ${config.caddyApiUrl}. Ensure Caddy is running and accessible.`);
throw new Error(
`Unable to reach Caddy API at ${config.caddyApiUrl}. Ensure Caddy is running and accessible.`,
{ cause: error }
);
}
throw error;
@@ -2017,7 +2037,7 @@ type DnsResolverRouteConfig = {
timeout: string | null;
};
function buildHealthChecksConfig(config: LoadBalancerRouteConfig, dnsConfig: DnsResolverRouteConfig | null): Record<string, unknown> | null {
function buildHealthChecksConfig(config: LoadBalancerRouteConfig): Record<string, unknown> | null {
const healthChecks: Record<string, unknown> = {};
// Active health checks
+24
View File
@@ -138,6 +138,30 @@ export const caCertificates = sqliteTable("ca_certificates", {
updatedAt: text("updated_at").notNull()
});
export const issuedClientCertificates = sqliteTable(
"issued_client_certificates",
{
id: integer("id").primaryKey({ autoIncrement: true }),
caCertificateId: integer("ca_certificate_id")
.references(() => caCertificates.id, { onDelete: "cascade" })
.notNull(),
commonName: text("common_name").notNull(),
serialNumber: text("serial_number").notNull(),
fingerprintSha256: text("fingerprint_sha256").notNull(),
certificatePem: text("certificate_pem").notNull(),
validFrom: text("valid_from").notNull(),
validTo: text("valid_to").notNull(),
revokedAt: text("revoked_at"),
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
},
(table) => ({
caCertificateIdx: index("issued_client_certificates_ca_idx").on(table.caCertificateId),
revokedAtIdx: index("issued_client_certificates_revoked_at_idx").on(table.revokedAt)
})
);
export const proxyHosts = sqliteTable("proxy_hosts", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
+15 -4
View File
@@ -1,5 +1,5 @@
import db, { nowIso } from "./db";
import { accessListEntries, accessLists, caCertificates, certificates, proxyHosts } from "./db/schema";
import { accessListEntries, accessLists, caCertificates, certificates, issuedClientCertificates, proxyHosts } from "./db/schema";
import { getSetting, setSetting } from "./settings";
import { recordInstanceSyncResult, updateInstance } from "./models/instances";
import { decryptSecret, encryptSecret, isEncryptedSecret } from "./secret";
@@ -24,6 +24,7 @@ export type SyncPayload = {
data: {
certificates: Array<typeof certificates.$inferSelect>;
caCertificates: Array<typeof caCertificates.$inferSelect>;
issuedClientCertificates: Array<typeof issuedClientCertificates.$inferSelect>;
accessLists: Array<typeof accessLists.$inferSelect>;
accessListEntries: Array<typeof accessListEntries.$inferSelect>;
proxyHosts: Array<typeof proxyHosts.$inferSelect>;
@@ -232,9 +233,10 @@ export async function clearSyncedSetting(key: string): Promise<void> {
}
export async function buildSyncPayload(): Promise<SyncPayload> {
const [certRows, caCertRows, accessListRows, accessEntryRows, proxyRows] = await Promise.all([
const [certRows, caCertRows, issuedClientCertRows, accessListRows, accessEntryRows, proxyRows] = await Promise.all([
db.select().from(certificates),
db.select().from(caCertificates),
db.select().from(issuedClientCertificates),
db.select().from(accessLists),
db.select().from(accessListEntries),
db.select().from(proxyHosts)
@@ -267,6 +269,11 @@ export async function buildSyncPayload(): Promise<SyncPayload> {
createdBy: null
}));
const sanitizedIssuedClientCertificates = issuedClientCertRows.map((row) => ({
...row,
createdBy: null
}));
const sanitizedProxyHosts = proxyRows.map((row) => ({
...row,
ownerUserId: null
@@ -278,6 +285,7 @@ export async function buildSyncPayload(): Promise<SyncPayload> {
data: {
certificates: sanitizedCertificates,
caCertificates: sanitizedCaCertificates,
issuedClientCertificates: sanitizedIssuedClientCertificates,
accessLists: sanitizedAccessLists,
accessListEntries: accessEntryRows,
proxyHosts: sanitizedProxyHosts
@@ -305,7 +313,6 @@ export async function syncInstances(): Promise<{ total: number; success: number;
const httpAllowed = isHttpSyncAllowed();
const payload = await buildSyncPayload();
let skippedHttp = 0;
// Sync database-configured instances
const dbResults = await Promise.all(
@@ -396,7 +403,7 @@ export async function syncInstances(): Promise<{ total: number; success: number;
const allResults = [...dbResults, ...envResults];
const success = allResults.filter((r) => r.ok).length;
skippedHttp = allResults.filter((r) => r.skippedHttp).length;
const skippedHttp = allResults.filter((r) => r.skippedHttp).length;
const failed = allResults.length - success - skippedHttp;
return { total: allResults.length, success, failed, skippedHttp };
@@ -418,6 +425,7 @@ export async function applySyncPayload(payload: SyncPayload) {
tx.delete(proxyHosts).run();
tx.delete(accessListEntries).run();
tx.delete(accessLists).run();
tx.delete(issuedClientCertificates).run();
tx.delete(certificates).run();
tx.delete(caCertificates).run();
@@ -427,6 +435,9 @@ export async function applySyncPayload(payload: SyncPayload) {
if (payload.data.caCertificates && payload.data.caCertificates.length > 0) {
tx.insert(caCertificates).values(payload.data.caCertificates).run();
}
if (payload.data.issuedClientCertificates && payload.data.issuedClientCertificates.length > 0) {
tx.insert(issuedClientCertificates).values(payload.data.issuedClientCertificates).run();
}
if (payload.data.accessLists.length > 0) {
tx.insert(accessLists).values(payload.data.accessLists).run();
}
@@ -0,0 +1,138 @@
import db, { nowIso, toIso } from "../db";
import { logAuditEvent } from "../audit";
import { applyCaddyConfig } from "../caddy";
import { issuedClientCertificates } from "../db/schema";
import { desc, eq } from "drizzle-orm";
export type IssuedClientCertificate = {
id: number;
ca_certificate_id: number;
common_name: string;
serial_number: string;
fingerprint_sha256: string;
certificate_pem: string;
valid_from: string;
valid_to: string;
revoked_at: string | null;
created_at: string;
updated_at: string;
};
export type IssuedClientCertificateInput = {
ca_certificate_id: number;
common_name: string;
serial_number: string;
fingerprint_sha256: string;
certificate_pem: string;
valid_from: string;
valid_to: string;
};
type IssuedClientCertificateRow = typeof issuedClientCertificates.$inferSelect;
function parseIssuedClientCertificate(row: IssuedClientCertificateRow): IssuedClientCertificate {
return {
id: row.id,
ca_certificate_id: row.caCertificateId,
common_name: row.commonName,
serial_number: row.serialNumber,
fingerprint_sha256: row.fingerprintSha256,
certificate_pem: row.certificatePem,
valid_from: toIso(row.validFrom)!,
valid_to: toIso(row.validTo)!,
revoked_at: toIso(row.revokedAt),
created_at: toIso(row.createdAt)!,
updated_at: toIso(row.updatedAt)!
};
}
export async function listIssuedClientCertificates(): Promise<IssuedClientCertificate[]> {
const rows = await db
.select()
.from(issuedClientCertificates)
.orderBy(desc(issuedClientCertificates.createdAt));
return rows.map(parseIssuedClientCertificate);
}
export async function getIssuedClientCertificate(id: number): Promise<IssuedClientCertificate | null> {
const record = await db.query.issuedClientCertificates.findFirst({
where: (table, { eq: compareEq }) => compareEq(table.id, id)
});
return record ? parseIssuedClientCertificate(record) : null;
}
export async function createIssuedClientCertificate(
input: IssuedClientCertificateInput,
actorUserId: number
): Promise<IssuedClientCertificate> {
const now = nowIso();
const [record] = await db
.insert(issuedClientCertificates)
.values({
caCertificateId: input.ca_certificate_id,
commonName: input.common_name.trim(),
serialNumber: input.serial_number.trim(),
fingerprintSha256: input.fingerprint_sha256.trim(),
certificatePem: input.certificate_pem.trim(),
validFrom: input.valid_from,
validTo: input.valid_to,
createdBy: actorUserId,
createdAt: now,
updatedAt: now
})
.returning();
if (!record) {
throw new Error("Failed to store issued client certificate");
}
logAuditEvent({
userId: actorUserId,
action: "create",
entityType: "issued_client_certificate",
entityId: record.id,
summary: `Issued client certificate ${input.common_name}`,
data: {
caCertificateId: input.ca_certificate_id,
serialNumber: input.serial_number
}
});
await applyCaddyConfig();
return (await getIssuedClientCertificate(record.id))!;
}
export async function revokeIssuedClientCertificate(
id: number,
actorUserId: number
): Promise<IssuedClientCertificate> {
const existing = await getIssuedClientCertificate(id);
if (!existing) {
throw new Error("Issued client certificate not found");
}
if (existing.revoked_at) {
throw new Error("Issued client certificate is already revoked");
}
const revokedAt = nowIso();
await db
.update(issuedClientCertificates)
.set({
revokedAt,
updatedAt: revokedAt
})
.where(eq(issuedClientCertificates.id, id));
logAuditEvent({
userId: actorUserId,
action: "revoke",
entityType: "issued_client_certificate",
entityId: id,
summary: `Revoked client certificate ${existing.common_name}`,
data: {
caCertificateId: existing.ca_certificate_id,
serialNumber: existing.serial_number
}
});
await applyCaddyConfig();
return (await getIssuedClientCertificate(id))!;
}