feat: add L4 (TCP/UDP) proxy host support via caddy-l4

- New l4_proxy_hosts table and Drizzle migration (0015)
- Full CRUD model layer with validation, audit logging, and Caddy config
  generation (buildL4Servers integrating into buildCaddyDocument)
- Server actions, paginated list page, create/edit/delete dialogs
- L4 port manager sidecar (docker/l4-port-manager) that auto-recreates
  the caddy container when port mappings change via a trigger file
- Auto-detects Docker Compose project name from caddy container labels
- Supports both named-volume and bind-mount (COMPOSE_HOST_DIR) deployments
- getL4PortsStatus simplified: status file is sole source of truth,
  trigger files deleted after processing to prevent stuck 'Waiting' banner
- Navigation entry added (CableIcon)
- Tests: unit (entrypoint.sh invariants + validation), integration (ports
  lifecycle + caddy config), E2E (CRUD + functional routing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
fuomag9
2026-03-22 00:11:16 +01:00
parent fc680d4171
commit 3a4a4d51cf
26 changed files with 4766 additions and 3 deletions

View File

@@ -0,0 +1,466 @@
import { Accordion, AccordionDetails, AccordionSummary, Alert, Box, FormControlLabel, MenuItem, Stack, Switch, TextField, Typography } from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { useFormState } from "react-dom";
import { useEffect, useState } from "react";
import {
createL4ProxyHostAction,
deleteL4ProxyHostAction,
updateL4ProxyHostAction,
} from "@/app/(dashboard)/l4-proxy-hosts/actions";
import { INITIAL_ACTION_STATE } from "@/src/lib/actions";
import type { L4ProxyHost } from "@/src/lib/models/l4-proxy-hosts";
import { AppDialog } from "@/src/components/ui/AppDialog";
function L4HostForm({
formId,
formAction,
state,
initialData,
}: {
formId: string;
formAction: (formData: FormData) => void;
state: { status: string; message?: string };
initialData?: L4ProxyHost | null;
}) {
const [protocol, setProtocol] = useState(initialData?.protocol ?? "tcp");
const [matcherType, setMatcherType] = useState(initialData?.matcher_type ?? "none");
return (
<Stack component="form" id={formId} action={formAction} spacing={2.5}>
{state.status !== "idle" && state.message && (
<Alert severity={state.status === "error" ? "error" : "success"}>
{state.message}
</Alert>
)}
<input type="hidden" name="enabled_present" value="1" />
<FormControlLabel
control={
<Switch
name="enabled"
defaultChecked={initialData?.enabled ?? true}
color="success"
/>
}
label="Enabled"
/>
<TextField
name="name"
label="Name"
placeholder="PostgreSQL Proxy"
defaultValue={initialData?.name ?? ""}
required
fullWidth
/>
<TextField
select
name="protocol"
label="Protocol"
value={protocol}
onChange={(e) => setProtocol(e.target.value as "tcp" | "udp")}
fullWidth
>
<MenuItem value="tcp">TCP</MenuItem>
<MenuItem value="udp">UDP</MenuItem>
</TextField>
<TextField
name="listen_address"
label="Listen Address"
placeholder=":5432"
defaultValue={initialData?.listen_address ?? ""}
helperText="Format: :PORT or HOST:PORT. Make sure to expose this port in docker-compose.yml on the caddy service."
required
fullWidth
/>
<TextField
name="upstreams"
label="Upstreams"
placeholder={"10.0.0.1:5432\n10.0.0.2:5432"}
defaultValue={initialData?.upstreams.join("\n") ?? ""}
helperText="One per line in host:port format."
multiline
minRows={2}
required
fullWidth
/>
<TextField
select
name="matcher_type"
label="Matcher"
value={matcherType}
onChange={(e) => setMatcherType(e.target.value as "none" | "tls_sni" | "http_host" | "proxy_protocol")}
helperText="Match incoming connections before proxying. 'None' matches all connections on this port."
fullWidth
>
<MenuItem value="none">None (catch-all)</MenuItem>
<MenuItem value="tls_sni">TLS SNI</MenuItem>
<MenuItem value="http_host">HTTP Host</MenuItem>
<MenuItem value="proxy_protocol">Proxy Protocol</MenuItem>
</TextField>
{(matcherType === "tls_sni" || matcherType === "http_host") && (
<TextField
name="matcher_value"
label={matcherType === "tls_sni" ? "SNI Hostnames" : "HTTP Hostnames"}
placeholder="db.example.com, api.example.com"
defaultValue={initialData?.matcher_value?.join(", ") ?? ""}
helperText="Comma-separated list of hostnames to match."
required
fullWidth
/>
)}
{protocol === "tcp" && (
<FormControlLabel
control={
<Switch
name="tls_termination"
defaultChecked={initialData?.tls_termination ?? false}
/>
}
label="TLS Termination"
sx={{ ml: 0 }}
/>
)}
<FormControlLabel
control={
<Switch
name="proxy_protocol_receive"
defaultChecked={initialData?.proxy_protocol_receive ?? false}
/>
}
label="Accept inbound PROXY protocol"
sx={{ ml: 0 }}
/>
<TextField
select
name="proxy_protocol_version"
label="Send PROXY protocol to upstream"
defaultValue={initialData?.proxy_protocol_version ?? ""}
fullWidth
>
<MenuItem value="">None</MenuItem>
<MenuItem value="v1">v1</MenuItem>
<MenuItem value="v2">v2</MenuItem>
</TextField>
{/* Load Balancer */}
<Accordion variant="outlined" defaultExpanded={!!initialData?.load_balancer?.enabled}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle2">Load Balancer</Typography>
</AccordionSummary>
<AccordionDetails>
<Stack spacing={2}>
<input type="hidden" name="lb_present" value="1" />
<input type="hidden" name="lb_enabled_present" value="1" />
<FormControlLabel
control={<Switch name="lb_enabled" defaultChecked={initialData?.load_balancer?.enabled ?? false} />}
label="Enable Load Balancing"
/>
<TextField
select
name="lb_policy"
label="Policy"
defaultValue={initialData?.load_balancer?.policy ?? "random"}
fullWidth
size="small"
>
<MenuItem value="random">Random</MenuItem>
<MenuItem value="round_robin">Round Robin</MenuItem>
<MenuItem value="least_conn">Least Connections</MenuItem>
<MenuItem value="ip_hash">IP Hash</MenuItem>
<MenuItem value="first">First Available</MenuItem>
</TextField>
<TextField name="lb_try_duration" label="Try Duration" placeholder="5s" defaultValue={initialData?.load_balancer?.tryDuration ?? ""} size="small" fullWidth />
<TextField name="lb_try_interval" label="Try Interval" placeholder="250ms" defaultValue={initialData?.load_balancer?.tryInterval ?? ""} size="small" fullWidth />
<TextField name="lb_retries" label="Retries" type="number" defaultValue={initialData?.load_balancer?.retries ?? ""} size="small" fullWidth />
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>Active Health Check</Typography>
<input type="hidden" name="lb_active_health_enabled_present" value="1" />
<FormControlLabel
control={<Switch name="lb_active_health_enabled" defaultChecked={initialData?.load_balancer?.activeHealthCheck?.enabled ?? false} size="small" />}
label="Enable Active Health Check"
/>
<TextField name="lb_active_health_port" label="Health Check Port" type="number" defaultValue={initialData?.load_balancer?.activeHealthCheck?.port ?? ""} size="small" fullWidth />
<TextField name="lb_active_health_interval" label="Interval" placeholder="30s" defaultValue={initialData?.load_balancer?.activeHealthCheck?.interval ?? ""} size="small" fullWidth />
<TextField name="lb_active_health_timeout" label="Timeout" placeholder="5s" defaultValue={initialData?.load_balancer?.activeHealthCheck?.timeout ?? ""} size="small" fullWidth />
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>Passive Health Check</Typography>
<input type="hidden" name="lb_passive_health_enabled_present" value="1" />
<FormControlLabel
control={<Switch name="lb_passive_health_enabled" defaultChecked={initialData?.load_balancer?.passiveHealthCheck?.enabled ?? false} size="small" />}
label="Enable Passive Health Check"
/>
<TextField name="lb_passive_health_fail_duration" label="Fail Duration" placeholder="30s" defaultValue={initialData?.load_balancer?.passiveHealthCheck?.failDuration ?? ""} size="small" fullWidth />
<TextField name="lb_passive_health_max_fails" label="Max Fails" type="number" defaultValue={initialData?.load_balancer?.passiveHealthCheck?.maxFails ?? ""} size="small" fullWidth />
<TextField name="lb_passive_health_unhealthy_latency" label="Unhealthy Latency" placeholder="5s" defaultValue={initialData?.load_balancer?.passiveHealthCheck?.unhealthyLatency ?? ""} size="small" fullWidth />
</Stack>
</AccordionDetails>
</Accordion>
{/* DNS Resolver */}
<Accordion variant="outlined" defaultExpanded={!!initialData?.dns_resolver?.enabled}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle2">Custom DNS Resolvers</Typography>
</AccordionSummary>
<AccordionDetails>
<Stack spacing={2}>
<input type="hidden" name="dns_present" value="1" />
<input type="hidden" name="dns_enabled_present" value="1" />
<FormControlLabel
control={<Switch name="dns_enabled" defaultChecked={initialData?.dns_resolver?.enabled ?? false} />}
label="Enable Custom DNS"
/>
<TextField
name="dns_resolvers"
label="DNS Resolvers"
placeholder={"1.1.1.1\n8.8.8.8"}
defaultValue={initialData?.dns_resolver?.resolvers?.join("\n") ?? ""}
helperText="One per line. Used for upstream hostname resolution."
multiline
minRows={2}
size="small"
fullWidth
/>
<TextField
name="dns_fallbacks"
label="Fallback Resolvers"
placeholder="8.8.4.4"
defaultValue={initialData?.dns_resolver?.fallbacks?.join("\n") ?? ""}
helperText="Fallback DNS servers (one per line)."
multiline
minRows={1}
size="small"
fullWidth
/>
<TextField name="dns_timeout" label="Timeout" placeholder="5s" defaultValue={initialData?.dns_resolver?.timeout ?? ""} size="small" fullWidth />
</Stack>
</AccordionDetails>
</Accordion>
{/* Geo Blocking */}
<Accordion variant="outlined" defaultExpanded={!!initialData?.geoblock?.enabled}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle2">Geo Blocking</Typography>
</AccordionSummary>
<AccordionDetails>
<Stack spacing={2}>
<input type="hidden" name="geoblock_present" value="1" />
<FormControlLabel
control={<Switch name="geoblock_enabled" defaultChecked={initialData?.geoblock?.enabled ?? false} />}
label="Enable Geo Blocking"
/>
<TextField
select
name="geoblock_mode"
label="Mode"
defaultValue={initialData?.geoblock_mode ?? "merge"}
size="small"
fullWidth
>
<MenuItem value="merge">Merge with global settings</MenuItem>
<MenuItem value="override">Override global settings</MenuItem>
</TextField>
<Typography variant="caption" color="text.secondary">Block Rules</Typography>
<TextField name="geoblock_block_countries" label="Block Countries" placeholder="CN, RU, KP" defaultValue={initialData?.geoblock?.block_countries?.join(", ") ?? ""} helperText="ISO 3166-1 alpha-2 codes, comma-separated" size="small" fullWidth />
<TextField name="geoblock_block_continents" label="Block Continents" placeholder="AF, AS" defaultValue={initialData?.geoblock?.block_continents?.join(", ") ?? ""} helperText="AF, AN, AS, EU, NA, OC, SA" size="small" fullWidth />
<TextField name="geoblock_block_asns" label="Block ASNs" placeholder="12345, 67890" defaultValue={initialData?.geoblock?.block_asns?.join(", ") ?? ""} size="small" fullWidth />
<TextField name="geoblock_block_cidrs" label="Block CIDRs" placeholder="192.0.2.0/24" defaultValue={initialData?.geoblock?.block_cidrs?.join(", ") ?? ""} size="small" fullWidth />
<TextField name="geoblock_block_ips" label="Block IPs" placeholder="203.0.113.1" defaultValue={initialData?.geoblock?.block_ips?.join(", ") ?? ""} size="small" fullWidth />
<Typography variant="caption" color="text.secondary">Allow Rules (override blocks)</Typography>
<TextField name="geoblock_allow_countries" label="Allow Countries" placeholder="US, DE" defaultValue={initialData?.geoblock?.allow_countries?.join(", ") ?? ""} size="small" fullWidth />
<TextField name="geoblock_allow_continents" label="Allow Continents" placeholder="EU, NA" defaultValue={initialData?.geoblock?.allow_continents?.join(", ") ?? ""} size="small" fullWidth />
<TextField name="geoblock_allow_asns" label="Allow ASNs" placeholder="11111" defaultValue={initialData?.geoblock?.allow_asns?.join(", ") ?? ""} size="small" fullWidth />
<TextField name="geoblock_allow_cidrs" label="Allow CIDRs" placeholder="10.0.0.0/8" defaultValue={initialData?.geoblock?.allow_cidrs?.join(", ") ?? ""} size="small" fullWidth />
<TextField name="geoblock_allow_ips" label="Allow IPs" placeholder="1.2.3.4" defaultValue={initialData?.geoblock?.allow_ips?.join(", ") ?? ""} size="small" fullWidth />
<Alert severity="info" sx={{ mt: 1 }}>
At L4, geo blocking uses the client&apos;s direct IP address (no X-Forwarded-For support). Blocked connections are immediately closed.
</Alert>
</Stack>
</AccordionDetails>
</Accordion>
{/* Upstream DNS Resolution / Pinning */}
<Accordion variant="outlined" defaultExpanded={initialData?.upstream_dns_resolution?.enabled === true}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle2">Upstream DNS Pinning</Typography>
</AccordionSummary>
<AccordionDetails>
<Stack spacing={2}>
<input type="hidden" name="upstream_dns_resolution_present" value="1" />
<Typography variant="body2" color="text.secondary">
When enabled, upstream hostnames are resolved to IP addresses at config time, pinning DNS resolution.
</Typography>
<TextField
select
name="upstream_dns_resolution_mode"
label="Resolution Mode"
defaultValue={initialData?.upstream_dns_resolution?.enabled === true ? "enabled" : initialData?.upstream_dns_resolution?.enabled === false ? "disabled" : "inherit"}
size="small"
fullWidth
>
<MenuItem value="inherit">Inherit from global settings</MenuItem>
<MenuItem value="enabled">Enabled</MenuItem>
<MenuItem value="disabled">Disabled</MenuItem>
</TextField>
<TextField
select
name="upstream_dns_resolution_family"
label="Address Family Preference"
defaultValue={initialData?.upstream_dns_resolution?.family ?? "inherit"}
size="small"
fullWidth
>
<MenuItem value="inherit">Inherit from global settings</MenuItem>
<MenuItem value="both">Both (IPv6 + IPv4)</MenuItem>
<MenuItem value="ipv6">IPv6 only</MenuItem>
<MenuItem value="ipv4">IPv4 only</MenuItem>
</TextField>
</Stack>
</AccordionDetails>
</Accordion>
</Stack>
);
}
export function CreateL4HostDialog({
open,
onClose,
initialData,
}: {
open: boolean;
onClose: () => void;
initialData?: L4ProxyHost | null;
}) {
const [state, formAction] = useFormState(createL4ProxyHostAction, INITIAL_ACTION_STATE);
useEffect(() => {
if (state.status === "success") {
setTimeout(onClose, 1000);
}
}, [state.status, onClose]);
return (
<AppDialog
open={open}
onClose={onClose}
title={initialData ? "Duplicate L4 Proxy Host" : "Create L4 Proxy Host"}
maxWidth="sm"
submitLabel="Create"
onSubmit={() => {
(document.getElementById("create-l4-host-form") as HTMLFormElement)?.requestSubmit();
}}
>
<L4HostForm
formId="create-l4-host-form"
formAction={formAction}
state={state}
initialData={initialData ? { ...initialData, name: `${initialData.name} (Copy)` } : null}
/>
</AppDialog>
);
}
export function EditL4HostDialog({
open,
host,
onClose,
}: {
open: boolean;
host: L4ProxyHost;
onClose: () => void;
}) {
const [state, formAction] = useFormState(updateL4ProxyHostAction.bind(null, host.id), INITIAL_ACTION_STATE);
useEffect(() => {
if (state.status === "success") {
setTimeout(onClose, 1000);
}
}, [state.status, onClose]);
return (
<AppDialog
open={open}
onClose={onClose}
title="Edit L4 Proxy Host"
maxWidth="sm"
submitLabel="Save Changes"
onSubmit={() => {
(document.getElementById("edit-l4-host-form") as HTMLFormElement)?.requestSubmit();
}}
>
<L4HostForm
formId="edit-l4-host-form"
formAction={formAction}
state={state}
initialData={host}
/>
</AppDialog>
);
}
export function DeleteL4HostDialog({
open,
host,
onClose,
}: {
open: boolean;
host: L4ProxyHost;
onClose: () => void;
}) {
const [state, formAction] = useFormState(deleteL4ProxyHostAction.bind(null, host.id), INITIAL_ACTION_STATE);
useEffect(() => {
if (state.status === "success") {
setTimeout(onClose, 1000);
}
}, [state.status, onClose]);
return (
<AppDialog
open={open}
onClose={onClose}
title="Delete L4 Proxy Host"
maxWidth="sm"
submitLabel="Delete"
onSubmit={() => {
(document.getElementById("delete-l4-host-form") as HTMLFormElement)?.requestSubmit();
}}
>
<Stack component="form" id="delete-l4-host-form" action={formAction} spacing={2}>
{state.status !== "idle" && state.message && (
<Alert severity={state.status === "error" ? "error" : "success"}>
{state.message}
</Alert>
)}
<Typography variant="body1">
Are you sure you want to delete the L4 proxy host <strong>{host.name}</strong>?
</Typography>
<Typography variant="body2" color="text.secondary">
This will remove the configuration for:
</Typography>
<Box sx={{ pl: 2 }}>
<Typography variant="body2" color="text.secondary">
{"\u2022"} Protocol: {host.protocol.toUpperCase()}
</Typography>
<Typography variant="body2" color="text.secondary">
{"\u2022"} Listen: {host.listen_address}
</Typography>
<Typography variant="body2" color="text.secondary">
{"\u2022"} Upstreams: {host.upstreams.join(", ")}
</Typography>
</Box>
<Typography variant="body2" color="error.main" fontWeight={500}>
This action cannot be undone.
</Typography>
</Stack>
</AppDialog>
);
}

View File

@@ -0,0 +1,135 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Alert, Box, Button, Chip, CircularProgress, Collapse, Stack, Typography } from "@mui/material";
import SyncIcon from "@mui/icons-material/Sync";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import ErrorIcon from "@mui/icons-material/Error";
type PortsDiff = {
currentPorts: string[];
requiredPorts: string[];
needsApply: boolean;
};
type PortsStatus = {
state: "idle" | "pending" | "applying" | "applied" | "failed";
message?: string;
appliedAt?: string;
error?: string;
};
type PortsResponse = {
diff: PortsDiff;
status: PortsStatus;
error?: string;
};
export function L4PortsApplyBanner() {
const [data, setData] = useState<PortsResponse | null>(null);
const [applying, setApplying] = useState(false);
const [polling, setPolling] = useState(false);
const fetchStatus = useCallback(async () => {
try {
const res = await fetch("/api/l4-ports");
if (res.ok) {
setData(await res.json());
}
} catch {
// ignore fetch errors
}
}, []);
// Initial fetch and poll when pending/applying
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
useEffect(() => {
if (!data) return;
const shouldPoll = data.status.state === "pending" || data.status.state === "applying";
if (shouldPoll && !polling) {
setPolling(true);
const interval = setInterval(fetchStatus, 2000);
return () => { clearInterval(interval); setPolling(false); };
}
if (!shouldPoll && polling) {
setPolling(false);
}
}, [data, polling, fetchStatus]);
const handleApply = async () => {
setApplying(true);
try {
const res = await fetch("/api/l4-ports", { method: "POST" });
if (res.ok) {
await fetchStatus();
}
} catch {
// ignore
} finally {
setApplying(false);
}
};
if (!data) return null;
const { diff, status } = data;
// Show nothing if no changes needed and status is idle/applied
if (!diff.needsApply && (status.state === "idle" || status.state === "applied")) {
return null;
}
const stateIcon = {
idle: null,
pending: <CircularProgress size={16} />,
applying: <CircularProgress size={16} />,
applied: <CheckCircleIcon color="success" fontSize="small" />,
failed: <ErrorIcon color="error" fontSize="small" />,
}[status.state];
const severity = status.state === "failed" ? "error"
: status.state === "applied" ? "success"
: diff.needsApply ? "warning"
: "info";
return (
<Alert
severity={severity}
icon={stateIcon || undefined}
action={
diff.needsApply ? (
<Button
color="inherit"
size="small"
onClick={handleApply}
disabled={applying || status.state === "pending" || status.state === "applying"}
startIcon={applying ? <CircularProgress size={14} /> : <SyncIcon />}
>
Apply Ports
</Button>
) : undefined
}
>
<Stack spacing={0.5}>
{diff.needsApply ? (
<Typography variant="body2">
<strong>Docker port changes pending.</strong> The caddy container needs to be recreated to expose L4 ports.
{diff.requiredPorts.length > 0 && (
<> Required: {diff.requiredPorts.map(p => (
<Chip key={p} label={p} size="small" variant="outlined" sx={{ ml: 0.5, height: 20, fontSize: "0.7rem" }} />
))}</>
)}
</Typography>
) : (
<Typography variant="body2">{status.message}</Typography>
)}
{status.state === "failed" && status.error && (
<Typography variant="caption" color="error.main">{status.error}</Typography>
)}
</Stack>
</Alert>
);
}