diff --git a/app/(dashboard)/certificates/CertificatesClient.tsx b/app/(dashboard)/certificates/CertificatesClient.tsx index 25cee9ca..4c684374 100644 --- a/app/(dashboard)/certificates/CertificatesClient.tsx +++ b/app/(dashboard)/certificates/CertificatesClient.tsx @@ -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(null); - const [deleteCaCert, setDeleteCaCert] = useState(null); - const [issueCaCert, setIssueCaCert] = useState(null); + const [editCaCert, setEditCaCert] = useState(null); + const [deleteCaCert, setDeleteCaCert] = useState(null); + const [issueCaCert, setIssueCaCert] = useState(null); + const [manageIssuedCaCert, setManageIssuedCaCert] = useState(null); const acmeColumns = [ { id: 'name', @@ -445,7 +446,8 @@ export default function CertificatesClient({ acmeHosts, importedCerts, managedCe - 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. )} + setEditCaCert(ca)}> @@ -536,6 +568,14 @@ export default function CertificatesClient({ acmeHosts, importedCerts, managedCe onClose={() => setIssueCaCert(null)} /> )} + {manageIssuedCaCert && ( + setManageIssuedCaCert(null)} + /> + )} ); } diff --git a/app/(dashboard)/certificates/ca-actions.ts b/app/(dashboard)/certificates/ca-actions.ts index be080443..6f92e137 100644 --- a/app/(dashboard)/certificates/ca-actions.ts +++ b/app/(dashboard)/certificates/ca-actions.ts @@ -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 { - 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! }; +} diff --git a/app/(dashboard)/certificates/page.tsx b/app/(dashboard)/certificates/page.tsx index 1c524e08..b7a892a7 100644 --- a/app/(dashboard)/certificates/page.tsx +++ b/app/(dashboard)/certificates/page.tsx @@ -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, 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 }} /> ); diff --git a/app/api/instances/sync/route.ts b/app/api/instances/sync/route.ts index 3eb98e2c..d0a21fe9 100644 --- a/app/api/instances/sync/route.ts +++ b/app/api/instances/sync/route.ts @@ -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 }); } diff --git a/drizzle/0013_issued_client_certificates.sql b/drizzle/0013_issued_client_certificates.sql new file mode 100644 index 00000000..eb9ca12f --- /dev/null +++ b/drizzle/0013_issued_client_certificates.sql @@ -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`); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 587e1db6..402b13bf 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] -} \ No newline at end of file +} diff --git a/src/components/ca-certificates/CaCertDialogs.tsx b/src/components/ca-certificates/CaCertDialogs.tsx index e713791e..049c9a6a 100644 --- a/src/components/ca-certificates/CaCertDialogs.tsx +++ b/src/components/ca-certificates/CaCertDialogs.tsx @@ -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(issuedCerts); + const [error, setError] = useState(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 ( + + Issued Client Certificates + + + + Revoking a client certificate removes it from the trusted mTLS client certificate pool for hosts using{" "} + {cert.name}. + + {error && {error}} + {items.length === 0 ? ( + + No issued client certificates are currently tracked for this CA. Certificates issued from this UI will + appear here and can then be revoked individually. + + ) : ( + items.map((item) => { + const expired = new Date(item.valid_to).getTime() < Date.now(); + return ( + + + + + + + {item.common_name} + + + Serial {item.serial_number} + + + + + + + + + Issued {formatDateTime(item.created_at)} + + + SHA-256 {formatFingerprint(item.fingerprint_sha256)} + + {item.revoked_at ? ( + + Revoked {formatDateTime(item.revoked_at)} + + ) : ( + + + + )} + + + + ); + }) + )} + + + + + + + ); +} + export function DeleteCaCertDialog({ open, cert, diff --git a/src/lib/caddy.ts b/src/lib/caddy.ts index 0c9fbc83..27fbfb3d 100644 --- a/src/lib/caddy.ts +++ b/src/lib/caddy.ts @@ -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, - caCertMap: Map + caCertMap: Map, + issuedClientCertMap: Map ): Record | 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(); 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, autoManagedDomains: Set, mTlsDomainMap: Map, - caCertMap: Map + caCertMap: Map, + issuedClientCertMap: Map ) { const policies: Record[] = []; const readyCertificates = new Set(); @@ -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, 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, 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 | null { +function buildHealthChecksConfig(config: LoadBalancerRouteConfig): Record | null { const healthChecks: Record = {}; // Active health checks diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 0f38c3da..1a381980 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -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(), diff --git a/src/lib/instance-sync.ts b/src/lib/instance-sync.ts index 7047673b..3d4e5950 100644 --- a/src/lib/instance-sync.ts +++ b/src/lib/instance-sync.ts @@ -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; caCertificates: Array; + issuedClientCertificates: Array; accessLists: Array; accessListEntries: Array; proxyHosts: Array; @@ -232,9 +233,10 @@ export async function clearSyncedSetting(key: string): Promise { } export async function buildSyncPayload(): Promise { - 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 { 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 { 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(); } diff --git a/src/lib/models/issued-client-certificates.ts b/src/lib/models/issued-client-certificates.ts new file mode 100644 index 00000000..bf7acbb6 --- /dev/null +++ b/src/lib/models/issued-client-certificates.ts @@ -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 { + const rows = await db + .select() + .from(issuedClientCertificates) + .orderBy(desc(issuedClientCertificates.createdAt)); + return rows.map(parseIssuedClientCertificate); +} + +export async function getIssuedClientCertificate(id: number): Promise { + 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 { + 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 { + 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))!; +}