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:
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+215
-3
@@ -23,7 +23,7 @@ import {
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import db, { nowIso } from "./db";
|
||||
import { isNull } from "drizzle-orm";
|
||||
import { eq, isNull } from "drizzle-orm";
|
||||
import { config } from "./config";
|
||||
import {
|
||||
getCloudflareSettings,
|
||||
@@ -47,7 +47,8 @@ import {
|
||||
certificates,
|
||||
caCertificates,
|
||||
issuedClientCertificates,
|
||||
proxyHosts
|
||||
proxyHosts,
|
||||
l4ProxyHosts
|
||||
} from "./db/schema";
|
||||
import { type GeoBlockMode, type WafHostConfig, type MtlsConfig, type RedirectRule, type RewriteConfig } from "./models/proxy-hosts";
|
||||
import { buildClientAuthentication, groupMtlsDomainsByCaSet } from "./caddy-mtls";
|
||||
@@ -117,6 +118,14 @@ type ProxyHostMeta = {
|
||||
rewrite?: RewriteConfig;
|
||||
};
|
||||
|
||||
type L4Meta = {
|
||||
load_balancer?: LoadBalancerMeta;
|
||||
dns_resolver?: DnsResolverMeta;
|
||||
upstream_dns_resolution?: UpstreamDnsResolutionMeta;
|
||||
geoblock?: GeoBlockSettings;
|
||||
geoblock_mode?: GeoBlockMode;
|
||||
};
|
||||
|
||||
type ProxyHostAuthentikMeta = {
|
||||
enabled?: boolean;
|
||||
outpost_domain?: string;
|
||||
@@ -1297,6 +1306,204 @@ async function buildTlsAutomation(
|
||||
};
|
||||
}
|
||||
|
||||
async function buildL4Servers(): Promise<Record<string, unknown> | null> {
|
||||
const l4Hosts = await db
|
||||
.select()
|
||||
.from(l4ProxyHosts)
|
||||
.where(eq(l4ProxyHosts.enabled, true));
|
||||
|
||||
if (l4Hosts.length === 0) return null;
|
||||
|
||||
const [globalDnsSettings, globalUpstreamDnsResolutionSettings, globalGeoBlock] = await Promise.all([
|
||||
getDnsSettings(),
|
||||
getUpstreamDnsResolutionSettings(),
|
||||
getGeoBlockSettings(),
|
||||
]);
|
||||
|
||||
// Group hosts by listen address — multiple hosts on the same port share routes in one server
|
||||
const serverMap = new Map<string, typeof l4Hosts>();
|
||||
for (const host of l4Hosts) {
|
||||
const key = host.listenAddress;
|
||||
if (!serverMap.has(key)) serverMap.set(key, []);
|
||||
serverMap.get(key)!.push(host);
|
||||
}
|
||||
|
||||
const servers: Record<string, unknown> = {};
|
||||
let serverIdx = 0;
|
||||
for (const [listenAddr, hosts] of serverMap) {
|
||||
const routes: Record<string, unknown>[] = [];
|
||||
|
||||
for (const host of hosts) {
|
||||
const route: Record<string, unknown> = {};
|
||||
|
||||
// Build matchers
|
||||
const matcherType = host.matcherType as string;
|
||||
const matcherValues = host.matcherValue ? parseJson<string[]>(host.matcherValue, []) : [];
|
||||
|
||||
if (matcherType === "tls_sni" && matcherValues.length > 0) {
|
||||
route.match = [{ tls: { sni: matcherValues } }];
|
||||
} else if (matcherType === "http_host" && matcherValues.length > 0) {
|
||||
route.match = [{ http: [{ host: matcherValues }] }];
|
||||
} else if (matcherType === "proxy_protocol") {
|
||||
route.match = [{ proxy_protocol: {} }];
|
||||
}
|
||||
// "none" = no match block (catch-all)
|
||||
|
||||
// Parse per-host meta for load balancing, DNS resolver, and upstream DNS resolution
|
||||
const meta = parseJson<L4Meta>(host.meta, {});
|
||||
|
||||
// Load balancer config
|
||||
const lbMeta = meta.load_balancer;
|
||||
let lbConfig: LoadBalancerRouteConfig | null = null;
|
||||
if (lbMeta?.enabled) {
|
||||
lbConfig = {
|
||||
enabled: true,
|
||||
policy: lbMeta.policy ?? "random",
|
||||
policyHeaderField: null,
|
||||
policyCookieName: null,
|
||||
policyCookieSecret: null,
|
||||
tryDuration: lbMeta.try_duration ?? null,
|
||||
tryInterval: lbMeta.try_interval ?? null,
|
||||
retries: lbMeta.retries ?? null,
|
||||
activeHealthCheck: lbMeta.active_health_check?.enabled ? {
|
||||
enabled: true,
|
||||
uri: null,
|
||||
port: lbMeta.active_health_check.port ?? null,
|
||||
interval: lbMeta.active_health_check.interval ?? null,
|
||||
timeout: lbMeta.active_health_check.timeout ?? null,
|
||||
status: null,
|
||||
body: null,
|
||||
} : null,
|
||||
passiveHealthCheck: lbMeta.passive_health_check?.enabled ? {
|
||||
enabled: true,
|
||||
failDuration: lbMeta.passive_health_check.fail_duration ?? null,
|
||||
maxFails: lbMeta.passive_health_check.max_fails ?? null,
|
||||
unhealthyStatus: null,
|
||||
unhealthyLatency: lbMeta.passive_health_check.unhealthy_latency ?? null,
|
||||
} : null,
|
||||
};
|
||||
}
|
||||
|
||||
// DNS resolver config
|
||||
const dnsConfig = parseDnsResolverConfig(meta.dns_resolver);
|
||||
|
||||
// Upstream DNS resolution (pinning)
|
||||
const hostDnsResolution = parseUpstreamDnsResolutionConfig(meta.upstream_dns_resolution);
|
||||
const effectiveDnsResolution = resolveEffectiveUpstreamDnsResolution(
|
||||
globalUpstreamDnsResolutionSettings,
|
||||
hostDnsResolution
|
||||
);
|
||||
|
||||
// Build handler chain
|
||||
const handlers: Record<string, unknown>[] = [];
|
||||
|
||||
// 1. Receive inbound proxy protocol
|
||||
if (host.proxyProtocolReceive) {
|
||||
handlers.push({ handler: "proxy_protocol" });
|
||||
}
|
||||
|
||||
// 2. TLS termination
|
||||
if (host.tlsTermination) {
|
||||
handlers.push({ handler: "tls" });
|
||||
}
|
||||
|
||||
// 3. Proxy handler
|
||||
const upstreams = parseJson<string[]>(host.upstreams, []);
|
||||
|
||||
// Resolve upstream hostnames to IPs if DNS pinning is enabled
|
||||
let resolvedDials = upstreams;
|
||||
if (effectiveDnsResolution.enabled) {
|
||||
const resolver = new Resolver();
|
||||
const lookupServers = getLookupServers(dnsConfig, globalDnsSettings);
|
||||
if (lookupServers.length > 0) {
|
||||
try { resolver.setServers(lookupServers); } catch { /* ignore invalid servers */ }
|
||||
}
|
||||
const timeoutMs = getLookupTimeoutMs(dnsConfig, globalDnsSettings);
|
||||
|
||||
const pinned: string[] = [];
|
||||
for (const upstream of upstreams) {
|
||||
const colonIdx = upstream.lastIndexOf(":");
|
||||
if (colonIdx <= 0) { pinned.push(upstream); continue; }
|
||||
const hostPart = upstream.substring(0, colonIdx);
|
||||
const portPart = upstream.substring(colonIdx + 1);
|
||||
if (isIP(hostPart) !== 0) { pinned.push(upstream); continue; }
|
||||
try {
|
||||
const addresses = await resolveHostnameAddresses(resolver, hostPart, effectiveDnsResolution.family, timeoutMs);
|
||||
for (const addr of addresses) {
|
||||
pinned.push(addr.includes(":") ? `[${addr}]:${portPart}` : `${addr}:${portPart}`);
|
||||
}
|
||||
} catch {
|
||||
pinned.push(upstream);
|
||||
}
|
||||
}
|
||||
resolvedDials = pinned;
|
||||
}
|
||||
|
||||
const proxyHandler: Record<string, unknown> = {
|
||||
handler: "proxy",
|
||||
upstreams: resolvedDials.map((u) => ({ dial: [u] })),
|
||||
};
|
||||
if (host.proxyProtocolVersion) {
|
||||
proxyHandler.proxy_protocol = host.proxyProtocolVersion;
|
||||
}
|
||||
if (lbConfig) {
|
||||
const loadBalancing = buildLoadBalancingConfig(lbConfig);
|
||||
if (loadBalancing) proxyHandler.load_balancing = loadBalancing;
|
||||
const healthChecks = buildHealthChecksConfig(lbConfig);
|
||||
if (healthChecks) proxyHandler.health_checks = healthChecks;
|
||||
}
|
||||
handlers.push(proxyHandler);
|
||||
|
||||
route.handle = handlers;
|
||||
|
||||
// Geo blocking: add a blocking route BEFORE the proxy route.
|
||||
// At L4, the blocker is a matcher (layer4.matchers.blocker) — blocked connections
|
||||
// match this route and are closed. Non-blocked connections fall through to the proxy route.
|
||||
const effectiveGeoBlock = resolveEffectiveGeoBlock(globalGeoBlock, {
|
||||
geoblock: meta.geoblock ?? null,
|
||||
geoblock_mode: meta.geoblock_mode ?? "merge",
|
||||
});
|
||||
if (effectiveGeoBlock) {
|
||||
const blockerMatcher: Record<string, unknown> = {
|
||||
geoip_db: "/usr/share/GeoIP/GeoLite2-Country.mmdb",
|
||||
asn_db: "/usr/share/GeoIP/GeoLite2-ASN.mmdb",
|
||||
};
|
||||
if (effectiveGeoBlock.block_countries?.length) blockerMatcher.block_countries = effectiveGeoBlock.block_countries;
|
||||
if (effectiveGeoBlock.block_continents?.length) blockerMatcher.block_continents = effectiveGeoBlock.block_continents;
|
||||
if (effectiveGeoBlock.block_asns?.length) blockerMatcher.block_asns = effectiveGeoBlock.block_asns;
|
||||
if (effectiveGeoBlock.block_cidrs?.length) blockerMatcher.block_cidrs = effectiveGeoBlock.block_cidrs;
|
||||
if (effectiveGeoBlock.block_ips?.length) blockerMatcher.block_ips = effectiveGeoBlock.block_ips;
|
||||
if (effectiveGeoBlock.allow_countries?.length) blockerMatcher.allow_countries = effectiveGeoBlock.allow_countries;
|
||||
if (effectiveGeoBlock.allow_continents?.length) blockerMatcher.allow_continents = effectiveGeoBlock.allow_continents;
|
||||
if (effectiveGeoBlock.allow_asns?.length) blockerMatcher.allow_asns = effectiveGeoBlock.allow_asns;
|
||||
if (effectiveGeoBlock.allow_cidrs?.length) blockerMatcher.allow_cidrs = effectiveGeoBlock.allow_cidrs;
|
||||
if (effectiveGeoBlock.allow_ips?.length) blockerMatcher.allow_ips = effectiveGeoBlock.allow_ips;
|
||||
|
||||
// Build the same route matcher as the proxy route (if any)
|
||||
const blockRoute: Record<string, unknown> = {
|
||||
match: [
|
||||
{
|
||||
blocker: blockerMatcher,
|
||||
...(route.match ? (route.match as Record<string, unknown>[])[0] : {}),
|
||||
},
|
||||
],
|
||||
handle: [{ handler: "close" }],
|
||||
};
|
||||
routes.push(blockRoute);
|
||||
}
|
||||
|
||||
routes.push(route);
|
||||
}
|
||||
|
||||
servers[`l4_server_${serverIdx++}`] = {
|
||||
listen: [listenAddr],
|
||||
routes,
|
||||
};
|
||||
}
|
||||
|
||||
return servers;
|
||||
}
|
||||
|
||||
async function buildCaddyDocument() {
|
||||
const [proxyHostRecords, certRows, accessListEntryRecords, caCertRows, issuedClientCertRows, allIssuedCaCertIds] = await Promise.all([
|
||||
db
|
||||
@@ -1525,6 +1732,10 @@ async function buildCaddyDocument() {
|
||||
}
|
||||
const loggingApp = { logging: { logs: loggingLogs } };
|
||||
|
||||
// Build L4 (TCP/UDP) proxy servers
|
||||
const l4Servers = await buildL4Servers();
|
||||
const l4App = l4Servers ? { layer4: { servers: l4Servers } } : {};
|
||||
|
||||
return {
|
||||
admin: {
|
||||
listen: "0.0.0.0:2019",
|
||||
@@ -1538,7 +1749,8 @@ async function buildCaddyDocument() {
|
||||
...(tlsApp ?? {}),
|
||||
...(importedCertPems.length > 0 ? { certificates: { load_pem: importedCertPems } } : {})
|
||||
}
|
||||
} : {})
|
||||
} : {}),
|
||||
...l4App
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -273,3 +273,21 @@ export const wafLogParseState = sqliteTable('waf_log_parse_state', {
|
||||
key: text('key').primaryKey(),
|
||||
value: text('value').notNull(),
|
||||
});
|
||||
|
||||
export const l4ProxyHosts = sqliteTable("l4_proxy_hosts", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
protocol: text("protocol").notNull(),
|
||||
listenAddress: text("listen_address").notNull(),
|
||||
upstreams: text("upstreams").notNull(),
|
||||
matcherType: text("matcher_type").notNull().default("none"),
|
||||
matcherValue: text("matcher_value"),
|
||||
tlsTermination: integer("tls_termination", { mode: "boolean" }).notNull().default(false),
|
||||
proxyProtocolVersion: text("proxy_protocol_version"),
|
||||
proxyProtocolReceive: integer("proxy_protocol_receive", { mode: "boolean" }).notNull().default(false),
|
||||
ownerUserId: integer("owner_user_id").references(() => users.id, { onDelete: "set null" }),
|
||||
meta: text("meta"),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* L4 Port Management
|
||||
*
|
||||
* Generates a Docker Compose override file with the required port mappings
|
||||
* for L4 proxy hosts, and manages the apply/status lifecycle via trigger
|
||||
* files on a shared volume.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Web app computes required ports from enabled L4 proxy hosts
|
||||
* 2. Web writes docker-compose.l4-ports.yml override file
|
||||
* 3. Web writes l4-ports.trigger to signal the sidecar
|
||||
* 4. Sidecar detects trigger, runs `docker compose up -d caddy`
|
||||
* 5. Sidecar writes l4-ports.status with result
|
||||
* 6. Web reads status to show user the outcome
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import db from "./db";
|
||||
import { l4ProxyHosts } from "./db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
const DATA_DIR = process.env.L4_PORTS_DIR || "/app/data";
|
||||
const OVERRIDE_FILE = "docker-compose.l4-ports.yml";
|
||||
const TRIGGER_FILE = "l4-ports.trigger";
|
||||
const STATUS_FILE = "l4-ports.status";
|
||||
|
||||
export type L4PortsStatus = {
|
||||
state: "idle" | "pending" | "applying" | "applied" | "failed";
|
||||
message?: string;
|
||||
appliedAt?: string;
|
||||
triggeredAt?: string;
|
||||
appliedHash?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type L4PortsDiff = {
|
||||
currentPorts: string[];
|
||||
requiredPorts: string[];
|
||||
needsApply: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute the set of ports that need to be exposed on the caddy container
|
||||
* based on all enabled L4 proxy hosts.
|
||||
*/
|
||||
export async function getRequiredL4Ports(): Promise<string[]> {
|
||||
const hosts = await db
|
||||
.select({
|
||||
listenAddress: l4ProxyHosts.listenAddress,
|
||||
protocol: l4ProxyHosts.protocol,
|
||||
})
|
||||
.from(l4ProxyHosts)
|
||||
.where(eq(l4ProxyHosts.enabled, true));
|
||||
|
||||
const portSet = new Set<string>();
|
||||
for (const host of hosts) {
|
||||
const addr = host.listenAddress.trim();
|
||||
// Extract port from ":PORT" or "HOST:PORT"
|
||||
const match = addr.match(/:(\d+)$/);
|
||||
if (!match) continue;
|
||||
const port = match[1];
|
||||
const proto = host.protocol === "udp" ? "/udp" : "";
|
||||
portSet.add(`${port}:${port}${proto}`);
|
||||
}
|
||||
|
||||
return Array.from(portSet).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the currently applied ports from the override file on disk.
|
||||
*/
|
||||
export function getAppliedL4Ports(): string[] {
|
||||
const filePath = join(DATA_DIR, OVERRIDE_FILE);
|
||||
if (!existsSync(filePath)) return [];
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const ports: string[] = [];
|
||||
// Simple YAML parsing — extract port lines from "ports:" section
|
||||
const lines = content.split("\n");
|
||||
let inPorts = false;
|
||||
for (const line of lines) {
|
||||
if (line.trim() === "ports:") {
|
||||
inPorts = true;
|
||||
continue;
|
||||
}
|
||||
if (inPorts) {
|
||||
const match = line.match(/^\s+-\s+"(.+)"$/);
|
||||
if (match) {
|
||||
ports.push(match[1]);
|
||||
} else if (line.trim() && !line.startsWith(" ") && !line.startsWith("-")) {
|
||||
break; // End of ports section
|
||||
}
|
||||
}
|
||||
}
|
||||
return ports.sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute hash of a port list for change detection.
|
||||
*/
|
||||
function hashPorts(ports: string[]): string {
|
||||
return crypto.createHash("sha256").update(ports.join(",")).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current L4 proxy host config differs from applied ports.
|
||||
*/
|
||||
export async function getL4PortsDiff(): Promise<L4PortsDiff> {
|
||||
const requiredPorts = await getRequiredL4Ports();
|
||||
const currentPorts = getAppliedL4Ports();
|
||||
const needsApply = hashPorts(requiredPorts) !== hashPorts(currentPorts);
|
||||
return { currentPorts, requiredPorts, needsApply };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the Docker Compose override file and write the trigger.
|
||||
* Returns the status after triggering.
|
||||
*/
|
||||
export async function applyL4Ports(): Promise<L4PortsStatus> {
|
||||
const requiredPorts = await getRequiredL4Ports();
|
||||
|
||||
// Generate the override YAML
|
||||
let yaml: string;
|
||||
if (requiredPorts.length === 0) {
|
||||
// Empty override — no extra ports needed
|
||||
yaml = `# Auto-generated by Caddy Proxy Manager — L4 port mappings
|
||||
# No L4 proxy hosts require additional ports
|
||||
services: {}
|
||||
`;
|
||||
} else {
|
||||
const portLines = requiredPorts.map((p) => ` - "${p}"`).join("\n");
|
||||
yaml = `# Auto-generated by Caddy Proxy Manager — L4 port mappings
|
||||
# Do not edit manually — this file is regenerated when you click "Apply Ports"
|
||||
services:
|
||||
caddy:
|
||||
ports:
|
||||
${portLines}
|
||||
`;
|
||||
}
|
||||
|
||||
const overridePath = join(DATA_DIR, OVERRIDE_FILE);
|
||||
const triggerPath = join(DATA_DIR, TRIGGER_FILE);
|
||||
|
||||
// Write the override file
|
||||
writeFileSync(overridePath, yaml, "utf-8");
|
||||
|
||||
// Write trigger to signal sidecar
|
||||
const triggeredAt = new Date().toISOString();
|
||||
writeFileSync(triggerPath, JSON.stringify({
|
||||
triggeredAt,
|
||||
hash: hashPorts(requiredPorts),
|
||||
ports: requiredPorts,
|
||||
}), "utf-8");
|
||||
|
||||
return {
|
||||
state: "pending",
|
||||
message: `Trigger written. Waiting for port manager sidecar to apply ${requiredPorts.length} port(s).`,
|
||||
triggeredAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current status from the status file written by the sidecar.
|
||||
*/
|
||||
export function getL4PortsStatus(): L4PortsStatus {
|
||||
const statusPath = join(DATA_DIR, STATUS_FILE);
|
||||
|
||||
if (!existsSync(statusPath)) {
|
||||
return { state: "idle" };
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(readFileSync(statusPath, "utf-8")) as L4PortsStatus;
|
||||
} catch {
|
||||
return { state: "idle" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the sidecar container is available by looking for the status file
|
||||
* or trigger file having been processed.
|
||||
*/
|
||||
export function isSidecarAvailable(): boolean {
|
||||
const statusPath = join(DATA_DIR, STATUS_FILE);
|
||||
return existsSync(statusPath);
|
||||
}
|
||||
@@ -0,0 +1,672 @@
|
||||
import db, { nowIso, toIso } from "../db";
|
||||
import { applyCaddyConfig } from "../caddy";
|
||||
import { logAuditEvent } from "../audit";
|
||||
import { l4ProxyHosts } from "../db/schema";
|
||||
import { desc, eq, count, like, or } from "drizzle-orm";
|
||||
|
||||
export type L4Protocol = "tcp" | "udp";
|
||||
export type L4MatcherType = "none" | "tls_sni" | "http_host" | "proxy_protocol";
|
||||
export type L4ProxyProtocolVersion = "v1" | "v2";
|
||||
|
||||
export type L4LoadBalancingPolicy = "random" | "round_robin" | "least_conn" | "ip_hash" | "first";
|
||||
|
||||
export type L4LoadBalancerActiveHealthCheck = {
|
||||
enabled: boolean;
|
||||
port: number | null;
|
||||
interval: string | null;
|
||||
timeout: string | null;
|
||||
};
|
||||
|
||||
export type L4LoadBalancerPassiveHealthCheck = {
|
||||
enabled: boolean;
|
||||
failDuration: string | null;
|
||||
maxFails: number | null;
|
||||
unhealthyLatency: string | null;
|
||||
};
|
||||
|
||||
export type L4LoadBalancerConfig = {
|
||||
enabled: boolean;
|
||||
policy: L4LoadBalancingPolicy;
|
||||
tryDuration: string | null;
|
||||
tryInterval: string | null;
|
||||
retries: number | null;
|
||||
activeHealthCheck: L4LoadBalancerActiveHealthCheck | null;
|
||||
passiveHealthCheck: L4LoadBalancerPassiveHealthCheck | null;
|
||||
};
|
||||
|
||||
export type L4DnsResolverConfig = {
|
||||
enabled: boolean;
|
||||
resolvers: string[];
|
||||
fallbacks: string[];
|
||||
timeout: string | null;
|
||||
};
|
||||
|
||||
export type L4UpstreamDnsResolutionConfig = {
|
||||
enabled: boolean | null;
|
||||
family: "ipv6" | "ipv4" | "both" | null;
|
||||
};
|
||||
|
||||
type L4LoadBalancerActiveHealthCheckMeta = {
|
||||
enabled?: boolean;
|
||||
port?: number;
|
||||
interval?: string;
|
||||
timeout?: string;
|
||||
};
|
||||
|
||||
type L4LoadBalancerPassiveHealthCheckMeta = {
|
||||
enabled?: boolean;
|
||||
fail_duration?: string;
|
||||
max_fails?: number;
|
||||
unhealthy_latency?: string;
|
||||
};
|
||||
|
||||
type L4LoadBalancerMeta = {
|
||||
enabled?: boolean;
|
||||
policy?: string;
|
||||
try_duration?: string;
|
||||
try_interval?: string;
|
||||
retries?: number;
|
||||
active_health_check?: L4LoadBalancerActiveHealthCheckMeta;
|
||||
passive_health_check?: L4LoadBalancerPassiveHealthCheckMeta;
|
||||
};
|
||||
|
||||
type L4DnsResolverMeta = {
|
||||
enabled?: boolean;
|
||||
resolvers?: string[];
|
||||
fallbacks?: string[];
|
||||
timeout?: string;
|
||||
};
|
||||
|
||||
type L4UpstreamDnsResolutionMeta = {
|
||||
enabled?: boolean;
|
||||
family?: string;
|
||||
};
|
||||
|
||||
export type L4GeoBlockConfig = {
|
||||
enabled: boolean;
|
||||
block_countries: string[];
|
||||
block_continents: string[];
|
||||
block_asns: number[];
|
||||
block_cidrs: string[];
|
||||
block_ips: string[];
|
||||
allow_countries: string[];
|
||||
allow_continents: string[];
|
||||
allow_asns: number[];
|
||||
allow_cidrs: string[];
|
||||
allow_ips: string[];
|
||||
};
|
||||
|
||||
export type L4GeoBlockMode = "merge" | "override";
|
||||
|
||||
export type L4ProxyHostMeta = {
|
||||
load_balancer?: L4LoadBalancerMeta;
|
||||
dns_resolver?: L4DnsResolverMeta;
|
||||
upstream_dns_resolution?: L4UpstreamDnsResolutionMeta;
|
||||
geoblock?: L4GeoBlockConfig;
|
||||
geoblock_mode?: L4GeoBlockMode;
|
||||
};
|
||||
|
||||
const VALID_L4_LB_POLICIES: L4LoadBalancingPolicy[] = ["random", "round_robin", "least_conn", "ip_hash", "first"];
|
||||
const VALID_L4_UPSTREAM_DNS_FAMILIES: L4UpstreamDnsResolutionConfig["family"][] = ["ipv6", "ipv4", "both"];
|
||||
|
||||
export type L4ProxyHost = {
|
||||
id: number;
|
||||
name: string;
|
||||
protocol: L4Protocol;
|
||||
listen_address: string;
|
||||
upstreams: string[];
|
||||
matcher_type: L4MatcherType;
|
||||
matcher_value: string[];
|
||||
tls_termination: boolean;
|
||||
proxy_protocol_version: L4ProxyProtocolVersion | null;
|
||||
proxy_protocol_receive: boolean;
|
||||
enabled: boolean;
|
||||
meta: L4ProxyHostMeta | null;
|
||||
load_balancer: L4LoadBalancerConfig | null;
|
||||
dns_resolver: L4DnsResolverConfig | null;
|
||||
upstream_dns_resolution: L4UpstreamDnsResolutionConfig | null;
|
||||
geoblock: L4GeoBlockConfig | null;
|
||||
geoblock_mode: L4GeoBlockMode;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type L4ProxyHostInput = {
|
||||
name: string;
|
||||
protocol: L4Protocol;
|
||||
listen_address: string;
|
||||
upstreams: string[];
|
||||
matcher_type?: L4MatcherType;
|
||||
matcher_value?: string[];
|
||||
tls_termination?: boolean;
|
||||
proxy_protocol_version?: L4ProxyProtocolVersion | null;
|
||||
proxy_protocol_receive?: boolean;
|
||||
enabled?: boolean;
|
||||
meta?: L4ProxyHostMeta | null;
|
||||
load_balancer?: Partial<L4LoadBalancerConfig> | null;
|
||||
dns_resolver?: Partial<L4DnsResolverConfig> | null;
|
||||
upstream_dns_resolution?: Partial<L4UpstreamDnsResolutionConfig> | null;
|
||||
geoblock?: L4GeoBlockConfig | null;
|
||||
geoblock_mode?: L4GeoBlockMode;
|
||||
};
|
||||
|
||||
const VALID_PROTOCOLS: L4Protocol[] = ["tcp", "udp"];
|
||||
const VALID_MATCHER_TYPES: L4MatcherType[] = ["none", "tls_sni", "http_host", "proxy_protocol"];
|
||||
const VALID_PROXY_PROTOCOL_VERSIONS: L4ProxyProtocolVersion[] = ["v1", "v2"];
|
||||
|
||||
function safeJsonParse<T>(value: string | null, fallback: T): T {
|
||||
if (!value) return fallback;
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMetaValue(value: string | null | undefined): string | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function hydrateL4LoadBalancer(meta: L4LoadBalancerMeta | undefined): L4LoadBalancerConfig | null {
|
||||
if (!meta) return null;
|
||||
|
||||
const enabled = Boolean(meta.enabled);
|
||||
const policy: L4LoadBalancingPolicy =
|
||||
meta.policy && VALID_L4_LB_POLICIES.includes(meta.policy as L4LoadBalancingPolicy)
|
||||
? (meta.policy as L4LoadBalancingPolicy)
|
||||
: "random";
|
||||
|
||||
const tryDuration = normalizeMetaValue(meta.try_duration ?? null);
|
||||
const tryInterval = normalizeMetaValue(meta.try_interval ?? null);
|
||||
const retries =
|
||||
typeof meta.retries === "number" && Number.isFinite(meta.retries) && meta.retries >= 0
|
||||
? meta.retries
|
||||
: null;
|
||||
|
||||
let activeHealthCheck: L4LoadBalancerActiveHealthCheck | null = null;
|
||||
if (meta.active_health_check) {
|
||||
activeHealthCheck = {
|
||||
enabled: Boolean(meta.active_health_check.enabled),
|
||||
port:
|
||||
typeof meta.active_health_check.port === "number" &&
|
||||
Number.isFinite(meta.active_health_check.port) &&
|
||||
meta.active_health_check.port > 0
|
||||
? meta.active_health_check.port
|
||||
: null,
|
||||
interval: normalizeMetaValue(meta.active_health_check.interval ?? null),
|
||||
timeout: normalizeMetaValue(meta.active_health_check.timeout ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
let passiveHealthCheck: L4LoadBalancerPassiveHealthCheck | null = null;
|
||||
if (meta.passive_health_check) {
|
||||
passiveHealthCheck = {
|
||||
enabled: Boolean(meta.passive_health_check.enabled),
|
||||
failDuration: normalizeMetaValue(meta.passive_health_check.fail_duration ?? null),
|
||||
maxFails:
|
||||
typeof meta.passive_health_check.max_fails === "number" &&
|
||||
Number.isFinite(meta.passive_health_check.max_fails) &&
|
||||
meta.passive_health_check.max_fails >= 0
|
||||
? meta.passive_health_check.max_fails
|
||||
: null,
|
||||
unhealthyLatency: normalizeMetaValue(meta.passive_health_check.unhealthy_latency ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
enabled,
|
||||
policy,
|
||||
tryDuration,
|
||||
tryInterval,
|
||||
retries,
|
||||
activeHealthCheck,
|
||||
passiveHealthCheck,
|
||||
};
|
||||
}
|
||||
|
||||
function dehydrateL4LoadBalancer(config: Partial<L4LoadBalancerConfig> | null): L4LoadBalancerMeta | undefined {
|
||||
if (!config) return undefined;
|
||||
|
||||
const meta: L4LoadBalancerMeta = {
|
||||
enabled: Boolean(config.enabled),
|
||||
};
|
||||
|
||||
if (config.policy) {
|
||||
meta.policy = config.policy;
|
||||
}
|
||||
if (config.tryDuration) {
|
||||
meta.try_duration = config.tryDuration;
|
||||
}
|
||||
if (config.tryInterval) {
|
||||
meta.try_interval = config.tryInterval;
|
||||
}
|
||||
if (config.retries !== undefined && config.retries !== null) {
|
||||
meta.retries = config.retries;
|
||||
}
|
||||
|
||||
if (config.activeHealthCheck) {
|
||||
const ahc: L4LoadBalancerActiveHealthCheckMeta = {
|
||||
enabled: config.activeHealthCheck.enabled,
|
||||
};
|
||||
if (config.activeHealthCheck.port !== null && config.activeHealthCheck.port !== undefined) {
|
||||
ahc.port = config.activeHealthCheck.port;
|
||||
}
|
||||
if (config.activeHealthCheck.interval) {
|
||||
ahc.interval = config.activeHealthCheck.interval;
|
||||
}
|
||||
if (config.activeHealthCheck.timeout) {
|
||||
ahc.timeout = config.activeHealthCheck.timeout;
|
||||
}
|
||||
meta.active_health_check = ahc;
|
||||
}
|
||||
|
||||
if (config.passiveHealthCheck) {
|
||||
const phc: L4LoadBalancerPassiveHealthCheckMeta = {
|
||||
enabled: config.passiveHealthCheck.enabled,
|
||||
};
|
||||
if (config.passiveHealthCheck.failDuration) {
|
||||
phc.fail_duration = config.passiveHealthCheck.failDuration;
|
||||
}
|
||||
if (config.passiveHealthCheck.maxFails !== null && config.passiveHealthCheck.maxFails !== undefined) {
|
||||
phc.max_fails = config.passiveHealthCheck.maxFails;
|
||||
}
|
||||
if (config.passiveHealthCheck.unhealthyLatency) {
|
||||
phc.unhealthy_latency = config.passiveHealthCheck.unhealthyLatency;
|
||||
}
|
||||
meta.passive_health_check = phc;
|
||||
}
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
function hydrateL4DnsResolver(meta: L4DnsResolverMeta | undefined): L4DnsResolverConfig | null {
|
||||
if (!meta) return null;
|
||||
|
||||
const enabled = Boolean(meta.enabled);
|
||||
|
||||
const resolvers = Array.isArray(meta.resolvers)
|
||||
? meta.resolvers.map((r) => (typeof r === "string" ? r.trim() : "")).filter((r) => r.length > 0)
|
||||
: [];
|
||||
|
||||
const fallbacks = Array.isArray(meta.fallbacks)
|
||||
? meta.fallbacks.map((r) => (typeof r === "string" ? r.trim() : "")).filter((r) => r.length > 0)
|
||||
: [];
|
||||
|
||||
const timeout = normalizeMetaValue(meta.timeout ?? null);
|
||||
|
||||
return {
|
||||
enabled,
|
||||
resolvers,
|
||||
fallbacks,
|
||||
timeout,
|
||||
};
|
||||
}
|
||||
|
||||
function dehydrateL4DnsResolver(config: Partial<L4DnsResolverConfig> | null): L4DnsResolverMeta | undefined {
|
||||
if (!config) return undefined;
|
||||
|
||||
const meta: L4DnsResolverMeta = {
|
||||
enabled: Boolean(config.enabled),
|
||||
};
|
||||
|
||||
if (config.resolvers && config.resolvers.length > 0) {
|
||||
meta.resolvers = [...config.resolvers];
|
||||
}
|
||||
if (config.fallbacks && config.fallbacks.length > 0) {
|
||||
meta.fallbacks = [...config.fallbacks];
|
||||
}
|
||||
if (config.timeout) {
|
||||
meta.timeout = config.timeout;
|
||||
}
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
function hydrateL4UpstreamDnsResolution(meta: L4UpstreamDnsResolutionMeta | undefined): L4UpstreamDnsResolutionConfig | null {
|
||||
if (!meta) return null;
|
||||
|
||||
const enabled = meta.enabled === undefined ? null : Boolean(meta.enabled);
|
||||
const family =
|
||||
meta.family && VALID_L4_UPSTREAM_DNS_FAMILIES.includes(meta.family as L4UpstreamDnsResolutionConfig["family"])
|
||||
? (meta.family as L4UpstreamDnsResolutionConfig["family"])
|
||||
: null;
|
||||
|
||||
return {
|
||||
enabled,
|
||||
family,
|
||||
};
|
||||
}
|
||||
|
||||
function dehydrateL4UpstreamDnsResolution(
|
||||
config: Partial<L4UpstreamDnsResolutionConfig> | null
|
||||
): L4UpstreamDnsResolutionMeta | undefined {
|
||||
if (!config) return undefined;
|
||||
|
||||
const meta: L4UpstreamDnsResolutionMeta = {};
|
||||
if (config.enabled !== null && config.enabled !== undefined) {
|
||||
meta.enabled = Boolean(config.enabled);
|
||||
}
|
||||
if (config.family && VALID_L4_UPSTREAM_DNS_FAMILIES.includes(config.family)) {
|
||||
meta.family = config.family;
|
||||
}
|
||||
|
||||
return Object.keys(meta).length > 0 ? meta : undefined;
|
||||
}
|
||||
|
||||
type L4ProxyHostRow = typeof l4ProxyHosts.$inferSelect;
|
||||
|
||||
function parseL4ProxyHost(row: L4ProxyHostRow): L4ProxyHost {
|
||||
const meta = safeJsonParse<L4ProxyHostMeta>(row.meta, {});
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
protocol: row.protocol as L4Protocol,
|
||||
listen_address: row.listenAddress,
|
||||
upstreams: safeJsonParse<string[]>(row.upstreams, []),
|
||||
matcher_type: (row.matcherType as L4MatcherType) || "none",
|
||||
matcher_value: safeJsonParse<string[]>(row.matcherValue, []),
|
||||
tls_termination: row.tlsTermination,
|
||||
proxy_protocol_version: row.proxyProtocolVersion as L4ProxyProtocolVersion | null,
|
||||
proxy_protocol_receive: row.proxyProtocolReceive,
|
||||
enabled: row.enabled,
|
||||
meta: Object.keys(meta).length > 0 ? meta : null,
|
||||
load_balancer: hydrateL4LoadBalancer(meta.load_balancer),
|
||||
dns_resolver: hydrateL4DnsResolver(meta.dns_resolver),
|
||||
upstream_dns_resolution: hydrateL4UpstreamDnsResolution(meta.upstream_dns_resolution),
|
||||
geoblock: meta.geoblock?.enabled ? meta.geoblock : null,
|
||||
geoblock_mode: meta.geoblock_mode ?? "merge",
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!,
|
||||
};
|
||||
}
|
||||
|
||||
function validateL4Input(input: L4ProxyHostInput | Partial<L4ProxyHostInput>, isCreate: boolean) {
|
||||
if (isCreate) {
|
||||
if (!input.name?.trim()) {
|
||||
throw new Error("Name is required");
|
||||
}
|
||||
if (!input.protocol || !VALID_PROTOCOLS.includes(input.protocol)) {
|
||||
throw new Error("Protocol must be 'tcp' or 'udp'");
|
||||
}
|
||||
if (!input.listen_address?.trim()) {
|
||||
throw new Error("Listen address is required");
|
||||
}
|
||||
if (!input.upstreams || input.upstreams.length === 0) {
|
||||
throw new Error("At least one upstream must be specified");
|
||||
}
|
||||
}
|
||||
|
||||
if (input.listen_address !== undefined) {
|
||||
const addr = input.listen_address.trim();
|
||||
// Must be :PORT or HOST:PORT
|
||||
const portMatch = addr.match(/:(\d+)$/);
|
||||
if (!portMatch) {
|
||||
throw new Error("Listen address must be in format ':PORT' or 'HOST:PORT'");
|
||||
}
|
||||
const port = parseInt(portMatch[1], 10);
|
||||
if (port < 1 || port > 65535) {
|
||||
throw new Error("Port must be between 1 and 65535");
|
||||
}
|
||||
}
|
||||
|
||||
if (input.protocol !== undefined && !VALID_PROTOCOLS.includes(input.protocol)) {
|
||||
throw new Error("Protocol must be 'tcp' or 'udp'");
|
||||
}
|
||||
|
||||
if (input.matcher_type !== undefined && !VALID_MATCHER_TYPES.includes(input.matcher_type)) {
|
||||
throw new Error(`Matcher type must be one of: ${VALID_MATCHER_TYPES.join(", ")}`);
|
||||
}
|
||||
|
||||
if (input.matcher_type === "tls_sni" || input.matcher_type === "http_host") {
|
||||
if (!input.matcher_value || input.matcher_value.length === 0) {
|
||||
throw new Error("Matcher value is required for TLS SNI and HTTP Host matchers");
|
||||
}
|
||||
}
|
||||
|
||||
if (input.tls_termination && input.protocol === "udp") {
|
||||
throw new Error("TLS termination is only supported with TCP protocol");
|
||||
}
|
||||
|
||||
if (input.proxy_protocol_version !== undefined && input.proxy_protocol_version !== null) {
|
||||
if (!VALID_PROXY_PROTOCOL_VERSIONS.includes(input.proxy_protocol_version)) {
|
||||
throw new Error("Proxy protocol version must be 'v1' or 'v2'");
|
||||
}
|
||||
}
|
||||
|
||||
if (input.upstreams) {
|
||||
for (const upstream of input.upstreams) {
|
||||
if (!upstream.includes(":")) {
|
||||
throw new Error(`Upstream '${upstream}' must be in 'host:port' format`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function listL4ProxyHosts(): Promise<L4ProxyHost[]> {
|
||||
const hosts = await db.select().from(l4ProxyHosts).orderBy(desc(l4ProxyHosts.createdAt));
|
||||
return hosts.map(parseL4ProxyHost);
|
||||
}
|
||||
|
||||
export async function countL4ProxyHosts(search?: string): Promise<number> {
|
||||
const where = search
|
||||
? or(
|
||||
like(l4ProxyHosts.name, `%${search}%`),
|
||||
like(l4ProxyHosts.listenAddress, `%${search}%`),
|
||||
like(l4ProxyHosts.upstreams, `%${search}%`)
|
||||
)
|
||||
: undefined;
|
||||
const [row] = await db.select({ value: count() }).from(l4ProxyHosts).where(where);
|
||||
return row?.value ?? 0;
|
||||
}
|
||||
|
||||
export async function listL4ProxyHostsPaginated(limit: number, offset: number, search?: string): Promise<L4ProxyHost[]> {
|
||||
const where = search
|
||||
? or(
|
||||
like(l4ProxyHosts.name, `%${search}%`),
|
||||
like(l4ProxyHosts.listenAddress, `%${search}%`),
|
||||
like(l4ProxyHosts.upstreams, `%${search}%`)
|
||||
)
|
||||
: undefined;
|
||||
const hosts = await db
|
||||
.select()
|
||||
.from(l4ProxyHosts)
|
||||
.where(where)
|
||||
.orderBy(desc(l4ProxyHosts.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
return hosts.map(parseL4ProxyHost);
|
||||
}
|
||||
|
||||
export async function createL4ProxyHost(input: L4ProxyHostInput, actorUserId: number) {
|
||||
validateL4Input(input, true);
|
||||
|
||||
const now = nowIso();
|
||||
const [record] = await db
|
||||
.insert(l4ProxyHosts)
|
||||
.values({
|
||||
name: input.name.trim(),
|
||||
protocol: input.protocol,
|
||||
listenAddress: input.listen_address.trim(),
|
||||
upstreams: JSON.stringify(Array.from(new Set(input.upstreams.map((u) => u.trim())))),
|
||||
matcherType: input.matcher_type ?? "none",
|
||||
matcherValue: input.matcher_value ? JSON.stringify(input.matcher_value.map((v) => v.trim()).filter(Boolean)) : null,
|
||||
tlsTermination: input.tls_termination ?? false,
|
||||
proxyProtocolVersion: input.proxy_protocol_version ?? null,
|
||||
proxyProtocolReceive: input.proxy_protocol_receive ?? false,
|
||||
ownerUserId: actorUserId,
|
||||
meta: (() => {
|
||||
const meta: L4ProxyHostMeta = { ...(input.meta ?? {}) };
|
||||
if (input.load_balancer) meta.load_balancer = dehydrateL4LoadBalancer(input.load_balancer);
|
||||
if (input.dns_resolver) meta.dns_resolver = dehydrateL4DnsResolver(input.dns_resolver);
|
||||
if (input.upstream_dns_resolution) meta.upstream_dns_resolution = dehydrateL4UpstreamDnsResolution(input.upstream_dns_resolution);
|
||||
if (input.geoblock) meta.geoblock = input.geoblock;
|
||||
if (input.geoblock_mode && input.geoblock_mode !== "merge") meta.geoblock_mode = input.geoblock_mode;
|
||||
return Object.keys(meta).length > 0 ? JSON.stringify(meta) : null;
|
||||
})(),
|
||||
enabled: input.enabled ?? true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!record) {
|
||||
throw new Error("Failed to create L4 proxy host");
|
||||
}
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "create",
|
||||
entityType: "l4_proxy_host",
|
||||
entityId: record.id,
|
||||
summary: `Created L4 proxy host ${input.name}`,
|
||||
data: input,
|
||||
});
|
||||
|
||||
await applyCaddyConfig();
|
||||
return (await getL4ProxyHost(record.id))!;
|
||||
}
|
||||
|
||||
export async function getL4ProxyHost(id: number): Promise<L4ProxyHost | null> {
|
||||
const host = await db.query.l4ProxyHosts.findFirst({
|
||||
where: (table, { eq }) => eq(table.id, id),
|
||||
});
|
||||
return host ? parseL4ProxyHost(host) : null;
|
||||
}
|
||||
|
||||
export async function updateL4ProxyHost(id: number, input: Partial<L4ProxyHostInput>, actorUserId: number) {
|
||||
const existing = await getL4ProxyHost(id);
|
||||
if (!existing) {
|
||||
throw new Error("L4 proxy host not found");
|
||||
}
|
||||
|
||||
// For validation, merge with existing to check cross-field constraints
|
||||
const merged = {
|
||||
protocol: input.protocol ?? existing.protocol,
|
||||
tls_termination: input.tls_termination ?? existing.tls_termination,
|
||||
matcher_type: input.matcher_type ?? existing.matcher_type,
|
||||
matcher_value: input.matcher_value ?? existing.matcher_value,
|
||||
};
|
||||
if (merged.tls_termination && merged.protocol === "udp") {
|
||||
throw new Error("TLS termination is only supported with TCP protocol");
|
||||
}
|
||||
if ((merged.matcher_type === "tls_sni" || merged.matcher_type === "http_host") && merged.matcher_value.length === 0) {
|
||||
throw new Error("Matcher value is required for TLS SNI and HTTP Host matchers");
|
||||
}
|
||||
|
||||
validateL4Input(input, false);
|
||||
|
||||
const now = nowIso();
|
||||
await db
|
||||
.update(l4ProxyHosts)
|
||||
.set({
|
||||
...(input.name !== undefined ? { name: input.name.trim() } : {}),
|
||||
...(input.protocol !== undefined ? { protocol: input.protocol } : {}),
|
||||
...(input.listen_address !== undefined ? { listenAddress: input.listen_address.trim() } : {}),
|
||||
...(input.upstreams !== undefined
|
||||
? { upstreams: JSON.stringify(Array.from(new Set(input.upstreams.map((u) => u.trim())))) }
|
||||
: {}),
|
||||
...(input.matcher_type !== undefined ? { matcherType: input.matcher_type } : {}),
|
||||
...(input.matcher_value !== undefined
|
||||
? { matcherValue: JSON.stringify(input.matcher_value.map((v) => v.trim()).filter(Boolean)) }
|
||||
: {}),
|
||||
...(input.tls_termination !== undefined ? { tlsTermination: input.tls_termination } : {}),
|
||||
...(input.proxy_protocol_version !== undefined ? { proxyProtocolVersion: input.proxy_protocol_version } : {}),
|
||||
...(input.proxy_protocol_receive !== undefined ? { proxyProtocolReceive: input.proxy_protocol_receive } : {}),
|
||||
...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
|
||||
...(() => {
|
||||
const hasMetaChanges =
|
||||
input.meta !== undefined ||
|
||||
input.load_balancer !== undefined ||
|
||||
input.dns_resolver !== undefined ||
|
||||
input.upstream_dns_resolution !== undefined;
|
||||
if (!hasMetaChanges) return {};
|
||||
|
||||
// Start from existing meta
|
||||
const existingMeta: L4ProxyHostMeta = {
|
||||
...(existing.load_balancer ? { load_balancer: dehydrateL4LoadBalancer(existing.load_balancer) } : {}),
|
||||
...(existing.dns_resolver ? { dns_resolver: dehydrateL4DnsResolver(existing.dns_resolver) } : {}),
|
||||
...(existing.upstream_dns_resolution ? { upstream_dns_resolution: dehydrateL4UpstreamDnsResolution(existing.upstream_dns_resolution) } : {}),
|
||||
...(existing.geoblock ? { geoblock: existing.geoblock } : {}),
|
||||
...(existing.geoblock_mode !== "merge" ? { geoblock_mode: existing.geoblock_mode } : {}),
|
||||
};
|
||||
|
||||
// Apply direct meta override if provided
|
||||
const meta: L4ProxyHostMeta = input.meta !== undefined ? { ...(input.meta ?? {}) } : { ...existingMeta };
|
||||
|
||||
// Apply structured field overrides
|
||||
if (input.load_balancer !== undefined) {
|
||||
const lb = dehydrateL4LoadBalancer(input.load_balancer);
|
||||
if (lb) {
|
||||
meta.load_balancer = lb;
|
||||
} else {
|
||||
delete meta.load_balancer;
|
||||
}
|
||||
}
|
||||
if (input.dns_resolver !== undefined) {
|
||||
const dr = dehydrateL4DnsResolver(input.dns_resolver);
|
||||
if (dr) {
|
||||
meta.dns_resolver = dr;
|
||||
} else {
|
||||
delete meta.dns_resolver;
|
||||
}
|
||||
}
|
||||
if (input.upstream_dns_resolution !== undefined) {
|
||||
const udr = dehydrateL4UpstreamDnsResolution(input.upstream_dns_resolution);
|
||||
if (udr) {
|
||||
meta.upstream_dns_resolution = udr;
|
||||
} else {
|
||||
delete meta.upstream_dns_resolution;
|
||||
}
|
||||
}
|
||||
if (input.geoblock !== undefined) {
|
||||
if (input.geoblock) {
|
||||
meta.geoblock = input.geoblock;
|
||||
} else {
|
||||
delete meta.geoblock;
|
||||
}
|
||||
}
|
||||
if (input.geoblock_mode !== undefined) {
|
||||
if (input.geoblock_mode !== "merge") {
|
||||
meta.geoblock_mode = input.geoblock_mode;
|
||||
} else {
|
||||
delete meta.geoblock_mode;
|
||||
}
|
||||
}
|
||||
|
||||
return { meta: Object.keys(meta).length > 0 ? JSON.stringify(meta) : null };
|
||||
})(),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(l4ProxyHosts.id, id));
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "update",
|
||||
entityType: "l4_proxy_host",
|
||||
entityId: id,
|
||||
summary: `Updated L4 proxy host ${input.name ?? existing.name}`,
|
||||
data: input,
|
||||
});
|
||||
|
||||
await applyCaddyConfig();
|
||||
return (await getL4ProxyHost(id))!;
|
||||
}
|
||||
|
||||
export async function deleteL4ProxyHost(id: number, actorUserId: number) {
|
||||
const existing = await getL4ProxyHost(id);
|
||||
if (!existing) {
|
||||
throw new Error("L4 proxy host not found");
|
||||
}
|
||||
|
||||
await db.delete(l4ProxyHosts).where(eq(l4ProxyHosts.id, id));
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "delete",
|
||||
entityType: "l4_proxy_host",
|
||||
entityId: id,
|
||||
summary: `Deleted L4 proxy host ${existing.name}`,
|
||||
});
|
||||
await applyCaddyConfig();
|
||||
}
|
||||
Reference in New Issue
Block a user