feat: add mTLS support for proxy hosts

- New `ca_certificates` table for reusable CA certs (migration 0011)
- CA cert CRUD model, server actions, and UI dialogs
- Proxy host create/edit dialogs include mTLS toggle + CA cert selection
- Caddy config generates `client_authentication` TLS policy blocks with
  `require_and_verify` mode for hosts with mTLS enabled
- CA certs sync to slave instances via instance-sync payload
- Certificates page shows CA Certificates section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
fuomag9
2026-03-05 20:29:55 +01:00
parent c407a01ca4
commit f3358c20cd
16 changed files with 821 additions and 31 deletions

View File

@@ -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({
<UpstreamDnsResolutionFields upstreamDnsResolution={initialData?.upstream_dns_resolution} />
<GeoBlockFields />
<WafFields value={initialData?.waf} />
<MtlsFields value={initialData?.mtls} caCertificates={caCertificates} />
</Stack>
</AppDialog>
);
@@ -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({
}}
/>
<WafFields value={host.waf} />
<MtlsFields value={host.mtls} caCertificates={caCertificates} />
</Stack>
</AppDialog>
);

View File

@@ -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<number[]>(value?.ca_certificate_ids ?? []);
function toggleId(id: number) {
setSelectedIds(prev =>
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
);
}
return (
<Box
sx={{
borderRadius: 2,
border: "1px solid",
borderColor: "info.main",
bgcolor: (theme) =>
theme.palette.mode === "dark" ? "rgba(2,136,209,0.06)" : "rgba(2,136,209,0.04)",
p: 2,
}}
>
<input type="hidden" name="mtls_present" value="1" />
<input type="hidden" name="mtls_enabled" value={enabled ? "true" : "false"} />
{enabled && selectedIds.map(id => (
<input key={id} type="hidden" name="mtls_ca_cert_id" value={String(id)} />
))}
{/* Header */}
<Stack direction="row" alignItems="flex-start" justifyContent="space-between" spacing={1}>
<Stack direction="row" alignItems="flex-start" spacing={1.5} flex={1} minWidth={0}>
<Box
sx={{
mt: 0.25,
width: 32,
height: 32,
borderRadius: 1.5,
bgcolor: "info.main",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<LockPersonIcon sx={{ fontSize: 18, color: "#fff" }} />
</Box>
<Box minWidth={0}>
<Typography variant="subtitle1" fontWeight={700} lineHeight={1.3}>
Mutual TLS (mTLS)
</Typography>
<Typography variant="body2" color="text.secondary" mt={0.25}>
Require clients to present a certificate signed by a trusted CA
</Typography>
</Box>
</Stack>
<Switch
checked={enabled}
onChange={(_, checked) => setEnabled(checked)}
sx={{ flexShrink: 0 }}
/>
</Stack>
<Collapse in={enabled} timeout="auto" unmountOnExit>
<Box mt={2}>
<Alert severity="info" sx={{ mb: 2 }}>
mTLS requires TLS to be configured on this host (certificate must be set).
</Alert>
<Typography
variant="caption"
color="text.secondary"
fontWeight={600}
sx={{ textTransform: "uppercase", letterSpacing: 0.5 }}
>
Trusted Client CA Certificates
</Typography>
{caCertificates.length === 0 ? (
<Typography variant="body2" color="text.secondary" mt={1}>
No CA certificates configured. Add them on the Certificates page.
</Typography>
) : (
<Stack mt={0.5}>
{caCertificates.map(ca => (
<FormControlLabel
key={ca.id}
control={
<Checkbox
checked={selectedIds.includes(ca.id)}
onChange={() => toggleId(ca.id)}
size="small"
/>
}
label={
<Typography variant="body2">{ca.name}</Typography>
}
/>
))}
</Stack>
)}
</Box>
</Collapse>
</Box>
);
}