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

View File

@@ -27,15 +27,15 @@ import {
deleteCertificateAction,
updateCertificateAction,
} from "./actions";
import type { AcmeHost, CaCertificate, CertExpiryStatus, ImportedCertView, ManagedCertView } from "./page";
import type { AcmeHost, CaCertificateView, CertExpiryStatus, ImportedCertView, ManagedCertView } from "./page";
import { useState } from "react";
import { CreateCaCertDialog, EditCaCertDialog, DeleteCaCertDialog, IssueClientCertDialog } from "@/src/components/ca-certificates/CaCertDialogs";
import { CreateCaCertDialog, EditCaCertDialog, DeleteCaCertDialog, IssueClientCertDialog, ManageIssuedClientCertsDialog } from "@/src/components/ca-certificates/CaCertDialogs";
type Props = {
acmeHosts: AcmeHost[];
importedCerts: ImportedCertView[];
managedCerts: ManagedCertView[];
caCertificates: CaCertificate[];
caCertificates: CaCertificateView[];
acmePagination: { total: number; page: number; perPage: number };
};
@@ -65,9 +65,10 @@ function ExpiryChip({
export default function CertificatesClient({ acmeHosts, importedCerts, managedCerts, caCertificates, acmePagination }: Props) {
const [createCaOpen, setCreateCaOpen] = useState(false);
const [editCaCert, setEditCaCert] = useState<CaCertificate | null>(null);
const [deleteCaCert, setDeleteCaCert] = useState<CaCertificate | null>(null);
const [issueCaCert, setIssueCaCert] = useState<CaCertificate | null>(null);
const [editCaCert, setEditCaCert] = useState<CaCertificateView | null>(null);
const [deleteCaCert, setDeleteCaCert] = useState<CaCertificateView | null>(null);
const [issueCaCert, setIssueCaCert] = useState<CaCertificateView | null>(null);
const [manageIssuedCaCert, setManageIssuedCaCert] = useState<CaCertificateView | null>(null);
const acmeColumns = [
{
id: 'name',
@@ -445,7 +446,8 @@ export default function CertificatesClient({ acmeHosts, importedCerts, managedCe
<Stack spacing={2}>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Typography variant="body2" color="text.secondary">
CA certificates used for mTLS clients must present a certificate signed by one of these CAs.
CA certificates used for mTLS. New client certificates issued from this UI are tracked here and can be
revoked individually; previously issued certificates are not backfilled.
</Typography>
<Button
startIcon={<AddIcon />}
@@ -467,21 +469,43 @@ export default function CertificatesClient({ acmeHosts, importedCerts, managedCe
{
id: "name",
label: "Name",
render: (ca: CaCertificate) => <Typography fontWeight={600}>{ca.name}</Typography>,
render: (ca: CaCertificateView) => <Typography fontWeight={600}>{ca.name}</Typography>,
},
{
id: "created_at",
label: "Added",
render: (ca: CaCertificate) => (
render: (ca: CaCertificateView) => (
<Typography variant="body2" color="text.secondary">
{new Date(ca.created_at).toLocaleDateString()}
</Typography>
),
},
{
id: "issued",
label: "Issued Client Certs",
render: (ca: CaCertificateView) => {
const activeCount = ca.issuedCerts.filter((issued) => !issued.revoked_at).length;
if (ca.issuedCerts.length === 0) {
return (
<Typography variant="body2" color="text.secondary">
None
</Typography>
);
}
return (
<Chip
label={`${activeCount}/${ca.issuedCerts.length} active`}
size="small"
color={activeCount > 0 ? "success" : "default"}
variant="outlined"
/>
);
},
},
{
id: "actions",
label: "",
render: (ca: CaCertificate) => (
render: (ca: CaCertificateView) => (
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
{ca.has_private_key && (
<Tooltip title="Issue Client Certificate">
@@ -490,6 +514,14 @@ export default function CertificatesClient({ acmeHosts, importedCerts, managedCe
</Button>
</Tooltip>
)}
<Button
size="small"
variant="outlined"
onClick={() => setManageIssuedCaCert(ca)}
disabled={ca.issuedCerts.length === 0}
>
Issued
</Button>
<Tooltip title="Edit">
<IconButton size="small" onClick={() => setEditCaCert(ca)}>
<EditIcon fontSize="small" />
@@ -536,6 +568,14 @@ export default function CertificatesClient({ acmeHosts, importedCerts, managedCe
onClose={() => setIssueCaCert(null)}
/>
)}
{manageIssuedCaCert && (
<ManageIssuedClientCertsDialog
open={!!manageIssuedCaCert}
cert={manageIssuedCaCert}
issuedCerts={manageIssuedCaCert.issuedCerts}
onClose={() => setManageIssuedCaCert(null)}
/>
)}
</Stack>
);
}

View File

@@ -3,6 +3,7 @@
import { revalidatePath } from "next/cache";
import { requireAdmin } from "@/src/lib/auth";
import { createCaCertificate, deleteCaCertificate, updateCaCertificate, getCaCertificatePrivateKey } from "@/src/lib/models/ca-certificates";
import { createIssuedClientCertificate, revokeIssuedClientCertificate } from "@/src/lib/models/issued-client-certificates";
import { X509Certificate } from "node:crypto";
import forge from "node-forge";
@@ -101,7 +102,8 @@ export async function issueClientCertificateAction(
caCertId: number,
formData: FormData
): Promise<IssuedClientCert> {
await requireAdmin();
const session = await requireAdmin();
const userId = Number(session.user.id);
const commonName = String(formData.get("common_name") ?? "").trim();
const validityDays = Math.min(3650, Math.max(1, parseInt(String(formData.get("validity_days") ?? "365"), 10) || 365));
const exportPassword = String(formData.get("export_password") ?? "");
@@ -137,6 +139,22 @@ export async function issueClientCertificateAction(
]);
cert.sign(caKey, forge.md.sha256.create());
const certificatePem = forge.pki.certificateToPem(cert);
const certificate = new X509Certificate(certificatePem);
await createIssuedClientCertificate(
{
ca_certificate_id: caCertId,
common_name: commonName,
serial_number: cert.serialNumber.toUpperCase(),
fingerprint_sha256: certificate.fingerprint256,
certificate_pem: certificatePem,
valid_from: new Date(certificate.validFrom).toISOString(),
valid_to: new Date(certificate.validTo).toISOString()
},
userId
);
revalidatePath("/certificates");
const pkcs12Asn1 = forge.pkcs12.toPkcs12Asn1(
keypair.privateKey,
@@ -155,3 +173,11 @@ export async function issueClientCertificateAction(
exportAlgorithm,
};
}
export async function revokeIssuedClientCertificateAction(id: number): Promise<{ revokedAt: string }> {
const session = await requireAdmin();
const userId = Number(session.user.id);
const record = await revokeIssuedClientCertificate(id, userId);
revalidatePath("/certificates");
return { revokedAt: record.revoked_at! };
}

View File

@@ -6,8 +6,14 @@ import { requireAdmin } from '@/src/lib/auth';
import CertificatesClient from './CertificatesClient';
import { scanAcmeCerts } from '@/src/lib/acme-certs';
import { listCaCertificates, type CaCertificate } from '@/src/lib/models/ca-certificates';
import { listIssuedClientCertificates, type IssuedClientCertificate } from '@/src/lib/models/issued-client-certificates';
export type { CaCertificate };
export type { IssuedClientCertificate };
export type CaCertificateView = CaCertificate & {
issuedCerts: IssuedClientCertificate[];
};
export type CertExpiryStatus = 'ok' | 'expiring_soon' | 'expired';
@@ -82,7 +88,10 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
const offset = (page - 1) * PER_PAGE;
const acmeCertMap = scanAcmeCerts();
const caCerts = await listCaCertificates();
const [caCerts, issuedClientCerts] = await Promise.all([
listCaCertificates(),
listIssuedClientCertificates()
]);
const [acmeRows, acmeTotal, certRows, usageRows] = await Promise.all([
db
@@ -149,6 +158,16 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
const importedCerts: ImportedCertView[] = [];
const managedCerts: ManagedCertView[] = [];
const issuedByCa = issuedClientCerts.reduce<Map<number, IssuedClientCertificate[]>>((map, cert) => {
const current = map.get(cert.ca_certificate_id) ?? [];
current.push(cert);
map.set(cert.ca_certificate_id, current);
return map;
}, new Map());
const caCertificateViews: CaCertificateView[] = caCerts.map((cert) => ({
...cert,
issuedCerts: issuedByCa.get(cert.id) ?? [],
}));
for (const cert of certRows) {
const domainNames = JSON.parse(cert.domainNames) as string[];
@@ -174,7 +193,7 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
acmeHosts={acmeHosts}
importedCerts={importedCerts}
managedCerts={managedCerts}
caCertificates={caCerts}
caCertificates={caCertificateViews}
acmePagination={{ total: acmeTotal, page, perPage: PER_PAGE }}
/>
);

View File

@@ -111,6 +111,37 @@ function isAccessList(value: unknown): value is SyncPayload["data"]["accessLists
);
}
function isCaCertificate(value: unknown): value is SyncPayload["data"]["caCertificates"][number] {
if (!isRecord(value)) return false;
return (
isNumber(value.id) &&
isString(value.name) &&
isString(value.certificatePem) &&
isNullableString(value.privateKeyPem) &&
isNullableNumber(value.createdBy) &&
isString(value.createdAt) &&
isString(value.updatedAt)
);
}
function isIssuedClientCertificate(value: unknown): value is SyncPayload["data"]["issuedClientCertificates"][number] {
if (!isRecord(value)) return false;
return (
isNumber(value.id) &&
isNumber(value.caCertificateId) &&
isString(value.commonName) &&
isString(value.serialNumber) &&
isString(value.fingerprintSha256) &&
isString(value.certificatePem) &&
isString(value.validFrom) &&
isString(value.validTo) &&
isNullableString(value.revokedAt) &&
isNullableNumber(value.createdBy) &&
isString(value.createdAt) &&
isString(value.updatedAt)
);
}
function isAccessListEntry(value: unknown): value is SyncPayload["data"]["accessListEntries"][number] {
if (!isRecord(value)) return false;
return (
@@ -183,6 +214,8 @@ function isValidSyncPayload(payload: unknown): payload is SyncPayload {
return (
validateArray(d.certificates, isCertificate) &&
validateArray(d.caCertificates, isCaCertificate) &&
validateArray(d.issuedClientCertificates, isIssuedClientCertificate) &&
validateArray(d.accessLists, isAccessList) &&
validateArray(d.accessListEntries, isAccessListEntry) &&
validateArray(d.proxyHosts, isProxyHost)
@@ -223,7 +256,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Sync payload too large" }, { status: 413 });
}
payload = JSON.parse(bodyText);
} catch (error) {
} catch {
return NextResponse.json({ error: "Invalid JSON payload" }, { status: 400 });
}

View File

@@ -0,0 +1,16 @@
CREATE TABLE `issued_client_certificates` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`ca_certificate_id` integer NOT NULL REFERENCES `ca_certificates`(`id`) ON DELETE cascade,
`common_name` text NOT NULL,
`serial_number` text NOT NULL,
`fingerprint_sha256` text NOT NULL,
`certificate_pem` text NOT NULL,
`valid_from` text NOT NULL,
`valid_to` text NOT NULL,
`revoked_at` text,
`created_by` integer REFERENCES `users`(`id`) ON DELETE set null,
`created_at` text NOT NULL,
`updated_at` text NOT NULL
);
CREATE INDEX `issued_client_certificates_ca_idx` ON `issued_client_certificates` (`ca_certificate_id`);
CREATE INDEX `issued_client_certificates_revoked_at_idx` ON `issued_client_certificates` (`revoked_at`);

View File

@@ -92,6 +92,13 @@
"when": 1772400000000,
"tag": "0012_ca_private_key",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1772500000000,
"tag": "0013_issued_client_certificates",
"breakpoints": true
}
]
}
}

View File

@@ -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,

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

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(),

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();
}

View File

@@ -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))!;
}