diff --git a/app/(dashboard)/certificates/CertificatesClient.tsx b/app/(dashboard)/certificates/CertificatesClient.tsx index 91f27b76..cf0506b0 100644 --- a/app/(dashboard)/certificates/CertificatesClient.tsx +++ b/app/(dashboard)/certificates/CertificatesClient.tsx @@ -12,22 +12,30 @@ import { CardContent, Chip, Divider, + IconButton, Stack, TextField, + Tooltip, Typography, } from "@mui/material"; +import AddIcon from "@mui/icons-material/Add"; +import EditIcon from "@mui/icons-material/Edit"; +import DeleteIcon from "@mui/icons-material/Delete"; import { DataTable } from "@/src/components/ui/DataTable"; import { createCertificateAction, deleteCertificateAction, updateCertificateAction, } from "./actions"; -import type { AcmeHost, CertExpiryStatus, ImportedCertView, ManagedCertView } from "./page"; +import type { AcmeHost, CaCertificate, CertExpiryStatus, ImportedCertView, ManagedCertView } from "./page"; +import { useState } from "react"; +import { CreateCaCertDialog, EditCaCertDialog, DeleteCaCertDialog } from "@/src/components/ca-certificates/CaCertDialogs"; type Props = { acmeHosts: AcmeHost[]; importedCerts: ImportedCertView[]; managedCerts: ManagedCertView[]; + caCertificates: CaCertificate[]; acmePagination: { total: number; page: number; perPage: number }; }; @@ -55,7 +63,10 @@ function ExpiryChip({ return ; } -export default function CertificatesClient({ acmeHosts, importedCerts, managedCerts, acmePagination }: Props) { +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 acmeColumns = [ { id: 'name', @@ -420,6 +431,96 @@ export default function CertificatesClient({ acmeHosts, importedCerts, managedCe )} + + {/* Client CA Certificates */} + 0} disableGutters> + }> + + Client CA Certificates + + + + + + + + CA certificates used for mTLS — clients must present a certificate signed by one of these CAs. + + + + + {caCertificates.length === 0 ? ( + + No client CA certificates configured. + + ) : ( + {ca.name}, + }, + { + id: "created_at", + label: "Added", + render: (ca: CaCertificate) => ( + + {new Date(ca.created_at).toLocaleDateString()} + + ), + }, + { + id: "actions", + label: "", + render: (ca: CaCertificate) => ( + + + setEditCaCert(ca)}> + + + + + setDeleteCaCert(ca)}> + + + + + ), + }, + ]} + data={caCertificates} + keyField="id" + emptyMessage="No CA certificates found" + /> + )} + + + + + {/* CA Cert Dialogs */} + setCreateCaOpen(false)} /> + {editCaCert && ( + setEditCaCert(null)} + /> + )} + {deleteCaCert && ( + setDeleteCaCert(null)} + /> + )} ); } diff --git a/app/(dashboard)/certificates/ca-actions.ts b/app/(dashboard)/certificates/ca-actions.ts new file mode 100644 index 00000000..2c0fc4f6 --- /dev/null +++ b/app/(dashboard)/certificates/ca-actions.ts @@ -0,0 +1,52 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireAdmin } from "@/src/lib/auth"; +import { createCaCertificate, deleteCaCertificate, updateCaCertificate } from "@/src/lib/models/ca-certificates"; +import { X509Certificate } from "node:crypto"; + +function validatePem(pem: string): void { + try { + new X509Certificate(pem); + } catch { + throw new Error("Invalid certificate PEM: could not parse as X.509 certificate"); + } +} + +export async function createCaCertificateAction(formData: FormData) { + const session = await requireAdmin(); + const userId = Number(session.user.id); + const name = String(formData.get("name") ?? "").trim(); + const certificatePem = String(formData.get("certificate_pem") ?? "").trim(); + + if (!name) throw new Error("Name is required"); + if (!certificatePem) throw new Error("Certificate PEM is required"); + validatePem(certificatePem); + + await createCaCertificate({ name, certificate_pem: certificatePem }, userId); + revalidatePath("/certificates"); +} + +export async function updateCaCertificateAction(id: number, formData: FormData) { + const session = await requireAdmin(); + const userId = Number(session.user.id); + const name = formData.get("name") ? String(formData.get("name")).trim() : undefined; + const certificatePem = formData.get("certificate_pem") ? String(formData.get("certificate_pem")).trim() : undefined; + + if (certificatePem) { + validatePem(certificatePem); + } + + await updateCaCertificate(id, { + ...(name ? { name } : {}), + ...(certificatePem ? { certificate_pem: certificatePem } : {}) + }, userId); + revalidatePath("/certificates"); +} + +export async function deleteCaCertificateAction(id: number) { + const session = await requireAdmin(); + const userId = Number(session.user.id); + await deleteCaCertificate(id, userId); + revalidatePath("/certificates"); +} diff --git a/app/(dashboard)/certificates/page.tsx b/app/(dashboard)/certificates/page.tsx index 578903b6..1c524e08 100644 --- a/app/(dashboard)/certificates/page.tsx +++ b/app/(dashboard)/certificates/page.tsx @@ -5,6 +5,9 @@ import { isNull, isNotNull, count } from 'drizzle-orm'; 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'; + +export type { CaCertificate }; export type CertExpiryStatus = 'ok' | 'expiring_soon' | 'expired'; @@ -79,6 +82,8 @@ export default async function CertificatesPage({ searchParams }: PageProps) { const offset = (page - 1) * PER_PAGE; const acmeCertMap = scanAcmeCerts(); + const caCerts = await listCaCertificates(); + const [acmeRows, acmeTotal, certRows, usageRows] = await Promise.all([ db .select({ @@ -169,6 +174,7 @@ export default async function CertificatesPage({ searchParams }: PageProps) { acmeHosts={acmeHosts} importedCerts={importedCerts} managedCerts={managedCerts} + caCertificates={caCerts} acmePagination={{ total: acmeTotal, page, perPage: PER_PAGE }} /> ); diff --git a/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx b/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx index 3698a46f..e4c060ae 100644 --- a/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx +++ b/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx @@ -9,6 +9,7 @@ import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import type { AccessList } from "@/src/lib/models/access-lists"; import type { Certificate } from "@/src/lib/models/certificates"; import type { ProxyHost } from "@/src/lib/models/proxy-hosts"; +import type { CaCertificate } from "@/src/lib/models/ca-certificates"; import type { AuthentikSettings } from "@/src/lib/settings"; import { toggleProxyHostAction } from "./actions"; import { PageHeader } from "@/src/components/ui/PageHeader"; @@ -20,12 +21,13 @@ type Props = { hosts: ProxyHost[]; certificates: Certificate[]; accessLists: AccessList[]; + caCertificates: CaCertificate[]; authentikDefaults: AuthentikSettings | null; pagination: { total: number; page: number; perPage: number }; initialSearch: string; }; -export default function ProxyHostsClient({ hosts, certificates, accessLists, authentikDefaults, pagination, initialSearch }: Props) { +export default function ProxyHostsClient({ hosts, certificates, accessLists, caCertificates, authentikDefaults, pagination, initialSearch }: Props) { const [createOpen, setCreateOpen] = useState(false); const [duplicateHost, setDuplicateHost] = useState(null); const [editHost, setEditHost] = useState(null); @@ -170,6 +172,7 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, aut certificates={certificates} accessLists={accessLists} authentikDefaults={authentikDefaults} + caCertificates={caCertificates} /> {editHost && ( @@ -179,6 +182,7 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, aut onClose={() => setEditHost(null)} certificates={certificates} accessLists={accessLists} + caCertificates={caCertificates} /> )} diff --git a/app/(dashboard)/proxy-hosts/actions.ts b/app/(dashboard)/proxy-hosts/actions.ts index 88592c09..bc438b06 100644 --- a/app/(dashboard)/proxy-hosts/actions.ts +++ b/app/(dashboard)/proxy-hosts/actions.ts @@ -13,7 +13,8 @@ import { type DnsResolverInput, type UpstreamDnsResolutionInput, type GeoBlockMode, - type WafHostConfig + type WafHostConfig, + type MtlsConfig } from "@/src/lib/models/proxy-hosts"; import { getCertificate } from "@/src/lib/models/certificates"; import { getCloudflareSettings, type GeoBlockSettings } from "@/src/lib/settings"; @@ -474,6 +475,14 @@ function parseDnsResolverConfig(formData: FormData): DnsResolverInput | undefine return Object.keys(result).length > 0 ? result : undefined; } +function parseMtlsConfig(formData: FormData): MtlsConfig | null { + if (!formData.has("mtls_present")) return null; + const enabled = formData.get("mtls_enabled") === "true"; + if (!enabled) return null; + const ids = formData.getAll("mtls_ca_cert_id").map(Number).filter(n => Number.isFinite(n) && n > 0); + return { enabled, ca_certificate_ids: ids }; +} + function parseUpstreamDnsResolutionConfig(formData: FormData): UpstreamDnsResolutionInput | undefined { if (!formData.has("upstream_dns_resolution_present")) { return undefined; @@ -540,7 +549,8 @@ export async function createProxyHostAction( dns_resolver: parseDnsResolverConfig(formData), upstream_dns_resolution: parseUpstreamDnsResolutionConfig(formData), ...parseGeoBlockConfig(formData), - ...parseWafConfig(formData) + ...parseWafConfig(formData), + mtls: parseMtlsConfig(formData) }, userId ); @@ -612,7 +622,8 @@ export async function updateProxyHostAction( dns_resolver: parseDnsResolverConfig(formData), upstream_dns_resolution: parseUpstreamDnsResolutionConfig(formData), ...parseGeoBlockConfig(formData), - ...parseWafConfig(formData) + ...parseWafConfig(formData), + mtls: formData.has("mtls_present") ? parseMtlsConfig(formData) : undefined }, userId ); diff --git a/app/(dashboard)/proxy-hosts/page.tsx b/app/(dashboard)/proxy-hosts/page.tsx index 2c99d38e..31513835 100644 --- a/app/(dashboard)/proxy-hosts/page.tsx +++ b/app/(dashboard)/proxy-hosts/page.tsx @@ -1,6 +1,7 @@ import ProxyHostsClient from "./ProxyHostsClient"; import { listProxyHostsPaginated, countProxyHosts } from "@/src/lib/models/proxy-hosts"; import { listCertificates } from "@/src/lib/models/certificates"; +import { listCaCertificates } from "@/src/lib/models/ca-certificates"; import { listAccessLists } from "@/src/lib/models/access-lists"; import { getAuthentikSettings } from "@/src/lib/settings"; import { requireAdmin } from "@/src/lib/auth"; @@ -18,10 +19,11 @@ export default async function ProxyHostsPage({ searchParams }: PageProps) { const search = searchParam?.trim() || undefined; const offset = (page - 1) * PER_PAGE; - const [hosts, total, certificates, accessLists, authentikDefaults] = await Promise.all([ + const [hosts, total, certificates, caCertificates, accessLists, authentikDefaults] = await Promise.all([ listProxyHostsPaginated(PER_PAGE, offset, search), countProxyHosts(search), listCertificates(), + listCaCertificates(), listAccessLists(), getAuthentikSettings(), ]); @@ -30,6 +32,7 @@ export default async function ProxyHostsPage({ searchParams }: PageProps) { void; +}) { + const [isPending, startTransition] = useTransition(); + const formRef = useRef(null); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const formData = new FormData(formRef.current!); + startTransition(async () => { + await createCaCertificateAction(formData); + onClose(); + }); + } + + return ( + + Add Client CA Certificate +
+ + + + + + + + + + +
+
+ ); +} + +export function EditCaCertDialog({ + open, + cert, + onClose, +}: { + open: boolean; + cert: CaCertificate; + onClose: () => void; +}) { + const [isPending, startTransition] = useTransition(); + const formRef = useRef(null); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const formData = new FormData(formRef.current!); + startTransition(async () => { + await updateCaCertificateAction(cert.id, formData); + onClose(); + }); + } + + return ( + + Edit CA Certificate +
+ + + + + + + + + + +
+
+ ); +} + +export function DeleteCaCertDialog({ + open, + cert, + onClose, +}: { + open: boolean; + cert: CaCertificate; + onClose: () => void; +}) { + const [isPending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + function handleDelete() { + setError(null); + startTransition(async () => { + try { + await deleteCaCertificateAction(cert.id); + onClose(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to delete"); + } + }); + } + + return ( + + Delete CA Certificate + + + Delete CA certificate {cert.name}? This cannot be undone. + Proxy hosts using this CA for mTLS will stop requiring client certificates. + + {error && ( + + {error} + + )} + + + + + + + ); +} + diff --git a/src/components/proxy-hosts/HostDialogs.tsx b/src/components/proxy-hosts/HostDialogs.tsx index c5ba4620..d7453739 100644 --- a/src/components/proxy-hosts/HostDialogs.tsx +++ b/src/components/proxy-hosts/HostDialogs.tsx @@ -21,6 +21,8 @@ import { UpstreamDnsResolutionFields } from "./UpstreamDnsResolutionFields"; import { UpstreamInput } from "./UpstreamInput"; import { GeoBlockFields } from "./GeoBlockFields"; import { WafFields } from "./WafFields"; +import { MtlsFields } from "./MtlsConfig"; +import type { CaCertificate } from "@/src/lib/models/ca-certificates"; export function CreateHostDialog({ open, @@ -28,7 +30,8 @@ export function CreateHostDialog({ certificates, accessLists, authentikDefaults, - initialData + initialData, + caCertificates = [] }: { open: boolean; onClose: () => void; @@ -36,6 +39,7 @@ export function CreateHostDialog({ accessLists: AccessList[]; authentikDefaults: AuthentikSettings | null; initialData?: ProxyHost | null; + caCertificates?: CaCertificate[]; }) { const [state, formAction] = useFormState(createProxyHostAction, INITIAL_ACTION_STATE); @@ -130,6 +134,7 @@ export function CreateHostDialog({ + ); @@ -140,13 +145,15 @@ export function EditHostDialog({ host, onClose, certificates, - accessLists + accessLists, + caCertificates = [] }: { open: boolean; host: ProxyHost; onClose: () => void; certificates: Certificate[]; accessLists: AccessList[]; + caCertificates?: CaCertificate[]; }) { const [state, formAction] = useFormState(updateProxyHostAction.bind(null, host.id), INITIAL_ACTION_STATE); @@ -234,6 +241,7 @@ export function EditHostDialog({ }} /> + ); diff --git a/src/components/proxy-hosts/MtlsConfig.tsx b/src/components/proxy-hosts/MtlsConfig.tsx new file mode 100644 index 00000000..69a189f8 --- /dev/null +++ b/src/components/proxy-hosts/MtlsConfig.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { + Alert, + Box, + Checkbox, + Collapse, + FormControlLabel, + Stack, + Switch, + Typography, +} from "@mui/material"; +import LockPersonIcon from "@mui/icons-material/LockPerson"; +import { useState } from "react"; +import type { CaCertificate } from "@/src/lib/models/ca-certificates"; +import type { MtlsConfig } from "@/src/lib/models/proxy-hosts"; + +type Props = { + value?: MtlsConfig | null; + caCertificates: CaCertificate[]; +}; + +export function MtlsFields({ value, caCertificates }: Props) { + const [enabled, setEnabled] = useState(value?.enabled ?? false); + const [selectedIds, setSelectedIds] = useState(value?.ca_certificate_ids ?? []); + + function toggleId(id: number) { + setSelectedIds(prev => + prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id] + ); + } + + return ( + + theme.palette.mode === "dark" ? "rgba(2,136,209,0.06)" : "rgba(2,136,209,0.04)", + p: 2, + }} + > + + + {enabled && selectedIds.map(id => ( + + ))} + + {/* Header */} + + + + + + + + Mutual TLS (mTLS) + + + Require clients to present a certificate signed by a trusted CA + + + + setEnabled(checked)} + sx={{ flexShrink: 0 }} + /> + + + + + + mTLS requires TLS to be configured on this host (certificate must be set). + + + + Trusted Client CA Certificates + + + {caCertificates.length === 0 ? ( + + No CA certificates configured. Add them on the Certificates page. + + ) : ( + + {caCertificates.map(ca => ( + toggleId(ca.id)} + size="small" + /> + } + label={ + {ca.name} + } + /> + ))} + + )} + + + + ); +} diff --git a/src/lib/caddy.ts b/src/lib/caddy.ts index fe59e059..c4c1909b 100644 --- a/src/lib/caddy.ts +++ b/src/lib/caddy.ts @@ -27,9 +27,10 @@ import { syncInstances } from "./instance-sync"; import { accessListEntries, certificates, + caCertificates, proxyHosts } from "./db/schema"; -import { type GeoBlockMode, type WafHostConfig } from "./models/proxy-hosts"; +import { type GeoBlockMode, type WafHostConfig, type MtlsConfig } from "./models/proxy-hosts"; const CERTS_DIR = process.env.CERTS_DIRECTORY || join(process.cwd(), "data", "certs"); mkdirSync(CERTS_DIR, { recursive: true, mode: 0o700 }); @@ -687,6 +688,12 @@ function writeCertificateFiles(cert: CertificateRow) { return { certificate_file: certPath, key_file: keyPath }; } +function writeCaCertFile(caCert: { id: number; certificatePem: string }): string { + const caPath = join(CERTS_DIR, `ca-certificate-${caCert.id}.pem`); + writeFileSync(caPath, caCert.certificatePem, { encoding: "utf-8", mode: 0o600 }); + return caPath; +} + function collectCertificateUsage(rows: ProxyHostRow[], certificates: Map) { const usage = new Map(); const autoManagedDomains = new Set(); @@ -1311,10 +1318,41 @@ async function buildProxyRoutes( return routes; } +function buildClientAuthentication( + domains: string[], + mTlsDomainMap: Map, + caCertMap: Map +): Record | null { + // Collect all CA cert IDs for any domain in this policy that has mTLS + const caCertIds = new Set(); + for (const domain of domains) { + const ids = mTlsDomainMap.get(domain.toLowerCase()); + if (ids) { + for (const id of ids) caCertIds.add(id); + } + } + if (caCertIds.size === 0) return null; + + const pemFiles: string[] = []; + for (const id of caCertIds) { + const ca = caCertMap.get(id); + if (!ca) continue; + pemFiles.push(writeCaCertFile(ca)); + } + if (pemFiles.length === 0) return null; + + return { + trusted_ca_certs_pem_files: pemFiles, + mode: "require_and_verify" + }; +} + function buildTlsConnectionPolicies( usage: Map, managedCertificatesWithAutomation: Set, - autoManagedDomains: Set + autoManagedDomains: Set, + mTlsDomainMap: Map, + caCertMap: Map ) { const policies: Record[] = []; const readyCertificates = new Set(); @@ -1322,11 +1360,26 @@ function buildTlsConnectionPolicies( // Add policy for auto-managed domains (certificate_id = null) if (autoManagedDomains.size > 0) { const domains = Array.from(autoManagedDomains); - policies.push({ - match: { - sni: domains + const clientAuth = buildClientAuthentication(domains, mTlsDomainMap, caCertMap); + + if (clientAuth) { + // Split: mTLS domains get their own policy, non-mTLS get another + const mTlsDomains = domains.filter(d => mTlsDomainMap.has(d)); + const nonMTlsDomains = domains.filter(d => !mTlsDomainMap.has(d)); + + if (mTlsDomains.length > 0) { + const mTlsAuth = buildClientAuthentication(mTlsDomains, mTlsDomainMap, caCertMap); + policies.push({ + match: { sni: mTlsDomains }, + client_authentication: mTlsAuth + }); } - }); + if (nonMTlsDomains.length > 0) { + policies.push({ match: { sni: nonMTlsDomains } }); + } + } else { + policies.push({ match: { sni: domains } }); + } } for (const [id, entry] of usage.entries()) { @@ -1340,12 +1393,28 @@ function buildTlsConnectionPolicies( if (!files) { continue; } - policies.push({ - match: { - sni: domains - }, - certificates: [files] - }); + + const mTlsDomains = domains.filter(d => mTlsDomainMap.has(d)); + const nonMTlsDomains = domains.filter(d => !mTlsDomainMap.has(d)); + + if (mTlsDomains.length > 0) { + const mTlsAuth = buildClientAuthentication(mTlsDomains, mTlsDomainMap, caCertMap); + policies.push({ + match: { sni: mTlsDomains }, + certificates: [files], + ...(mTlsAuth ? { client_authentication: mTlsAuth } : {}) + }); + } + if (nonMTlsDomains.length > 0) { + policies.push({ + match: { sni: nonMTlsDomains }, + certificates: [files] + }); + } + if (mTlsDomains.length === 0 && nonMTlsDomains.length === 0) { + // all domains handled above + } + readyCertificates.add(id); continue; } @@ -1354,11 +1423,21 @@ function buildTlsConnectionPolicies( if (!managedCertificatesWithAutomation.has(id)) { continue; } - policies.push({ - match: { - sni: domains - } - }); + + const mTlsDomains = domains.filter(d => mTlsDomainMap.has(d)); + const nonMTlsDomains = domains.filter(d => !mTlsDomainMap.has(d)); + + if (mTlsDomains.length > 0) { + const mTlsAuth = buildClientAuthentication(mTlsDomains, mTlsDomainMap, caCertMap); + policies.push({ + match: { sni: mTlsDomains }, + ...(mTlsAuth ? { client_authentication: mTlsAuth } : {}) + }); + } + if (nonMTlsDomains.length > 0) { + policies.push({ match: { sni: nonMTlsDomains } }); + } + readyCertificates.add(id); } } @@ -1506,7 +1585,7 @@ async function buildTlsAutomation( } async function buildCaddyDocument() { - const [proxyHostRecords, certRows, accessListEntryRecords] = await Promise.all([ + const [proxyHostRecords, certRows, accessListEntryRecords, caCertRows] = await Promise.all([ db .select({ id: proxyHosts.id, @@ -1543,7 +1622,13 @@ async function buildCaddyDocument() { username: accessListEntries.username, passwordHash: accessListEntries.passwordHash }) - .from(accessListEntries) + .from(accessListEntries), + db + .select({ + id: caCertificates.id, + certificatePem: caCertificates.certificatePem + }) + .from(caCertificates) ]); const proxyHostRows: ProxyHostRow[] = proxyHostRecords.map((h) => ({ @@ -1581,6 +1666,7 @@ async function buildCaddyDocument() { })); const certificateMap = new Map(certRowsMapped.map((cert) => [cert.id, cert])); + const caCertMap = new Map(caCertRows.map((ca) => [ca.id, ca])); const accessMap = accessListEntryRows.reduce>((map, entry) => { if (!map.has(entry.access_list_id)) { map.set(entry.access_list_id, []); @@ -1589,6 +1675,18 @@ async function buildCaddyDocument() { return map; }, new Map()); + // Build domain → CA cert IDs map for mTLS-enabled hosts + const mTlsDomainMap = new Map(); + for (const row of proxyHostRows) { + if (!row.enabled) continue; + const meta = parseJson<{ mtls?: MtlsConfig }>(row.meta, {}); + if (!meta.mtls?.enabled || !meta.mtls.ca_certificate_ids?.length) continue; + const domains = parseJson(row.domains, []).map(d => d.trim().toLowerCase()).filter(Boolean); + for (const domain of domains) { + mTlsDomainMap.set(domain, meta.mtls.ca_certificate_ids); + } + } + const { usage: certificateUsage, autoManagedDomains } = collectCertificateUsage(proxyHostRows, certificateMap); const [generalSettings, dnsSettings, upstreamDnsResolutionSettings, globalGeoBlock, globalWaf] = await Promise.all([ getGeneralSettings(), @@ -1604,7 +1702,9 @@ async function buildCaddyDocument() { const { policies: tlsConnectionPolicies, readyCertificates } = buildTlsConnectionPolicies( certificateUsage, managedCertificateIds, - autoManagedDomains + autoManagedDomains, + mTlsDomainMap, + caCertMap ); const httpRoutes: CaddyHttpRoute[] = await buildProxyRoutes( diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 655823b4..79fac497 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -128,6 +128,15 @@ export const certificates = sqliteTable("certificates", { updatedAt: text("updated_at").notNull() }); +export const caCertificates = sqliteTable("ca_certificates", { + id: integer("id").primaryKey({ autoIncrement: true }), + name: text("name").notNull(), + certificatePem: text("certificate_pem").notNull(), + createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull() +}); + 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 e5dbbd66..7047673b 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, certificates, proxyHosts } from "./db/schema"; +import { accessListEntries, accessLists, caCertificates, certificates, proxyHosts } from "./db/schema"; import { getSetting, setSetting } from "./settings"; import { recordInstanceSyncResult, updateInstance } from "./models/instances"; import { decryptSecret, encryptSecret, isEncryptedSecret } from "./secret"; @@ -23,6 +23,7 @@ export type SyncPayload = { settings: SyncSettings; data: { certificates: Array; + caCertificates: Array; accessLists: Array; accessListEntries: Array; proxyHosts: Array; @@ -231,8 +232,9 @@ export async function clearSyncedSetting(key: string): Promise { } export async function buildSyncPayload(): Promise { - const [certRows, accessListRows, accessEntryRows, proxyRows] = await Promise.all([ + const [certRows, caCertRows, accessListRows, accessEntryRows, proxyRows] = await Promise.all([ db.select().from(certificates), + db.select().from(caCertificates), db.select().from(accessLists), db.select().from(accessListEntries), db.select().from(proxyHosts) @@ -260,6 +262,11 @@ export async function buildSyncPayload(): Promise { createdBy: null })); + const sanitizedCaCertificates = caCertRows.map((row) => ({ + ...row, + createdBy: null + })); + const sanitizedProxyHosts = proxyRows.map((row) => ({ ...row, ownerUserId: null @@ -270,6 +277,7 @@ export async function buildSyncPayload(): Promise { settings, data: { certificates: sanitizedCertificates, + caCertificates: sanitizedCaCertificates, accessLists: sanitizedAccessLists, accessListEntries: accessEntryRows, proxyHosts: sanitizedProxyHosts @@ -411,10 +419,14 @@ export async function applySyncPayload(payload: SyncPayload) { tx.delete(accessListEntries).run(); tx.delete(accessLists).run(); tx.delete(certificates).run(); + tx.delete(caCertificates).run(); if (payload.data.certificates.length > 0) { tx.insert(certificates).values(payload.data.certificates).run(); } + if (payload.data.caCertificates && payload.data.caCertificates.length > 0) { + tx.insert(caCertificates).values(payload.data.caCertificates).run(); + } if (payload.data.accessLists.length > 0) { tx.insert(accessLists).values(payload.data.accessLists).run(); } diff --git a/src/lib/models/ca-certificates.ts b/src/lib/models/ca-certificates.ts new file mode 100644 index 00000000..6ec5d4aa --- /dev/null +++ b/src/lib/models/ca-certificates.ts @@ -0,0 +1,135 @@ +import db, { nowIso, toIso } from "../db"; +import { logAuditEvent } from "../audit"; +import { applyCaddyConfig } from "../caddy"; +import { caCertificates, proxyHosts } from "../db/schema"; +import { desc, eq } from "drizzle-orm"; + +function tryParseJson(value: string | null | undefined, fallback: T): T { + if (!value) return fallback; + try { + return JSON.parse(value) as T; + } catch { + return fallback; + } +} + +export type CaCertificate = { + id: number; + name: string; + certificate_pem: string; + created_at: string; + updated_at: string; +}; + +export type CaCertificateInput = { + name: string; + certificate_pem: string; +}; + +type CaCertificateRow = typeof caCertificates.$inferSelect; + +function parseCaCertificate(row: CaCertificateRow): CaCertificate { + return { + id: row.id, + name: row.name, + certificate_pem: row.certificatePem, + created_at: toIso(row.createdAt)!, + updated_at: toIso(row.updatedAt)! + }; +} + +export async function listCaCertificates(): Promise { + const rows = await db.select().from(caCertificates).orderBy(desc(caCertificates.createdAt)); + return rows.map(parseCaCertificate); +} + +export async function getCaCertificate(id: number): Promise { + const cert = await db.query.caCertificates.findFirst({ + where: (table, { eq }) => eq(table.id, id) + }); + return cert ? parseCaCertificate(cert) : null; +} + +export async function createCaCertificate(input: CaCertificateInput, actorUserId: number): Promise { + const now = nowIso(); + const [record] = await db + .insert(caCertificates) + .values({ + name: input.name.trim(), + certificatePem: input.certificate_pem.trim(), + createdBy: actorUserId, + createdAt: now, + updatedAt: now + }) + .returning(); + + if (!record) { + throw new Error("Failed to create CA certificate"); + } + + logAuditEvent({ + userId: actorUserId, + action: "create", + entityType: "ca_certificate", + entityId: record.id, + summary: `Created CA certificate ${input.name}` + }); + await applyCaddyConfig(); + return (await getCaCertificate(record.id))!; +} + +export async function updateCaCertificate(id: number, input: Partial, actorUserId: number): Promise { + const existing = await getCaCertificate(id); + if (!existing) { + throw new Error("CA certificate not found"); + } + + const now = nowIso(); + await db + .update(caCertificates) + .set({ + name: input.name?.trim() ?? existing.name, + certificatePem: input.certificate_pem?.trim() ?? existing.certificate_pem, + updatedAt: now + }) + .where(eq(caCertificates.id, id)); + + logAuditEvent({ + userId: actorUserId, + action: "update", + entityType: "ca_certificate", + entityId: id, + summary: `Updated CA certificate ${input.name ?? existing.name}` + }); + await applyCaddyConfig(); + return (await getCaCertificate(id))!; +} + +export async function deleteCaCertificate(id: number, actorUserId: number): Promise { + const existing = await getCaCertificate(id); + if (!existing) { + throw new Error("CA certificate not found"); + } + + // Check if any proxy hosts reference this CA cert + const allHosts = await db.select({ meta: proxyHosts.meta, name: proxyHosts.name }).from(proxyHosts); + const referencing = allHosts.filter((host) => { + const meta = tryParseJson<{ mtls?: { enabled?: boolean; ca_certificate_ids?: number[] } }>(host.meta, {}); + return meta.mtls?.enabled && meta.mtls.ca_certificate_ids?.includes(id); + }); + + if (referencing.length > 0) { + const names = referencing.map((h) => h.name).join(", "); + throw new Error(`CA certificate is in use by proxy host(s): ${names}`); + } + + await db.delete(caCertificates).where(eq(caCertificates.id, id)); + logAuditEvent({ + userId: actorUserId, + action: "delete", + entityType: "ca_certificate", + entityId: id, + summary: `Deleted CA certificate ${existing.name}` + }); + await applyCaddyConfig(); +} diff --git a/src/lib/models/proxy-hosts.ts b/src/lib/models/proxy-hosts.ts index d2aa0a47..b8b1470d 100644 --- a/src/lib/models/proxy-hosts.ts +++ b/src/lib/models/proxy-hosts.ts @@ -200,6 +200,11 @@ type ProxyHostAuthentikMeta = { protected_paths?: string[]; }; +export type MtlsConfig = { + enabled: boolean; + ca_certificate_ids: number[]; +}; + type ProxyHostMeta = { custom_reverse_proxy_json?: string; custom_pre_handlers_json?: string; @@ -210,6 +215,7 @@ type ProxyHostMeta = { geoblock?: GeoBlockSettings; geoblock_mode?: GeoBlockMode; waf?: WafHostConfig; + mtls?: MtlsConfig; }; export type ProxyHost = { @@ -237,6 +243,7 @@ export type ProxyHost = { geoblock: GeoBlockSettings | null; geoblock_mode: GeoBlockMode; waf: WafHostConfig | null; + mtls: MtlsConfig | null; }; export type ProxyHostInput = { @@ -261,6 +268,7 @@ export type ProxyHostInput = { geoblock?: GeoBlockSettings | null; geoblock_mode?: GeoBlockMode; waf?: WafHostConfig | null; + mtls?: MtlsConfig | null; }; type ProxyHostRow = typeof proxyHosts.$inferSelect; @@ -538,6 +546,10 @@ function serializeMeta(meta: ProxyHostMeta | null | undefined) { normalized.waf = meta.waf; } + if (meta.mtls && meta.mtls.enabled) { + normalized.mtls = meta.mtls; + } + return Object.keys(normalized).length > 0 ? JSON.stringify(normalized) : null; } @@ -557,6 +569,7 @@ function parseMeta(value: string | null): ProxyHostMeta { geoblock: parsed.geoblock, geoblock_mode: parsed.geoblock_mode, waf: parsed.waf, + mtls: parsed.mtls, }; } catch (error) { console.warn("Failed to parse proxy host meta", error); @@ -1023,6 +1036,14 @@ function buildMeta(existing: ProxyHostMeta, input: Partial): str } } + if (input.mtls !== undefined) { + if (input.mtls && input.mtls.enabled) { + next.mtls = input.mtls; + } else { + delete next.mtls; + } + } + return serializeMeta(next); } @@ -1349,6 +1370,7 @@ function parseProxyHost(row: ProxyHostRow): ProxyHost { geoblock: hydrateGeoBlock(meta.geoblock), geoblock_mode: meta.geoblock_mode ?? "merge", waf: meta.waf ?? null, + mtls: meta.mtls ?? null, }; } @@ -1462,6 +1484,7 @@ export async function updateProxyHost(id: number, input: Partial geoblock: dehydrateGeoBlock(existing.geoblock), ...(existing.geoblock_mode !== "merge" ? { geoblock_mode: existing.geoblock_mode } : {}), ...(existing.waf ? { waf: existing.waf } : {}), + ...(existing.mtls ? { mtls: existing.mtls } : {}), }; const meta = buildMeta(existingMeta, input);