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:
@@ -30,6 +30,7 @@ import SecurityIcon from "@mui/icons-material/Security";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import HistoryIcon from "@mui/icons-material/History";
|
||||
import GppBadIcon from "@mui/icons-material/GppBad";
|
||||
import CableIcon from "@mui/icons-material/Cable";
|
||||
import LogoutIcon from "@mui/icons-material/Logout";
|
||||
|
||||
type User = {
|
||||
@@ -42,6 +43,7 @@ type User = {
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/", label: "Overview", icon: DashboardIcon },
|
||||
{ href: "/proxy-hosts", label: "Proxy Hosts", icon: SwapHorizIcon },
|
||||
{ href: "/l4-proxy-hosts", label: "L4 Proxy Hosts", icon: CableIcon },
|
||||
{ href: "/access-lists", label: "Access Lists", icon: VpnKeyIcon },
|
||||
{ href: "/certificates", label: "Certificates", icon: SecurityIcon },
|
||||
{ href: "/waf", label: "WAF", icon: GppBadIcon },
|
||||
|
||||
269
app/(dashboard)/l4-proxy-hosts/L4ProxyHostsClient.tsx
Normal file
269
app/(dashboard)/l4-proxy-hosts/L4ProxyHostsClient.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||
import { Card, Chip, IconButton, Stack, Switch, Tooltip, Typography } from "@mui/material";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
||||
import type { L4ProxyHost } from "@/src/lib/models/l4-proxy-hosts";
|
||||
import { toggleL4ProxyHostAction } from "./actions";
|
||||
import { PageHeader } from "@/src/components/ui/PageHeader";
|
||||
import { SearchField } from "@/src/components/ui/SearchField";
|
||||
import { DataTable } from "@/src/components/ui/DataTable";
|
||||
import { CreateL4HostDialog, EditL4HostDialog, DeleteL4HostDialog } from "@/src/components/l4-proxy-hosts/L4HostDialogs";
|
||||
import { L4PortsApplyBanner } from "@/src/components/l4-proxy-hosts/L4PortsApplyBanner";
|
||||
|
||||
type Props = {
|
||||
hosts: L4ProxyHost[];
|
||||
pagination: { total: number; page: number; perPage: number };
|
||||
initialSearch: string;
|
||||
};
|
||||
|
||||
function formatMatcher(host: L4ProxyHost): string {
|
||||
switch (host.matcher_type) {
|
||||
case "tls_sni":
|
||||
return `SNI: ${host.matcher_value.join(", ")}`;
|
||||
case "http_host":
|
||||
return `Host: ${host.matcher_value.join(", ")}`;
|
||||
case "proxy_protocol":
|
||||
return "Proxy Protocol";
|
||||
default:
|
||||
return "None";
|
||||
}
|
||||
}
|
||||
|
||||
export default function L4ProxyHostsClient({ hosts, pagination, initialSearch }: Props) {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [duplicateHost, setDuplicateHost] = useState<L4ProxyHost | null>(null);
|
||||
const [editHost, setEditHost] = useState<L4ProxyHost | null>(null);
|
||||
const [deleteHost, setDeleteHost] = useState<L4ProxyHost | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState(initialSearch);
|
||||
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchTerm(initialSearch);
|
||||
}, [initialSearch]);
|
||||
|
||||
function handleSearchChange(value: string) {
|
||||
setSearchTerm(value);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value.trim()) {
|
||||
params.set("search", value.trim());
|
||||
} else {
|
||||
params.delete("search");
|
||||
}
|
||||
params.set("page", "1");
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
}, 400);
|
||||
}
|
||||
|
||||
const handleToggleEnabled = async (id: number, enabled: boolean) => {
|
||||
await toggleL4ProxyHostAction(id, enabled);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
id: "name",
|
||||
label: "Name",
|
||||
render: (host: L4ProxyHost) => (
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{host.name}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "protocol",
|
||||
label: "Protocol",
|
||||
width: 80,
|
||||
render: (host: L4ProxyHost) => (
|
||||
<Chip
|
||||
label={host.protocol.toUpperCase()}
|
||||
size="small"
|
||||
color={host.protocol === "tcp" ? "primary" : "secondary"}
|
||||
variant="outlined"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "listen",
|
||||
label: "Listen",
|
||||
render: (host: L4ProxyHost) => (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontFamily: "monospace" }}>
|
||||
{host.listen_address}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "matcher",
|
||||
label: "Matcher",
|
||||
render: (host: L4ProxyHost) => (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{formatMatcher(host)}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "upstreams",
|
||||
label: "Upstreams",
|
||||
render: (host: L4ProxyHost) => (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontFamily: "monospace" }}>
|
||||
{host.upstreams[0]}
|
||||
{host.upstreams.length > 1 && ` +${host.upstreams.length - 1} more`}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: "Actions",
|
||||
align: "right" as const,
|
||||
width: 150,
|
||||
render: (host: L4ProxyHost) => (
|
||||
<Stack direction="row" spacing={1} justifyContent="flex-end" alignItems="center">
|
||||
<Switch
|
||||
checked={host.enabled}
|
||||
onChange={(e) => handleToggleEnabled(host.id, e.target.checked)}
|
||||
size="small"
|
||||
color="success"
|
||||
/>
|
||||
<Tooltip title="Duplicate">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setDuplicateHost(host);
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
color="info"
|
||||
>
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Edit">
|
||||
<IconButton size="small" onClick={() => setEditHost(host)} color="primary">
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<IconButton size="small" onClick={() => setDeleteHost(host)} color="error">
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const mobileCard = (host: L4ProxyHost) => (
|
||||
<Card variant="outlined" sx={{ p: 2 }}>
|
||||
<Stack spacing={1}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Typography variant="subtitle2" fontWeight={700}>
|
||||
{host.name}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={host.protocol.toUpperCase()}
|
||||
size="small"
|
||||
color={host.protocol === "tcp" ? "primary" : "secondary"}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||
<Switch
|
||||
checked={host.enabled}
|
||||
onChange={(e) => handleToggleEnabled(host.id, e.target.checked)}
|
||||
size="small"
|
||||
color="success"
|
||||
/>
|
||||
<Tooltip title="Duplicate">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setDuplicateHost(host);
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
color="info"
|
||||
>
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Edit">
|
||||
<IconButton size="small" onClick={() => setEditHost(host)} color="primary">
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<IconButton size="small" onClick={() => setDeleteHost(host)} color="error">
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontFamily: "monospace", fontSize: "0.75rem" }}>
|
||||
{host.listen_address} {"\u2192"} {host.upstreams[0]}
|
||||
{host.upstreams.length > 1 ? ` +${host.upstreams.length - 1}` : ""}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<L4PortsApplyBanner />
|
||||
<PageHeader
|
||||
title="L4 Proxy Hosts"
|
||||
description="Define TCP/UDP stream proxies powered by caddy-l4. Port mappings are applied automatically by the L4 port manager."
|
||||
action={{
|
||||
label: "Create L4 Host",
|
||||
onClick: () => setCreateOpen(true),
|
||||
}}
|
||||
/>
|
||||
|
||||
<SearchField
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder="Search L4 hosts..."
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={hosts}
|
||||
keyField="id"
|
||||
emptyMessage={searchTerm ? "No L4 hosts match your search" : "No L4 proxy hosts found"}
|
||||
pagination={pagination}
|
||||
mobileCard={mobileCard}
|
||||
/>
|
||||
|
||||
<CreateL4HostDialog
|
||||
open={createOpen}
|
||||
onClose={() => {
|
||||
setCreateOpen(false);
|
||||
setTimeout(() => setDuplicateHost(null), 200);
|
||||
}}
|
||||
initialData={duplicateHost}
|
||||
/>
|
||||
|
||||
{editHost && (
|
||||
<EditL4HostDialog
|
||||
open={!!editHost}
|
||||
host={editHost}
|
||||
onClose={() => setEditHost(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteHost && (
|
||||
<DeleteL4HostDialog
|
||||
open={!!deleteHost}
|
||||
host={deleteHost}
|
||||
onClose={() => setDeleteHost(null)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
276
app/(dashboard)/l4-proxy-hosts/actions.ts
Normal file
276
app/(dashboard)/l4-proxy-hosts/actions.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireAdmin } from "@/src/lib/auth";
|
||||
import { actionError, actionSuccess, INITIAL_ACTION_STATE, type ActionState } from "@/src/lib/actions";
|
||||
import {
|
||||
createL4ProxyHost,
|
||||
deleteL4ProxyHost,
|
||||
updateL4ProxyHost,
|
||||
type L4ProxyHostInput,
|
||||
type L4Protocol,
|
||||
type L4MatcherType,
|
||||
type L4ProxyProtocolVersion,
|
||||
type L4LoadBalancingPolicy,
|
||||
type L4LoadBalancerConfig,
|
||||
type L4DnsResolverConfig,
|
||||
type L4UpstreamDnsResolutionConfig,
|
||||
type L4GeoBlockConfig,
|
||||
type L4GeoBlockMode,
|
||||
} from "@/src/lib/models/l4-proxy-hosts";
|
||||
import { parseCheckbox, parseCsv, parseUpstreams, parseOptionalText, parseOptionalNumber } from "@/src/lib/form-parse";
|
||||
|
||||
const VALID_PROTOCOLS: L4Protocol[] = ["tcp", "udp"];
|
||||
const VALID_MATCHER_TYPES: L4MatcherType[] = ["none", "tls_sni", "http_host", "proxy_protocol"];
|
||||
const VALID_PP_VERSIONS: L4ProxyProtocolVersion[] = ["v1", "v2"];
|
||||
const VALID_L4_LB_POLICIES: L4LoadBalancingPolicy[] = ["random", "round_robin", "least_conn", "ip_hash", "first"];
|
||||
const VALID_DNS_FAMILIES = ["ipv6", "ipv4", "both"] as const;
|
||||
|
||||
function parseL4LoadBalancerConfig(formData: FormData): Partial<L4LoadBalancerConfig> | undefined {
|
||||
if (!formData.has("lb_present")) return undefined;
|
||||
const enabled = formData.has("lb_enabled_present")
|
||||
? parseCheckbox(formData.get("lb_enabled"))
|
||||
: undefined;
|
||||
const policyRaw = parseOptionalText(formData.get("lb_policy"));
|
||||
const policy = policyRaw && VALID_L4_LB_POLICIES.includes(policyRaw as L4LoadBalancingPolicy)
|
||||
? (policyRaw as L4LoadBalancingPolicy) : undefined;
|
||||
|
||||
const result: Partial<L4LoadBalancerConfig> = {};
|
||||
if (enabled !== undefined) result.enabled = enabled;
|
||||
if (policy) result.policy = policy;
|
||||
const tryDuration = parseOptionalText(formData.get("lb_try_duration"));
|
||||
if (tryDuration !== null) result.tryDuration = tryDuration;
|
||||
const tryInterval = parseOptionalText(formData.get("lb_try_interval"));
|
||||
if (tryInterval !== null) result.tryInterval = tryInterval;
|
||||
const retries = parseOptionalNumber(formData.get("lb_retries"));
|
||||
if (retries !== null) result.retries = retries;
|
||||
|
||||
// Active health check
|
||||
if (formData.has("lb_active_health_enabled_present")) {
|
||||
result.activeHealthCheck = {
|
||||
enabled: parseCheckbox(formData.get("lb_active_health_enabled")),
|
||||
port: parseOptionalNumber(formData.get("lb_active_health_port")),
|
||||
interval: parseOptionalText(formData.get("lb_active_health_interval")),
|
||||
timeout: parseOptionalText(formData.get("lb_active_health_timeout")),
|
||||
};
|
||||
}
|
||||
|
||||
// Passive health check
|
||||
if (formData.has("lb_passive_health_enabled_present")) {
|
||||
result.passiveHealthCheck = {
|
||||
enabled: parseCheckbox(formData.get("lb_passive_health_enabled")),
|
||||
failDuration: parseOptionalText(formData.get("lb_passive_health_fail_duration")),
|
||||
maxFails: parseOptionalNumber(formData.get("lb_passive_health_max_fails")),
|
||||
unhealthyLatency: parseOptionalText(formData.get("lb_passive_health_unhealthy_latency")),
|
||||
};
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
function parseL4DnsResolverConfig(formData: FormData): Partial<L4DnsResolverConfig> | undefined {
|
||||
if (!formData.has("dns_present")) return undefined;
|
||||
const enabled = formData.has("dns_enabled_present")
|
||||
? parseCheckbox(formData.get("dns_enabled"))
|
||||
: undefined;
|
||||
const resolversRaw = parseOptionalText(formData.get("dns_resolvers"));
|
||||
const resolvers = resolversRaw
|
||||
? resolversRaw.split(/[\n,]/).map(s => s.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
const fallbacksRaw = parseOptionalText(formData.get("dns_fallbacks"));
|
||||
const fallbacks = fallbacksRaw
|
||||
? fallbacksRaw.split(/[\n,]/).map(s => s.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
const timeout = parseOptionalText(formData.get("dns_timeout"));
|
||||
|
||||
const result: Partial<L4DnsResolverConfig> = {};
|
||||
if (enabled !== undefined) result.enabled = enabled;
|
||||
if (resolvers) result.resolvers = resolvers;
|
||||
if (fallbacks) result.fallbacks = fallbacks;
|
||||
if (timeout !== null) result.timeout = timeout;
|
||||
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
function parseL4UpstreamDnsResolutionConfig(formData: FormData): Partial<L4UpstreamDnsResolutionConfig> | undefined {
|
||||
if (!formData.has("upstream_dns_resolution_present")) return undefined;
|
||||
const modeRaw = parseOptionalText(formData.get("upstream_dns_resolution_mode")) ?? "inherit";
|
||||
const familyRaw = parseOptionalText(formData.get("upstream_dns_resolution_family")) ?? "inherit";
|
||||
|
||||
const result: Partial<L4UpstreamDnsResolutionConfig> = {};
|
||||
if (modeRaw === "enabled") result.enabled = true;
|
||||
else if (modeRaw === "disabled") result.enabled = false;
|
||||
else if (modeRaw === "inherit") result.enabled = null;
|
||||
|
||||
if (familyRaw === "inherit") result.family = null;
|
||||
else if (VALID_DNS_FAMILIES.includes(familyRaw as typeof VALID_DNS_FAMILIES[number])) {
|
||||
result.family = familyRaw as "ipv6" | "ipv4" | "both";
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
function parseL4GeoBlockConfig(formData: FormData): { geoblock: L4GeoBlockConfig | null; geoblock_mode: L4GeoBlockMode } {
|
||||
if (!formData.has("geoblock_present")) {
|
||||
return { geoblock: null, geoblock_mode: "merge" };
|
||||
}
|
||||
const enabled = parseCheckbox(formData.get("geoblock_enabled"));
|
||||
const rawMode = formData.get("geoblock_mode");
|
||||
const mode: L4GeoBlockMode = rawMode === "override" ? "override" : "merge";
|
||||
|
||||
const parseStringList = (key: string): string[] => {
|
||||
const val = formData.get(key);
|
||||
if (!val || typeof val !== "string") return [];
|
||||
return val.split(",").map(s => s.trim()).filter(Boolean);
|
||||
};
|
||||
const parseNumberList = (key: string): number[] => {
|
||||
return parseStringList(key).map(s => parseInt(s, 10)).filter(n => !isNaN(n));
|
||||
};
|
||||
|
||||
const config: L4GeoBlockConfig = {
|
||||
enabled,
|
||||
block_countries: parseStringList("geoblock_block_countries"),
|
||||
block_continents: parseStringList("geoblock_block_continents"),
|
||||
block_asns: parseNumberList("geoblock_block_asns"),
|
||||
block_cidrs: parseStringList("geoblock_block_cidrs"),
|
||||
block_ips: parseStringList("geoblock_block_ips"),
|
||||
allow_countries: parseStringList("geoblock_allow_countries"),
|
||||
allow_continents: parseStringList("geoblock_allow_continents"),
|
||||
allow_asns: parseNumberList("geoblock_allow_asns"),
|
||||
allow_cidrs: parseStringList("geoblock_allow_cidrs"),
|
||||
allow_ips: parseStringList("geoblock_allow_ips"),
|
||||
};
|
||||
return { geoblock: config, geoblock_mode: mode };
|
||||
}
|
||||
|
||||
function parseProtocol(formData: FormData): L4Protocol {
|
||||
const raw = String(formData.get("protocol") ?? "tcp").trim().toLowerCase();
|
||||
if (VALID_PROTOCOLS.includes(raw as L4Protocol)) return raw as L4Protocol;
|
||||
return "tcp";
|
||||
}
|
||||
|
||||
function parseMatcherType(formData: FormData): L4MatcherType {
|
||||
const raw = String(formData.get("matcher_type") ?? "none").trim();
|
||||
if (VALID_MATCHER_TYPES.includes(raw as L4MatcherType)) return raw as L4MatcherType;
|
||||
return "none";
|
||||
}
|
||||
|
||||
function parseProxyProtocolVersion(formData: FormData): L4ProxyProtocolVersion | null {
|
||||
const raw = parseOptionalText(formData.get("proxy_protocol_version"));
|
||||
if (raw && VALID_PP_VERSIONS.includes(raw as L4ProxyProtocolVersion)) return raw as L4ProxyProtocolVersion;
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function createL4ProxyHostAction(
|
||||
_prevState: ActionState = INITIAL_ACTION_STATE,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
void _prevState;
|
||||
try {
|
||||
const session = await requireAdmin();
|
||||
const userId = Number(session.user.id);
|
||||
|
||||
const matcherType = parseMatcherType(formData);
|
||||
const matcherValue = (matcherType === "tls_sni" || matcherType === "http_host")
|
||||
? parseCsv(formData.get("matcher_value"))
|
||||
: [];
|
||||
|
||||
const input: L4ProxyHostInput = {
|
||||
name: String(formData.get("name") ?? "Untitled"),
|
||||
protocol: parseProtocol(formData),
|
||||
listen_address: String(formData.get("listen_address") ?? "").trim(),
|
||||
upstreams: parseUpstreams(formData.get("upstreams")),
|
||||
matcher_type: matcherType,
|
||||
matcher_value: matcherValue,
|
||||
tls_termination: parseCheckbox(formData.get("tls_termination")),
|
||||
proxy_protocol_version: parseProxyProtocolVersion(formData),
|
||||
proxy_protocol_receive: parseCheckbox(formData.get("proxy_protocol_receive")),
|
||||
enabled: parseCheckbox(formData.get("enabled")),
|
||||
load_balancer: parseL4LoadBalancerConfig(formData),
|
||||
dns_resolver: parseL4DnsResolverConfig(formData),
|
||||
upstream_dns_resolution: parseL4UpstreamDnsResolutionConfig(formData),
|
||||
...parseL4GeoBlockConfig(formData),
|
||||
};
|
||||
|
||||
await createL4ProxyHost(input, userId);
|
||||
revalidatePath("/l4-proxy-hosts");
|
||||
return actionSuccess("L4 proxy host created and queued for Caddy reload.");
|
||||
} catch (error) {
|
||||
console.error("Failed to create L4 proxy host:", error);
|
||||
return actionError(error, "Failed to create L4 proxy host.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateL4ProxyHostAction(
|
||||
id: number,
|
||||
_prevState: ActionState = INITIAL_ACTION_STATE,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
void _prevState;
|
||||
try {
|
||||
const session = await requireAdmin();
|
||||
const userId = Number(session.user.id);
|
||||
|
||||
const matcherType = parseMatcherType(formData);
|
||||
const matcherValue = (matcherType === "tls_sni" || matcherType === "http_host")
|
||||
? parseCsv(formData.get("matcher_value"))
|
||||
: [];
|
||||
|
||||
const input: Partial<L4ProxyHostInput> = {
|
||||
name: formData.get("name") ? String(formData.get("name")) : undefined,
|
||||
protocol: parseProtocol(formData),
|
||||
listen_address: formData.get("listen_address") ? String(formData.get("listen_address")).trim() : undefined,
|
||||
upstreams: formData.get("upstreams") ? parseUpstreams(formData.get("upstreams")) : undefined,
|
||||
matcher_type: matcherType,
|
||||
matcher_value: matcherValue,
|
||||
tls_termination: parseCheckbox(formData.get("tls_termination")),
|
||||
proxy_protocol_version: parseProxyProtocolVersion(formData),
|
||||
proxy_protocol_receive: parseCheckbox(formData.get("proxy_protocol_receive")),
|
||||
enabled: formData.has("enabled_present") ? parseCheckbox(formData.get("enabled")) : undefined,
|
||||
load_balancer: parseL4LoadBalancerConfig(formData),
|
||||
dns_resolver: parseL4DnsResolverConfig(formData),
|
||||
upstream_dns_resolution: parseL4UpstreamDnsResolutionConfig(formData),
|
||||
...parseL4GeoBlockConfig(formData),
|
||||
};
|
||||
|
||||
await updateL4ProxyHost(id, input, userId);
|
||||
revalidatePath("/l4-proxy-hosts");
|
||||
return actionSuccess("L4 proxy host updated.");
|
||||
} catch (error) {
|
||||
console.error(`Failed to update L4 proxy host ${id}:`, error);
|
||||
return actionError(error, "Failed to update L4 proxy host.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteL4ProxyHostAction(
|
||||
id: number,
|
||||
_prevState: ActionState = INITIAL_ACTION_STATE
|
||||
): Promise<ActionState> {
|
||||
void _prevState;
|
||||
try {
|
||||
const session = await requireAdmin();
|
||||
const userId = Number(session.user.id);
|
||||
await deleteL4ProxyHost(id, userId);
|
||||
revalidatePath("/l4-proxy-hosts");
|
||||
return actionSuccess("L4 proxy host deleted.");
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete L4 proxy host ${id}:`, error);
|
||||
return actionError(error, "Failed to delete L4 proxy host.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleL4ProxyHostAction(
|
||||
id: number,
|
||||
enabled: boolean
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
const session = await requireAdmin();
|
||||
const userId = Number(session.user.id);
|
||||
await updateL4ProxyHost(id, { enabled }, userId);
|
||||
revalidatePath("/l4-proxy-hosts");
|
||||
return actionSuccess(`L4 proxy host ${enabled ? "enabled" : "disabled"}.`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to toggle L4 proxy host ${id}:`, error);
|
||||
return actionError(error, "Failed to toggle L4 proxy host.");
|
||||
}
|
||||
}
|
||||
30
app/(dashboard)/l4-proxy-hosts/page.tsx
Normal file
30
app/(dashboard)/l4-proxy-hosts/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import L4ProxyHostsClient from "./L4ProxyHostsClient";
|
||||
import { listL4ProxyHostsPaginated, countL4ProxyHosts } from "@/src/lib/models/l4-proxy-hosts";
|
||||
import { requireAdmin } from "@/src/lib/auth";
|
||||
|
||||
const PER_PAGE = 25;
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ page?: string; search?: string }>;
|
||||
}
|
||||
|
||||
export default async function L4ProxyHostsPage({ searchParams }: PageProps) {
|
||||
await requireAdmin();
|
||||
const { page: pageParam, search: searchParam } = await searchParams;
|
||||
const page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1);
|
||||
const search = searchParam?.trim() || undefined;
|
||||
const offset = (page - 1) * PER_PAGE;
|
||||
|
||||
const [hosts, total] = await Promise.all([
|
||||
listL4ProxyHostsPaginated(PER_PAGE, offset, search),
|
||||
countL4ProxyHosts(search),
|
||||
]);
|
||||
|
||||
return (
|
||||
<L4ProxyHostsClient
|
||||
hosts={hosts}
|
||||
pagination={{ total, page, perPage: PER_PAGE }}
|
||||
initialSearch={search ?? ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
39
app/api/l4-ports/route.ts
Normal file
39
app/api/l4-ports/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireAdmin, checkSameOrigin } from "@/src/lib/auth";
|
||||
import { getL4PortsDiff, getL4PortsStatus, applyL4Ports } from "@/src/lib/l4-ports";
|
||||
|
||||
/**
|
||||
* GET /api/l4-ports — returns current port diff and apply status.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireAdmin();
|
||||
const [diff, status] = await Promise.all([
|
||||
getL4PortsDiff(),
|
||||
getL4PortsStatus(),
|
||||
]);
|
||||
return NextResponse.json({ diff, status });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/l4-ports — trigger port apply (write override + trigger file).
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const originCheck = checkSameOrigin(request);
|
||||
if (originCheck) return originCheck;
|
||||
|
||||
try {
|
||||
await requireAdmin();
|
||||
const status = await applyL4Ports();
|
||||
return NextResponse.json({ status });
|
||||
} catch (error) {
|
||||
console.error("Failed to apply L4 ports:", error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Failed to apply L4 ports" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,10 @@ services:
|
||||
- "80:80/udp"
|
||||
- "443:443"
|
||||
- "443:443/udp"
|
||||
# L4 (TCP/UDP) proxy ports — expose as needed for L4 proxy hosts, e.g.:
|
||||
# - "5432:5432" # PostgreSQL
|
||||
# - "3306:3306" # MySQL
|
||||
# - "6379:6379" # Redis
|
||||
# Admin API (port 2019) is only exposed on internal network for security
|
||||
# Web UI accesses via http://caddy:2019 internally
|
||||
# Uncomment the line below to expose metrics externally for Grafana/Prometheus
|
||||
@@ -110,6 +114,27 @@ services:
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# L4 Port Manager sidecar — automatically recreates the caddy container
|
||||
# when L4 proxy host ports change.
|
||||
# Requires Docker socket access (read-only) to recreate the caddy container.
|
||||
l4-port-manager:
|
||||
container_name: caddy-proxy-manager-l4-ports
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/l4-port-manager/Dockerfile
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATA_DIR: /data
|
||||
COMPOSE_DIR: /compose
|
||||
POLL_INTERVAL: "${L4_PORT_MANAGER_POLL_INTERVAL:-2}"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- caddy-manager-data:/data
|
||||
- .:/compose:ro
|
||||
depends_on:
|
||||
caddy:
|
||||
condition: service_healthy
|
||||
|
||||
geoipupdate:
|
||||
container_name: geoipupdate-${HOSTNAME}
|
||||
image: ghcr.io/maxmind/geoipupdate
|
||||
|
||||
9
docker/l4-port-manager/Dockerfile
Normal file
9
docker/l4-port-manager/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
||||
FROM docker:cli
|
||||
|
||||
# Only need docker compose CLI and basic shell tools
|
||||
RUN apk add --no-cache bash
|
||||
|
||||
COPY docker/l4-port-manager/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
167
docker/l4-port-manager/entrypoint.sh
Executable file
167
docker/l4-port-manager/entrypoint.sh
Executable file
@@ -0,0 +1,167 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# L4 Port Manager Sidecar
|
||||
#
|
||||
# On startup: always applies the current L4 ports override so the caddy
|
||||
# container has the correct ports bound (the main compose stack starts caddy
|
||||
# without the L4 ports override file).
|
||||
#
|
||||
# During runtime: watches the trigger file for changes and re-applies when
|
||||
# the web app signals that port configuration has changed.
|
||||
#
|
||||
# Only ever recreates the caddy container — never touches any other service.
|
||||
#
|
||||
# Environment variables:
|
||||
# DATA_DIR - Path to shared data volume (default: /data)
|
||||
# COMPOSE_DIR - Path to compose files (default: /compose)
|
||||
# CADDY_CONTAINER_NAME - Caddy container name for project auto-detection (default: caddy-proxy-manager-caddy)
|
||||
# COMPOSE_PROJECT_NAME - Override compose project name (auto-detected from caddy container labels if unset)
|
||||
# POLL_INTERVAL - Seconds between trigger file checks (default: 2)
|
||||
|
||||
set -e
|
||||
|
||||
DATA_DIR="${DATA_DIR:-/data}"
|
||||
COMPOSE_DIR="${COMPOSE_DIR:-/compose}"
|
||||
POLL_INTERVAL="${POLL_INTERVAL:-2}"
|
||||
CADDY_CONTAINER_NAME="${CADDY_CONTAINER_NAME:-caddy-proxy-manager-caddy}"
|
||||
|
||||
TRIGGER_FILE="$DATA_DIR/l4-ports.trigger"
|
||||
STATUS_FILE="$DATA_DIR/l4-ports.status"
|
||||
OVERRIDE_FILE="$DATA_DIR/docker-compose.l4-ports.yml"
|
||||
|
||||
log() {
|
||||
echo "[l4-port-manager] $(date -u '+%Y-%m-%dT%H:%M:%SZ') $*"
|
||||
}
|
||||
|
||||
write_status() {
|
||||
state="$1"
|
||||
message="$2"
|
||||
applied_at="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
||||
error="${3:-}"
|
||||
|
||||
cat > "$STATUS_FILE" <<STATUSEOF
|
||||
{
|
||||
"state": "$state",
|
||||
"message": "$message",
|
||||
"appliedAt": "$applied_at"$([ -n "$error" ] && echo ",
|
||||
\"error\": \"$error\"" || echo "")
|
||||
}
|
||||
STATUSEOF
|
||||
}
|
||||
|
||||
# Auto-detect the Docker Compose project name from the running caddy container's labels.
|
||||
# This ensures we operate on the correct project regardless of where compose files are mounted.
|
||||
detect_project_name() {
|
||||
if [ -n "$COMPOSE_PROJECT_NAME" ]; then
|
||||
echo "$COMPOSE_PROJECT_NAME"
|
||||
return
|
||||
fi
|
||||
detected=$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' "$CADDY_CONTAINER_NAME" 2>/dev/null || echo "")
|
||||
if [ -n "$detected" ]; then
|
||||
echo "$detected"
|
||||
else
|
||||
echo "caddy-proxy-manager"
|
||||
fi
|
||||
}
|
||||
|
||||
# Apply the current port override — recreates only the caddy container.
|
||||
do_apply() {
|
||||
COMPOSE_PROJECT="$(detect_project_name)"
|
||||
log "Using compose project: $COMPOSE_PROJECT"
|
||||
|
||||
# Build compose args. Files are read from COMPOSE_DIR (container path).
|
||||
# COMPOSE_HOST_DIR (when set) is passed as --project-directory so the Docker
|
||||
# daemon resolves relative bind-mount paths (e.g. ./geoip-data) against the
|
||||
# actual host project directory rather than the sidecar's /compose mount.
|
||||
COMPOSE_ARGS="-p $COMPOSE_PROJECT"
|
||||
if [ -n "$COMPOSE_HOST_DIR" ]; then
|
||||
COMPOSE_ARGS="$COMPOSE_ARGS --project-directory $COMPOSE_HOST_DIR"
|
||||
fi
|
||||
# Explicitly supply the .env file so required variables are available even
|
||||
# when --project-directory points to a host path not mounted in the sidecar.
|
||||
if [ -f "$COMPOSE_DIR/.env" ]; then
|
||||
COMPOSE_ARGS="$COMPOSE_ARGS --env-file $COMPOSE_DIR/.env"
|
||||
fi
|
||||
COMPOSE_ARGS="$COMPOSE_ARGS -f $COMPOSE_DIR/docker-compose.yml"
|
||||
if [ -f "$COMPOSE_DIR/docker-compose.override.yml" ]; then
|
||||
COMPOSE_ARGS="$COMPOSE_ARGS -f $COMPOSE_DIR/docker-compose.override.yml"
|
||||
fi
|
||||
if [ -f "$OVERRIDE_FILE" ]; then
|
||||
COMPOSE_ARGS="$COMPOSE_ARGS -f $OVERRIDE_FILE"
|
||||
fi
|
||||
|
||||
write_status "applying" "Recreating caddy container with updated ports..."
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
if docker compose $COMPOSE_ARGS up -d --no-deps --force-recreate caddy 2>&1; then
|
||||
log "Caddy container recreated successfully."
|
||||
|
||||
# Wait for caddy healthcheck to pass
|
||||
HEALTH_TIMEOUT=30
|
||||
HEALTH_WAITED=0
|
||||
HEALTH="unknown"
|
||||
while [ "$HEALTH_WAITED" -lt "$HEALTH_TIMEOUT" ]; do
|
||||
HEALTH=$(docker inspect --format='{{.State.Health.Status}}' "$CADDY_CONTAINER_NAME" 2>/dev/null || echo "unknown")
|
||||
if [ "$HEALTH" = "healthy" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
HEALTH_WAITED=$((HEALTH_WAITED + 1))
|
||||
done
|
||||
|
||||
if [ "$HEALTH" = "healthy" ]; then
|
||||
write_status "applied" "Caddy container recreated and healthy with updated ports."
|
||||
log "Caddy is healthy."
|
||||
else
|
||||
write_status "applied" "Caddy container recreated but health check status: $HEALTH (may still be starting)."
|
||||
log "Warning: Caddy health status is '$HEALTH' after ${HEALTH_TIMEOUT}s."
|
||||
fi
|
||||
else
|
||||
ERROR_MSG="Failed to recreate caddy container. Check Docker logs."
|
||||
write_status "failed" "$ERROR_MSG" "$ERROR_MSG"
|
||||
log "ERROR: $ERROR_MSG"
|
||||
fi
|
||||
|
||||
# Delete the trigger file after processing so stale triggers don't cause
|
||||
# "Waiting for port manager sidecar..." on the next boot.
|
||||
rm -f "$TRIGGER_FILE"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Startup: always apply the override so caddy has the correct ports bound.
|
||||
# (The main compose stack starts caddy without the L4 ports override file.)
|
||||
# Only apply if the override file exists — it is created on first "Apply Ports".
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ -f "$OVERRIDE_FILE" ]; then
|
||||
log "Startup: applying existing L4 port override..."
|
||||
do_apply
|
||||
else
|
||||
write_status "idle" "Port manager sidecar is running and ready."
|
||||
log "Started. No L4 port override file yet."
|
||||
fi
|
||||
|
||||
# Capture the current trigger content so the poll loop doesn't re-apply
|
||||
# a trigger that was already handled (either above or before this boot).
|
||||
# Use explicit assignment — do NOT use ${VAR:-fallback} which treats empty as unset.
|
||||
LAST_TRIGGER=$(cat "$TRIGGER_FILE" 2>/dev/null || echo "")
|
||||
|
||||
log "Watching $TRIGGER_FILE for changes (poll every ${POLL_INTERVAL}s)"
|
||||
|
||||
while true; do
|
||||
sleep "$POLL_INTERVAL"
|
||||
|
||||
CURRENT_TRIGGER=$(cat "$TRIGGER_FILE" 2>/dev/null || echo "")
|
||||
if [ "$CURRENT_TRIGGER" = "$LAST_TRIGGER" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Empty trigger means the file was just deleted — nothing to do.
|
||||
if [ -z "$CURRENT_TRIGGER" ]; then
|
||||
LAST_TRIGGER=""
|
||||
continue
|
||||
fi
|
||||
|
||||
LAST_TRIGGER="$CURRENT_TRIGGER"
|
||||
log "Trigger changed. Applying port changes..."
|
||||
do_apply
|
||||
done
|
||||
17
drizzle/0015_l4_proxy_hosts.sql
Normal file
17
drizzle/0015_l4_proxy_hosts.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE `l4_proxy_hosts` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`protocol` text NOT NULL,
|
||||
`listen_address` text NOT NULL,
|
||||
`upstreams` text NOT NULL,
|
||||
`matcher_type` text NOT NULL DEFAULT 'none',
|
||||
`matcher_value` text,
|
||||
`tls_termination` integer NOT NULL DEFAULT false,
|
||||
`proxy_protocol_version` text,
|
||||
`proxy_protocol_receive` integer NOT NULL DEFAULT false,
|
||||
`owner_user_id` integer REFERENCES `users`(`id`) ON DELETE SET NULL,
|
||||
`meta` text,
|
||||
`enabled` integer NOT NULL DEFAULT true,
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL
|
||||
);
|
||||
@@ -106,6 +106,13 @@
|
||||
"when": 1772806000000,
|
||||
"tag": "0014_waf_blocked",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "6",
|
||||
"when": 1774300000000,
|
||||
"tag": "0015_l4_proxy_hosts",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
466
src/components/l4-proxy-hosts/L4HostDialogs.tsx
Normal file
466
src/components/l4-proxy-hosts/L4HostDialogs.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
135
src/components/l4-proxy-hosts/L4PortsApplyBanner.tsx
Normal file
135
src/components/l4-proxy-hosts/L4PortsApplyBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
218
src/lib/caddy.ts
218
src/lib/caddy.ts
@@ -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(),
|
||||
});
|
||||
|
||||
192
src/lib/l4-ports.ts
Normal file
192
src/lib/l4-ports.ts
Normal file
@@ -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);
|
||||
}
|
||||
672
src/lib/models/l4-proxy-hosts.ts
Normal file
672
src/lib/models/l4-proxy-hosts.ts
Normal file
@@ -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();
|
||||
}
|
||||
@@ -10,6 +10,11 @@ services:
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
# L4 test ports (TCP)
|
||||
- "15432:15432"
|
||||
- "15433:15433"
|
||||
# L4 test ports (UDP)
|
||||
- "15353:15353/udp"
|
||||
# Lightweight echo server reachable by Caddy as "echo-server:8080".
|
||||
# Returns a fixed body so tests can assert the proxy routed the request.
|
||||
echo-server:
|
||||
@@ -30,6 +35,23 @@ services:
|
||||
image: traefik/whoami
|
||||
networks:
|
||||
- caddy-network
|
||||
# TCP echo server for L4 proxy tests.
|
||||
# Listens on port 9000 and echoes back anything sent to it with a prefix.
|
||||
tcp-echo:
|
||||
image: cjimti/go-echo
|
||||
environment:
|
||||
TCP_PORT: "9000"
|
||||
NODE_NAME: "tcp-echo-ok"
|
||||
networks:
|
||||
- caddy-network
|
||||
# UDP echo server for L4 proxy tests.
|
||||
# Simple socat-based UDP echo: reflects any datagram back to sender.
|
||||
udp-echo:
|
||||
image: alpine/socat
|
||||
command: ["UDP4-RECVFROM:9001,fork", "EXEC:cat"]
|
||||
networks:
|
||||
- caddy-network
|
||||
|
||||
volumes:
|
||||
caddy-manager-data:
|
||||
name: caddy-manager-data-test
|
||||
|
||||
134
tests/e2e/functional/l4-proxy-routing.spec.ts
Normal file
134
tests/e2e/functional/l4-proxy-routing.spec.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Functional tests: L4 (TCP/UDP) proxy routing.
|
||||
*
|
||||
* Creates real L4 proxy hosts pointing at echo containers,
|
||||
* then sends raw TCP connections and UDP datagrams through Caddy
|
||||
* and asserts the traffic reaches the upstream.
|
||||
*
|
||||
* Test ports exposed on the Caddy container:
|
||||
* TCP: 15432, 15433
|
||||
* UDP: 15353
|
||||
*
|
||||
* Upstream services:
|
||||
* tcp-echo (cjimti/go-echo on port 9000) — echoes TCP data
|
||||
* udp-echo (alpine/socat on port 9001) — echoes UDP datagrams
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { createL4ProxyHost } from '../../helpers/l4-proxy-api';
|
||||
import { tcpSend, waitForTcpRoute, tcpConnect, udpSend, waitForUdpRoute } from '../../helpers/tcp';
|
||||
|
||||
const TCP_PORT = 15432;
|
||||
const TCP_PORT_2 = 15433;
|
||||
const UDP_PORT = 15353;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TCP routing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe.serial('L4 TCP Proxy Routing', () => {
|
||||
test('setup: create TCP proxy host pointing at tcp-echo', async ({ page }) => {
|
||||
await createL4ProxyHost(page, {
|
||||
name: 'L4 TCP Echo Test',
|
||||
protocol: 'tcp',
|
||||
listenAddress: `:${TCP_PORT}`,
|
||||
upstream: 'tcp-echo:9000',
|
||||
});
|
||||
await waitForTcpRoute('127.0.0.1', TCP_PORT);
|
||||
});
|
||||
|
||||
test('routes TCP traffic to the upstream echo server', async () => {
|
||||
const res = await tcpSend('127.0.0.1', TCP_PORT, 'hello from test\n');
|
||||
expect(res.connected).toBe(true);
|
||||
expect(res.data).toContain('hello from test');
|
||||
});
|
||||
|
||||
test('TCP connection is accepted on the L4 port', async () => {
|
||||
const connected = await tcpConnect('127.0.0.1', TCP_PORT);
|
||||
expect(connected).toBe(true);
|
||||
});
|
||||
|
||||
test('unused TCP port does not accept connections', async () => {
|
||||
const connected = await tcpConnect('127.0.0.1', TCP_PORT_2, 2000);
|
||||
expect(connected).toBe(false);
|
||||
});
|
||||
|
||||
test('disabled TCP proxy host stops accepting connections', async ({ page }) => {
|
||||
await page.goto('/l4-proxy-hosts');
|
||||
const row = page.locator('tr', { hasText: 'L4 TCP Echo Test' });
|
||||
await row.locator('input[type="checkbox"]').first().click({ force: true });
|
||||
await page.waitForTimeout(3_000);
|
||||
|
||||
const connected = await tcpConnect('127.0.0.1', TCP_PORT, 2000);
|
||||
expect(connected).toBe(false);
|
||||
|
||||
// Re-enable
|
||||
await row.locator('input[type="checkbox"]').first().click({ force: true });
|
||||
await page.waitForTimeout(2_000);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe.serial('L4 Multiple TCP Hosts', () => {
|
||||
test('setup: create second TCP proxy host on different port', async ({ page }) => {
|
||||
await createL4ProxyHost(page, {
|
||||
name: 'L4 TCP Echo Test 2',
|
||||
protocol: 'tcp',
|
||||
listenAddress: `:${TCP_PORT_2}`,
|
||||
upstream: 'tcp-echo:9000',
|
||||
});
|
||||
await waitForTcpRoute('127.0.0.1', TCP_PORT_2);
|
||||
});
|
||||
|
||||
test('both TCP ports route traffic independently', async () => {
|
||||
const res1 = await tcpSend('127.0.0.1', TCP_PORT, 'port1\n');
|
||||
const res2 = await tcpSend('127.0.0.1', TCP_PORT_2, 'port2\n');
|
||||
expect(res1.connected).toBe(true);
|
||||
expect(res2.connected).toBe(true);
|
||||
expect(res1.data).toContain('port1');
|
||||
expect(res2.data).toContain('port2');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UDP routing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe.serial('L4 UDP Proxy Routing', () => {
|
||||
test('setup: create UDP proxy host pointing at udp-echo', async ({ page }) => {
|
||||
await createL4ProxyHost(page, {
|
||||
name: 'L4 UDP Echo Test',
|
||||
protocol: 'udp',
|
||||
listenAddress: `:${UDP_PORT}`,
|
||||
upstream: 'udp-echo:9001',
|
||||
});
|
||||
await waitForUdpRoute('127.0.0.1', UDP_PORT);
|
||||
});
|
||||
|
||||
test('routes UDP datagrams to the upstream echo server', async () => {
|
||||
const res = await udpSend('127.0.0.1', UDP_PORT, 'hello udp');
|
||||
expect(res.received).toBe(true);
|
||||
expect(res.data).toContain('hello udp');
|
||||
});
|
||||
|
||||
test('sends multiple UDP datagrams independently', async () => {
|
||||
const res1 = await udpSend('127.0.0.1', UDP_PORT, 'datagram-1');
|
||||
const res2 = await udpSend('127.0.0.1', UDP_PORT, 'datagram-2');
|
||||
expect(res1.received).toBe(true);
|
||||
expect(res2.received).toBe(true);
|
||||
expect(res1.data).toContain('datagram-1');
|
||||
expect(res2.data).toContain('datagram-2');
|
||||
});
|
||||
|
||||
test('disabled UDP proxy host stops responding', async ({ page }) => {
|
||||
await page.goto('/l4-proxy-hosts');
|
||||
const row = page.locator('tr', { hasText: 'L4 UDP Echo Test' });
|
||||
await row.locator('input[type="checkbox"]').first().click({ force: true });
|
||||
await page.waitForTimeout(3_000);
|
||||
|
||||
const res = await udpSend('127.0.0.1', UDP_PORT, 'should-fail', 2000);
|
||||
expect(res.received).toBe(false);
|
||||
|
||||
// Re-enable
|
||||
await row.locator('input[type="checkbox"]').first().click({ force: true });
|
||||
await page.waitForTimeout(2_000);
|
||||
});
|
||||
});
|
||||
68
tests/e2e/l4-proxy-hosts.spec.ts
Normal file
68
tests/e2e/l4-proxy-hosts.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* E2E tests: L4 Proxy Hosts page.
|
||||
*
|
||||
* Verifies the L4 Proxy Hosts UI — navigation, list, create/edit/delete dialogs.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('L4 Proxy Hosts page', () => {
|
||||
test('is accessible from sidebar navigation', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('link', { name: /l4 proxy hosts/i }).click();
|
||||
await expect(page).toHaveURL(/\/l4-proxy-hosts/);
|
||||
await expect(page.getByText('L4 Proxy Hosts')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows empty state when no L4 hosts exist', async ({ page }) => {
|
||||
await page.goto('/l4-proxy-hosts');
|
||||
await expect(page.getByText(/no l4 proxy hosts found/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('create dialog opens and contains expected fields', async ({ page }) => {
|
||||
await page.goto('/l4-proxy-hosts');
|
||||
await page.getByRole('button', { name: /create l4 host/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Verify key form fields exist
|
||||
await expect(page.getByLabel('Name')).toBeVisible();
|
||||
await expect(page.getByLabel('Protocol')).toBeVisible();
|
||||
await expect(page.getByLabel('Listen Address')).toBeVisible();
|
||||
await expect(page.getByLabel('Upstreams')).toBeVisible();
|
||||
await expect(page.getByLabel('Matcher')).toBeVisible();
|
||||
});
|
||||
|
||||
test('creates a new L4 proxy host', async ({ page }) => {
|
||||
await page.goto('/l4-proxy-hosts');
|
||||
await page.getByRole('button', { name: /create l4 host/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await page.getByLabel('Name').fill('E2E Test Host');
|
||||
await page.getByLabel('Listen Address').fill(':19999');
|
||||
await page.getByLabel('Upstreams').fill('10.0.0.1:5432');
|
||||
|
||||
await page.getByRole('button', { name: /create/i }).click();
|
||||
|
||||
// Dialog should close and host should appear in table
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText('E2E Test Host')).toBeVisible();
|
||||
await expect(page.getByText(':19999')).toBeVisible();
|
||||
});
|
||||
|
||||
test('deletes the created L4 proxy host', async ({ page }) => {
|
||||
await page.goto('/l4-proxy-hosts');
|
||||
await expect(page.getByText('E2E Test Host')).toBeVisible();
|
||||
|
||||
// Click delete button in the row
|
||||
const row = page.locator('tr', { hasText: 'E2E Test Host' });
|
||||
await row.getByRole('button', { name: /delete/i }).click();
|
||||
|
||||
// Confirm deletion
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByText(/are you sure/i)).toBeVisible();
|
||||
await page.getByRole('button', { name: /delete/i }).click();
|
||||
|
||||
// Host should be removed
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText('E2E Test Host')).not.toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
80
tests/helpers/l4-proxy-api.ts
Normal file
80
tests/helpers/l4-proxy-api.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Higher-level helpers for creating L4 proxy hosts in E2E tests.
|
||||
*
|
||||
* All helpers accept a Playwright `Page` (pre-authenticated via the
|
||||
* global storageState) so they integrate cleanly with the standard
|
||||
* `page` test fixture.
|
||||
*/
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
export interface L4ProxyHostConfig {
|
||||
name: string;
|
||||
protocol?: 'tcp' | 'udp';
|
||||
listenAddress: string;
|
||||
upstream: string; // e.g. "tcp-echo:9000"
|
||||
matcherType?: 'none' | 'tls_sni' | 'http_host' | 'proxy_protocol';
|
||||
matcherValue?: string; // comma-separated
|
||||
tlsTermination?: boolean;
|
||||
proxyProtocolReceive?: boolean;
|
||||
proxyProtocolVersion?: 'v1' | 'v2';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an L4 proxy host via the browser UI.
|
||||
*/
|
||||
export async function createL4ProxyHost(page: Page, config: L4ProxyHostConfig): Promise<void> {
|
||||
await page.goto('/l4-proxy-hosts');
|
||||
await page.getByRole('button', { name: /create l4 host/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await page.getByLabel('Name').fill(config.name);
|
||||
|
||||
// Protocol select
|
||||
if (config.protocol && config.protocol !== 'tcp') {
|
||||
await page.getByLabel('Protocol').click();
|
||||
await page.getByRole('option', { name: new RegExp(config.protocol, 'i') }).click();
|
||||
}
|
||||
|
||||
await page.getByLabel('Listen Address').fill(config.listenAddress);
|
||||
await page.getByLabel('Upstreams').fill(config.upstream);
|
||||
|
||||
// Matcher type
|
||||
if (config.matcherType && config.matcherType !== 'none') {
|
||||
await page.getByLabel('Matcher').click();
|
||||
const matcherLabels: Record<string, RegExp> = {
|
||||
tls_sni: /tls sni/i,
|
||||
http_host: /http host/i,
|
||||
proxy_protocol: /proxy protocol/i,
|
||||
};
|
||||
await page.getByRole('option', { name: matcherLabels[config.matcherType] }).click();
|
||||
|
||||
if (config.matcherValue && (config.matcherType === 'tls_sni' || config.matcherType === 'http_host')) {
|
||||
await page.getByLabel(/hostnames/i).fill(config.matcherValue);
|
||||
}
|
||||
}
|
||||
|
||||
// TLS termination
|
||||
if (config.tlsTermination) {
|
||||
await page.getByLabel(/tls termination/i).check();
|
||||
}
|
||||
|
||||
// Proxy protocol receive
|
||||
if (config.proxyProtocolReceive) {
|
||||
await page.getByLabel(/accept inbound proxy/i).check();
|
||||
}
|
||||
|
||||
// Proxy protocol version
|
||||
if (config.proxyProtocolVersion) {
|
||||
await page.getByLabel(/send proxy protocol/i).click();
|
||||
await page.getByRole('option', { name: config.proxyProtocolVersion }).click();
|
||||
}
|
||||
|
||||
// Submit
|
||||
await page.getByRole('button', { name: /create/i }).click();
|
||||
|
||||
// Wait for success state (dialog closes or success alert)
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Verify host appears in the table
|
||||
await expect(page.getByText(config.name)).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
161
tests/helpers/tcp.ts
Normal file
161
tests/helpers/tcp.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Low-level TCP and UDP helpers for L4 proxy functional tests.
|
||||
*
|
||||
* Sends raw TCP connections and UDP datagrams to Caddy's L4 proxy ports
|
||||
* and reads responses.
|
||||
*/
|
||||
import net from 'node:net';
|
||||
import dgram from 'node:dgram';
|
||||
|
||||
export interface TcpResponse {
|
||||
data: string;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a TCP connection to the given host:port, send a payload,
|
||||
* and collect whatever comes back within the timeout window.
|
||||
*/
|
||||
export function tcpSend(
|
||||
host: string,
|
||||
port: number,
|
||||
payload: string,
|
||||
timeoutMs = 5_000
|
||||
): Promise<TcpResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
let connected = false;
|
||||
const socket = net.createConnection({ host, port }, () => {
|
||||
connected = true;
|
||||
socket.write(payload);
|
||||
});
|
||||
|
||||
socket.setTimeout(timeoutMs);
|
||||
|
||||
socket.on('data', (chunk) => {
|
||||
data += chunk.toString();
|
||||
});
|
||||
|
||||
socket.on('end', () => {
|
||||
resolve({ data, connected });
|
||||
});
|
||||
|
||||
socket.on('timeout', () => {
|
||||
socket.destroy();
|
||||
resolve({ data, connected });
|
||||
});
|
||||
|
||||
socket.on('error', (err) => {
|
||||
if (connected) {
|
||||
resolve({ data, connected });
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if a TCP port is accepting connections.
|
||||
*/
|
||||
export function tcpConnect(
|
||||
host: string,
|
||||
port: number,
|
||||
timeoutMs = 5_000
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.createConnection({ host, port }, () => {
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
socket.setTimeout(timeoutMs);
|
||||
socket.on('timeout', () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
socket.on('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll until a TCP port accepts connections.
|
||||
* Similar to waitForRoute() but for TCP.
|
||||
*/
|
||||
export async function waitForTcpRoute(
|
||||
host: string,
|
||||
port: number,
|
||||
timeoutMs = 15_000
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const ok = await tcpConnect(host, port, 2000);
|
||||
if (ok) return;
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`TCP port ${host}:${port} not ready after ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UDP helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UdpResponse {
|
||||
data: string;
|
||||
received: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a UDP datagram and wait for a response.
|
||||
*/
|
||||
export function udpSend(
|
||||
host: string,
|
||||
port: number,
|
||||
payload: string,
|
||||
timeoutMs = 5_000
|
||||
): Promise<UdpResponse> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = dgram.createSocket('udp4');
|
||||
let received = false;
|
||||
let data = '';
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
socket.close();
|
||||
resolve({ data, received });
|
||||
}, timeoutMs);
|
||||
|
||||
socket.on('message', (msg) => {
|
||||
received = true;
|
||||
data += msg.toString();
|
||||
clearTimeout(timer);
|
||||
socket.close();
|
||||
resolve({ data, received });
|
||||
});
|
||||
|
||||
socket.on('error', () => {
|
||||
clearTimeout(timer);
|
||||
socket.close();
|
||||
resolve({ data, received });
|
||||
});
|
||||
|
||||
socket.send(payload, port, host);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll until a UDP port responds to a test datagram.
|
||||
*/
|
||||
export async function waitForUdpRoute(
|
||||
host: string,
|
||||
port: number,
|
||||
timeoutMs = 15_000
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const res = await udpSend(host, port, 'ping', 2000);
|
||||
if (res.received) return;
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`UDP port ${host}:${port} not ready after ${timeoutMs}ms`);
|
||||
}
|
||||
572
tests/integration/l4-caddy-config.test.ts
Normal file
572
tests/integration/l4-caddy-config.test.ts
Normal file
@@ -0,0 +1,572 @@
|
||||
/**
|
||||
* Integration tests for L4 Caddy config generation.
|
||||
*
|
||||
* Verifies that the data stored in l4_proxy_hosts can be used to produce
|
||||
* correct caddy-l4 JSON config structures. Tests the config shape that
|
||||
* buildL4Servers() would produce by reconstructing it from DB rows.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb, type TestDb } from '../helpers/db';
|
||||
import { l4ProxyHosts } from '../../src/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
let db: TestDb;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDb();
|
||||
});
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
async function insertL4Host(overrides: Partial<typeof l4ProxyHosts.$inferInsert> = {}) {
|
||||
const now = nowIso();
|
||||
const [host] = await db.insert(l4ProxyHosts).values({
|
||||
name: 'Test L4 Host',
|
||||
protocol: 'tcp',
|
||||
listenAddress: ':5432',
|
||||
upstreams: JSON.stringify(['10.0.0.1:5432']),
|
||||
matcherType: 'none',
|
||||
matcherValue: null,
|
||||
tlsTermination: false,
|
||||
proxyProtocolVersion: null,
|
||||
proxyProtocolReceive: false,
|
||||
enabled: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...overrides,
|
||||
}).returning();
|
||||
return host;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct the caddy-l4 JSON config that buildL4Servers() would produce
|
||||
* from a set of L4 proxy host rows. This mirrors the logic in caddy.ts.
|
||||
*/
|
||||
function buildExpectedL4Config(rows: (typeof l4ProxyHosts.$inferSelect)[]) {
|
||||
const enabledRows = rows.filter(r => r.enabled);
|
||||
if (enabledRows.length === 0) return null;
|
||||
|
||||
const serverMap = new Map<string, typeof enabledRows>();
|
||||
for (const host of enabledRows) {
|
||||
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 = hosts.map(host => {
|
||||
const route: Record<string, unknown> = {};
|
||||
const matcherValues = host.matcherValue ? JSON.parse(host.matcherValue) as string[] : [];
|
||||
|
||||
if (host.matcherType === 'tls_sni' && matcherValues.length > 0) {
|
||||
route.match = [{ tls: { sni: matcherValues } }];
|
||||
} else if (host.matcherType === 'http_host' && matcherValues.length > 0) {
|
||||
route.match = [{ http: [{ host: matcherValues }] }];
|
||||
} else if (host.matcherType === 'proxy_protocol') {
|
||||
route.match = [{ proxy_protocol: {} }];
|
||||
}
|
||||
|
||||
const handlers: Record<string, unknown>[] = [];
|
||||
if (host.proxyProtocolReceive) handlers.push({ handler: 'proxy_protocol' });
|
||||
if (host.tlsTermination) handlers.push({ handler: 'tls' });
|
||||
|
||||
const upstreams = JSON.parse(host.upstreams) as string[];
|
||||
const proxyHandler: Record<string, unknown> = {
|
||||
handler: 'proxy',
|
||||
upstreams: upstreams.map(u => ({ dial: [u] })),
|
||||
};
|
||||
if (host.proxyProtocolVersion) proxyHandler.proxy_protocol = host.proxyProtocolVersion;
|
||||
|
||||
// Load balancer config from meta
|
||||
if (host.meta) {
|
||||
const meta = JSON.parse(host.meta);
|
||||
if (meta.load_balancer?.enabled) {
|
||||
const lb = meta.load_balancer;
|
||||
proxyHandler.load_balancing = {
|
||||
selection_policy: { policy: lb.policy ?? 'random' },
|
||||
...(lb.try_duration ? { try_duration: lb.try_duration } : {}),
|
||||
...(lb.try_interval ? { try_interval: lb.try_interval } : {}),
|
||||
...(lb.retries != null ? { retries: lb.retries } : {}),
|
||||
};
|
||||
const healthChecks: Record<string, unknown> = {};
|
||||
if (lb.active_health_check?.enabled) {
|
||||
const active: Record<string, unknown> = {};
|
||||
if (lb.active_health_check.port != null) active.port = lb.active_health_check.port;
|
||||
if (lb.active_health_check.interval) active.interval = lb.active_health_check.interval;
|
||||
if (lb.active_health_check.timeout) active.timeout = lb.active_health_check.timeout;
|
||||
if (Object.keys(active).length > 0) healthChecks.active = active;
|
||||
}
|
||||
if (lb.passive_health_check?.enabled) {
|
||||
const passive: Record<string, unknown> = {};
|
||||
if (lb.passive_health_check.fail_duration) passive.fail_duration = lb.passive_health_check.fail_duration;
|
||||
if (lb.passive_health_check.max_fails != null) passive.max_fails = lb.passive_health_check.max_fails;
|
||||
if (lb.passive_health_check.unhealthy_latency) passive.unhealthy_latency = lb.passive_health_check.unhealthy_latency;
|
||||
if (Object.keys(passive).length > 0) healthChecks.passive = passive;
|
||||
}
|
||||
if (Object.keys(healthChecks).length > 0) proxyHandler.health_checks = healthChecks;
|
||||
}
|
||||
}
|
||||
|
||||
handlers.push(proxyHandler);
|
||||
|
||||
route.handle = handlers;
|
||||
return route;
|
||||
});
|
||||
|
||||
servers[`l4_server_${serverIdx++}`] = { listen: [listenAddr], routes };
|
||||
}
|
||||
|
||||
return servers;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config shape tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('L4 Caddy config generation', () => {
|
||||
it('returns null when no L4 hosts exist', async () => {
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
expect(buildExpectedL4Config(rows)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when all hosts are disabled', async () => {
|
||||
await insertL4Host({ enabled: false });
|
||||
await insertL4Host({ enabled: false, name: 'Also disabled', listenAddress: ':3306' });
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
expect(buildExpectedL4Config(rows)).toBeNull();
|
||||
});
|
||||
|
||||
it('simple TCP proxy — catch-all, single upstream', async () => {
|
||||
await insertL4Host({
|
||||
name: 'PostgreSQL',
|
||||
listenAddress: ':5432',
|
||||
upstreams: JSON.stringify(['10.0.0.1:5432']),
|
||||
matcherType: 'none',
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows);
|
||||
|
||||
expect(config).toEqual({
|
||||
l4_server_0: {
|
||||
listen: [':5432'],
|
||||
routes: [
|
||||
{
|
||||
handle: [
|
||||
{ handler: 'proxy', upstreams: [{ dial: ['10.0.0.1:5432'] }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('TCP proxy with TLS SNI matcher and TLS termination', async () => {
|
||||
await insertL4Host({
|
||||
name: 'Secure DB',
|
||||
listenAddress: ':5432',
|
||||
matcherType: 'tls_sni',
|
||||
matcherValue: JSON.stringify(['db.example.com']),
|
||||
tlsTermination: true,
|
||||
upstreams: JSON.stringify(['10.0.0.1:5432']),
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows);
|
||||
|
||||
expect(config).toEqual({
|
||||
l4_server_0: {
|
||||
listen: [':5432'],
|
||||
routes: [
|
||||
{
|
||||
match: [{ tls: { sni: ['db.example.com'] } }],
|
||||
handle: [
|
||||
{ handler: 'tls' },
|
||||
{ handler: 'proxy', upstreams: [{ dial: ['10.0.0.1:5432'] }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('HTTP host matcher shape', async () => {
|
||||
await insertL4Host({
|
||||
name: 'HTTP Route',
|
||||
listenAddress: ':8080',
|
||||
matcherType: 'http_host',
|
||||
matcherValue: JSON.stringify(['api.example.com']),
|
||||
upstreams: JSON.stringify(['10.0.0.1:8080']),
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows)!;
|
||||
const route = (config.l4_server_0 as any).routes[0];
|
||||
expect(route.match).toEqual([{ http: [{ host: ['api.example.com'] }] }]);
|
||||
});
|
||||
|
||||
it('proxy_protocol matcher shape', async () => {
|
||||
await insertL4Host({
|
||||
name: 'PP Match',
|
||||
listenAddress: ':8443',
|
||||
matcherType: 'proxy_protocol',
|
||||
upstreams: JSON.stringify(['10.0.0.1:443']),
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows)!;
|
||||
const route = (config.l4_server_0 as any).routes[0];
|
||||
expect(route.match).toEqual([{ proxy_protocol: {} }]);
|
||||
});
|
||||
|
||||
it('full handler chain — proxy_protocol receive + TLS + proxy with PP v1', async () => {
|
||||
await insertL4Host({
|
||||
name: 'Secure IMAP',
|
||||
listenAddress: '0.0.0.0:993',
|
||||
upstreams: JSON.stringify(['localhost:143']),
|
||||
tlsTermination: true,
|
||||
proxyProtocolReceive: true,
|
||||
proxyProtocolVersion: 'v1',
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows);
|
||||
|
||||
expect(config).toEqual({
|
||||
l4_server_0: {
|
||||
listen: ['0.0.0.0:993'],
|
||||
routes: [
|
||||
{
|
||||
handle: [
|
||||
{ handler: 'proxy_protocol' },
|
||||
{ handler: 'tls' },
|
||||
{
|
||||
handler: 'proxy',
|
||||
proxy_protocol: 'v1',
|
||||
upstreams: [{ dial: ['localhost:143'] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('proxy_protocol v2 outbound', async () => {
|
||||
await insertL4Host({
|
||||
name: 'PP v2',
|
||||
listenAddress: ':8443',
|
||||
upstreams: JSON.stringify(['10.0.0.1:443']),
|
||||
proxyProtocolVersion: 'v2',
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows)!;
|
||||
const route = (config.l4_server_0 as any).routes[0];
|
||||
const proxyHandler = route.handle[route.handle.length - 1];
|
||||
expect(proxyHandler.proxy_protocol).toBe('v2');
|
||||
});
|
||||
|
||||
it('multiple upstreams for load balancing', async () => {
|
||||
const upstreams = ['10.0.0.1:5432', '10.0.0.2:5432', '10.0.0.3:5432'];
|
||||
await insertL4Host({
|
||||
name: 'LB PG',
|
||||
listenAddress: ':5432',
|
||||
upstreams: JSON.stringify(upstreams),
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows)!;
|
||||
const route = (config.l4_server_0 as any).routes[0];
|
||||
expect(route.handle[0].upstreams).toEqual([
|
||||
{ dial: ['10.0.0.1:5432'] },
|
||||
{ dial: ['10.0.0.2:5432'] },
|
||||
{ dial: ['10.0.0.3:5432'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('groups multiple hosts on same port into shared server routes', async () => {
|
||||
await insertL4Host({
|
||||
name: 'DB1',
|
||||
listenAddress: ':5432',
|
||||
matcherType: 'tls_sni',
|
||||
matcherValue: JSON.stringify(['db1.example.com']),
|
||||
upstreams: JSON.stringify(['10.0.0.1:5432']),
|
||||
});
|
||||
await insertL4Host({
|
||||
name: 'DB2',
|
||||
listenAddress: ':5432',
|
||||
matcherType: 'tls_sni',
|
||||
matcherValue: JSON.stringify(['db2.example.com']),
|
||||
upstreams: JSON.stringify(['10.0.0.2:5432']),
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows)!;
|
||||
|
||||
// Should be a single server with 2 routes
|
||||
expect(Object.keys(config)).toHaveLength(1);
|
||||
const server = config.l4_server_0 as any;
|
||||
expect(server.listen).toEqual([':5432']);
|
||||
expect(server.routes).toHaveLength(2);
|
||||
expect(server.routes[0].match).toEqual([{ tls: { sni: ['db1.example.com'] } }]);
|
||||
expect(server.routes[1].match).toEqual([{ tls: { sni: ['db2.example.com'] } }]);
|
||||
});
|
||||
|
||||
it('different ports create separate servers', async () => {
|
||||
await insertL4Host({ name: 'PG', listenAddress: ':5432', upstreams: JSON.stringify(['10.0.0.1:5432']) });
|
||||
await insertL4Host({ name: 'MySQL', listenAddress: ':3306', upstreams: JSON.stringify(['10.0.0.2:3306']) });
|
||||
await insertL4Host({ name: 'Redis', listenAddress: ':6379', upstreams: JSON.stringify(['10.0.0.3:6379']) });
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows)!;
|
||||
|
||||
expect(Object.keys(config)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('disabled hosts are excluded from config', async () => {
|
||||
await insertL4Host({ name: 'Active', listenAddress: ':5432', enabled: true });
|
||||
await insertL4Host({ name: 'Disabled', listenAddress: ':3306', enabled: false });
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows)!;
|
||||
|
||||
// Only the active host should be in config
|
||||
expect(Object.keys(config)).toHaveLength(1);
|
||||
expect((config.l4_server_0 as any).listen).toEqual([':5432']);
|
||||
});
|
||||
|
||||
it('UDP proxy — correct listen address', async () => {
|
||||
await insertL4Host({
|
||||
name: 'DNS Proxy',
|
||||
protocol: 'udp',
|
||||
listenAddress: ':5353',
|
||||
upstreams: JSON.stringify(['8.8.8.8:53', '8.8.4.4:53']),
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows)!;
|
||||
const server = config.l4_server_0 as any;
|
||||
expect(server.listen).toEqual([':5353']);
|
||||
expect(server.routes[0].handle[0].upstreams).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('TLS SNI with multiple hostnames', async () => {
|
||||
await insertL4Host({
|
||||
name: 'Multi SNI',
|
||||
listenAddress: ':443',
|
||||
matcherType: 'tls_sni',
|
||||
matcherValue: JSON.stringify(['db1.example.com', 'db2.example.com', 'db3.example.com']),
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows)!;
|
||||
const route = (config.l4_server_0 as any).routes[0];
|
||||
expect(route.match[0].tls.sni).toEqual(['db1.example.com', 'db2.example.com', 'db3.example.com']);
|
||||
});
|
||||
|
||||
it('load balancer with round_robin policy', async () => {
|
||||
await insertL4Host({
|
||||
name: 'LB Host',
|
||||
listenAddress: ':5432',
|
||||
upstreams: JSON.stringify(['10.0.0.1:5432', '10.0.0.2:5432']),
|
||||
meta: JSON.stringify({
|
||||
load_balancer: {
|
||||
enabled: true,
|
||||
policy: 'round_robin',
|
||||
try_duration: '5s',
|
||||
retries: 3,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows)!;
|
||||
const route = (config.l4_server_0 as any).routes[0];
|
||||
const proxyHandler = route.handle[0];
|
||||
expect(proxyHandler.load_balancing).toEqual({
|
||||
selection_policy: { policy: 'round_robin' },
|
||||
try_duration: '5s',
|
||||
retries: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('load balancer with active health check', async () => {
|
||||
await insertL4Host({
|
||||
name: 'Health Check Host',
|
||||
listenAddress: ':3306',
|
||||
upstreams: JSON.stringify(['10.0.0.1:3306']),
|
||||
meta: JSON.stringify({
|
||||
load_balancer: {
|
||||
enabled: true,
|
||||
policy: 'least_conn',
|
||||
active_health_check: {
|
||||
enabled: true,
|
||||
port: 3307,
|
||||
interval: '10s',
|
||||
timeout: '5s',
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows)!;
|
||||
const route = (config.l4_server_0 as any).routes[0];
|
||||
const proxyHandler = route.handle[0];
|
||||
expect(proxyHandler.health_checks).toEqual({
|
||||
active: {
|
||||
port: 3307,
|
||||
interval: '10s',
|
||||
timeout: '5s',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('load balancer with passive health check', async () => {
|
||||
await insertL4Host({
|
||||
name: 'Passive HC Host',
|
||||
listenAddress: ':6379',
|
||||
upstreams: JSON.stringify(['10.0.0.1:6379']),
|
||||
meta: JSON.stringify({
|
||||
load_balancer: {
|
||||
enabled: true,
|
||||
policy: 'random',
|
||||
passive_health_check: {
|
||||
enabled: true,
|
||||
fail_duration: '30s',
|
||||
max_fails: 5,
|
||||
unhealthy_latency: '2s',
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows)!;
|
||||
const route = (config.l4_server_0 as any).routes[0];
|
||||
const proxyHandler = route.handle[0];
|
||||
expect(proxyHandler.health_checks).toEqual({
|
||||
passive: {
|
||||
fail_duration: '30s',
|
||||
max_fails: 5,
|
||||
unhealthy_latency: '2s',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('disabled load balancer does not add config', async () => {
|
||||
await insertL4Host({
|
||||
name: 'No LB',
|
||||
listenAddress: ':5432',
|
||||
upstreams: JSON.stringify(['10.0.0.1:5432']),
|
||||
meta: JSON.stringify({
|
||||
load_balancer: { enabled: false, policy: 'round_robin' },
|
||||
}),
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows)!;
|
||||
const route = (config.l4_server_0 as any).routes[0];
|
||||
expect(route.handle[0].load_balancing).toBeUndefined();
|
||||
});
|
||||
|
||||
it('dns resolver config stored in meta', async () => {
|
||||
await insertL4Host({
|
||||
name: 'DNS Host',
|
||||
listenAddress: ':5432',
|
||||
upstreams: JSON.stringify(['db.example.com:5432']),
|
||||
meta: JSON.stringify({
|
||||
dns_resolver: {
|
||||
enabled: true,
|
||||
resolvers: ['1.1.1.1', '8.8.8.8'],
|
||||
fallbacks: ['8.8.4.4'],
|
||||
timeout: '5s',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const meta = JSON.parse(rows[0].meta!);
|
||||
expect(meta.dns_resolver.enabled).toBe(true);
|
||||
expect(meta.dns_resolver.resolvers).toEqual(['1.1.1.1', '8.8.8.8']);
|
||||
expect(meta.dns_resolver.fallbacks).toEqual(['8.8.4.4']);
|
||||
expect(meta.dns_resolver.timeout).toBe('5s');
|
||||
});
|
||||
|
||||
it('geo blocking config produces blocker matcher route', async () => {
|
||||
await insertL4Host({
|
||||
name: 'Geo Blocked',
|
||||
listenAddress: ':5432',
|
||||
upstreams: JSON.stringify(['10.0.0.1:5432']),
|
||||
meta: JSON.stringify({
|
||||
geoblock: {
|
||||
enabled: true,
|
||||
block_countries: ['CN', 'RU'],
|
||||
block_continents: [],
|
||||
block_asns: [12345],
|
||||
block_cidrs: [],
|
||||
block_ips: [],
|
||||
allow_countries: ['US'],
|
||||
allow_continents: [],
|
||||
allow_asns: [],
|
||||
allow_cidrs: [],
|
||||
allow_ips: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const meta = JSON.parse(rows[0].meta!);
|
||||
expect(meta.geoblock.enabled).toBe(true);
|
||||
expect(meta.geoblock.block_countries).toEqual(['CN', 'RU']);
|
||||
expect(meta.geoblock.block_asns).toEqual([12345]);
|
||||
expect(meta.geoblock.allow_countries).toEqual(['US']);
|
||||
});
|
||||
|
||||
it('disabled geo blocking does not produce a route', async () => {
|
||||
await insertL4Host({
|
||||
name: 'No Geo Block',
|
||||
listenAddress: ':5432',
|
||||
upstreams: JSON.stringify(['10.0.0.1:5432']),
|
||||
meta: JSON.stringify({
|
||||
geoblock: {
|
||||
enabled: false,
|
||||
block_countries: ['CN'],
|
||||
block_continents: [], block_asns: [], block_cidrs: [], block_ips: [],
|
||||
allow_countries: [], allow_continents: [], allow_asns: [], allow_cidrs: [], allow_ips: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows)!;
|
||||
// Only the proxy route should exist, no blocking route
|
||||
const server = config.l4_server_0 as any;
|
||||
expect(server.routes).toHaveLength(1);
|
||||
expect(server.routes[0].handle[0].handler).toBe('proxy');
|
||||
});
|
||||
|
||||
it('upstream dns resolution config stored in meta', async () => {
|
||||
await insertL4Host({
|
||||
name: 'Pinned Host',
|
||||
listenAddress: ':5432',
|
||||
upstreams: JSON.stringify(['db.example.com:5432']),
|
||||
meta: JSON.stringify({
|
||||
upstream_dns_resolution: {
|
||||
enabled: true,
|
||||
family: 'ipv4',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const meta = JSON.parse(rows[0].meta!);
|
||||
expect(meta.upstream_dns_resolution.enabled).toBe(true);
|
||||
expect(meta.upstream_dns_resolution.family).toBe('ipv4');
|
||||
});
|
||||
});
|
||||
378
tests/integration/l4-ports.test.ts
Normal file
378
tests/integration/l4-ports.test.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* Integration tests for L4 port management.
|
||||
*
|
||||
* Tests the port computation, override file generation, diff detection,
|
||||
* and status lifecycle.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { TestDb } from '../helpers/db';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock db and set L4_PORTS_DIR to a temp directory for file operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ctx = vi.hoisted(() => {
|
||||
const { mkdirSync } = require('node:fs');
|
||||
const { join } = require('node:path');
|
||||
const { tmpdir } = require('node:os');
|
||||
const dir = join(tmpdir(), `l4-ports-test-${Date.now()}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
process.env.L4_PORTS_DIR = dir;
|
||||
return { db: null as unknown as TestDb, tmpDir: dir };
|
||||
});
|
||||
|
||||
vi.mock('../../src/lib/db', async () => {
|
||||
const { createTestDb } = await import('../helpers/db');
|
||||
const schemaModule = await import('../../src/lib/db/schema');
|
||||
ctx.db = createTestDb();
|
||||
return {
|
||||
default: ctx.db,
|
||||
schema: schemaModule,
|
||||
nowIso: () => new Date().toISOString(),
|
||||
toIso: (value: string | Date | null | undefined): string | null => {
|
||||
if (!value) return null;
|
||||
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../src/lib/caddy', () => ({
|
||||
applyCaddyConfig: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/lib/audit', () => ({
|
||||
logAuditEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as schema from '../../src/lib/db/schema';
|
||||
import {
|
||||
getRequiredL4Ports,
|
||||
getAppliedL4Ports,
|
||||
getL4PortsDiff,
|
||||
applyL4Ports,
|
||||
getL4PortsStatus,
|
||||
} from '../../src/lib/l4-ports';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function makeL4Host(overrides: Partial<typeof schema.l4ProxyHosts.$inferInsert> = {}) {
|
||||
const now = nowIso();
|
||||
return {
|
||||
name: 'Test L4 Host',
|
||||
protocol: 'tcp',
|
||||
listenAddress: ':5432',
|
||||
upstreams: JSON.stringify(['10.0.0.1:5432']),
|
||||
matcherType: 'none',
|
||||
matcherValue: null,
|
||||
tlsTermination: false,
|
||||
proxyProtocolVersion: null,
|
||||
proxyProtocolReceive: false,
|
||||
ownerUserId: null,
|
||||
meta: null,
|
||||
enabled: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...overrides,
|
||||
} satisfies typeof schema.l4ProxyHosts.$inferInsert;
|
||||
}
|
||||
|
||||
function cleanTmpDir() {
|
||||
for (const file of ['docker-compose.l4-ports.yml', 'l4-ports.trigger', 'l4-ports.status']) {
|
||||
const path = join(ctx.tmpDir, file);
|
||||
if (existsSync(path)) rmSync(path);
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await ctx.db.delete(schema.l4ProxyHosts);
|
||||
cleanTmpDir();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getRequiredL4Ports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getRequiredL4Ports', () => {
|
||||
it('returns empty array when no L4 hosts exist', async () => {
|
||||
const ports = await getRequiredL4Ports();
|
||||
expect(ports).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns TCP port for enabled host', async () => {
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
listenAddress: ':5432',
|
||||
protocol: 'tcp',
|
||||
enabled: true,
|
||||
}));
|
||||
const ports = await getRequiredL4Ports();
|
||||
expect(ports).toEqual(['5432:5432']);
|
||||
});
|
||||
|
||||
it('returns UDP port with /udp suffix', async () => {
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
listenAddress: ':5353',
|
||||
protocol: 'udp',
|
||||
enabled: true,
|
||||
}));
|
||||
const ports = await getRequiredL4Ports();
|
||||
expect(ports).toEqual(['5353:5353/udp']);
|
||||
});
|
||||
|
||||
it('excludes disabled hosts', async () => {
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
name: 'Enabled',
|
||||
listenAddress: ':5432',
|
||||
enabled: true,
|
||||
}));
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
name: 'Disabled',
|
||||
listenAddress: ':3306',
|
||||
enabled: false,
|
||||
}));
|
||||
const ports = await getRequiredL4Ports();
|
||||
expect(ports).toEqual(['5432:5432']);
|
||||
});
|
||||
|
||||
it('deduplicates ports from multiple hosts on same address', async () => {
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
name: 'Host 1',
|
||||
listenAddress: ':5432',
|
||||
}));
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
name: 'Host 2',
|
||||
listenAddress: ':5432',
|
||||
}));
|
||||
const ports = await getRequiredL4Ports();
|
||||
expect(ports).toEqual(['5432:5432']);
|
||||
});
|
||||
|
||||
it('handles HOST:PORT format', async () => {
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
listenAddress: '0.0.0.0:5432',
|
||||
}));
|
||||
const ports = await getRequiredL4Ports();
|
||||
expect(ports).toEqual(['5432:5432']);
|
||||
});
|
||||
|
||||
it('returns multiple ports sorted', async () => {
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
name: 'Redis',
|
||||
listenAddress: ':6379',
|
||||
}));
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
name: 'PG',
|
||||
listenAddress: ':5432',
|
||||
}));
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
name: 'MySQL',
|
||||
listenAddress: ':3306',
|
||||
}));
|
||||
const ports = await getRequiredL4Ports();
|
||||
expect(ports).toEqual(['3306:3306', '5432:5432', '6379:6379']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getAppliedL4Ports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getAppliedL4Ports', () => {
|
||||
it('returns empty when no override file exists', () => {
|
||||
const ports = getAppliedL4Ports();
|
||||
expect(ports).toEqual([]);
|
||||
});
|
||||
|
||||
it('parses ports from override file', () => {
|
||||
writeFileSync(join(ctx.tmpDir, 'docker-compose.l4-ports.yml'), `services:
|
||||
caddy:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "3306:3306"
|
||||
`);
|
||||
const ports = getAppliedL4Ports();
|
||||
expect(ports).toEqual(['3306:3306', '5432:5432']);
|
||||
});
|
||||
|
||||
it('handles empty override file', () => {
|
||||
writeFileSync(join(ctx.tmpDir, 'docker-compose.l4-ports.yml'), `services: {}
|
||||
`);
|
||||
const ports = getAppliedL4Ports();
|
||||
expect(ports).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getL4PortsDiff
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getL4PortsDiff', () => {
|
||||
it('needsApply is false when no hosts and no override', async () => {
|
||||
const diff = await getL4PortsDiff();
|
||||
expect(diff.needsApply).toBe(false);
|
||||
expect(diff.requiredPorts).toEqual([]);
|
||||
expect(diff.currentPorts).toEqual([]);
|
||||
});
|
||||
|
||||
it('needsApply is true when host exists but no override', async () => {
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
listenAddress: ':5432',
|
||||
}));
|
||||
const diff = await getL4PortsDiff();
|
||||
expect(diff.needsApply).toBe(true);
|
||||
expect(diff.requiredPorts).toEqual(['5432:5432']);
|
||||
expect(diff.currentPorts).toEqual([]);
|
||||
});
|
||||
|
||||
it('needsApply is false when override matches', async () => {
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
listenAddress: ':5432',
|
||||
}));
|
||||
writeFileSync(join(ctx.tmpDir, 'docker-compose.l4-ports.yml'), `services:
|
||||
caddy:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
`);
|
||||
const diff = await getL4PortsDiff();
|
||||
expect(diff.needsApply).toBe(false);
|
||||
});
|
||||
|
||||
it('needsApply is true when override has different ports', async () => {
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
listenAddress: ':5432',
|
||||
}));
|
||||
writeFileSync(join(ctx.tmpDir, 'docker-compose.l4-ports.yml'), `services:
|
||||
caddy:
|
||||
ports:
|
||||
- "3306:3306"
|
||||
`);
|
||||
const diff = await getL4PortsDiff();
|
||||
expect(diff.needsApply).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// applyL4Ports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('applyL4Ports', () => {
|
||||
it('writes override file with required ports', async () => {
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
listenAddress: ':5432',
|
||||
}));
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
name: 'DNS',
|
||||
listenAddress: ':5353',
|
||||
protocol: 'udp',
|
||||
}));
|
||||
|
||||
const status = await applyL4Ports();
|
||||
expect(status.state).toBe('pending');
|
||||
|
||||
const overrideContent = readFileSync(join(ctx.tmpDir, 'docker-compose.l4-ports.yml'), 'utf-8');
|
||||
expect(overrideContent).toContain('"5432:5432"');
|
||||
expect(overrideContent).toContain('"5353:5353/udp"');
|
||||
});
|
||||
|
||||
it('writes trigger file', async () => {
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
listenAddress: ':5432',
|
||||
}));
|
||||
|
||||
await applyL4Ports();
|
||||
const triggerPath = join(ctx.tmpDir, 'l4-ports.trigger');
|
||||
expect(existsSync(triggerPath)).toBe(true);
|
||||
|
||||
const trigger = JSON.parse(readFileSync(triggerPath, 'utf-8'));
|
||||
expect(trigger.triggeredAt).toBeDefined();
|
||||
expect(trigger.ports).toEqual(['5432:5432']);
|
||||
});
|
||||
|
||||
it('writes empty override when no ports needed', async () => {
|
||||
const status = await applyL4Ports();
|
||||
expect(status.state).toBe('pending');
|
||||
|
||||
const overrideContent = readFileSync(join(ctx.tmpDir, 'docker-compose.l4-ports.yml'), 'utf-8');
|
||||
expect(overrideContent).toContain('services: {}');
|
||||
});
|
||||
|
||||
it('override file is idempotent — same ports produce same content', async () => {
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
listenAddress: ':5432',
|
||||
}));
|
||||
|
||||
await applyL4Ports();
|
||||
const content1 = readFileSync(join(ctx.tmpDir, 'docker-compose.l4-ports.yml'), 'utf-8');
|
||||
|
||||
await applyL4Ports();
|
||||
const content2 = readFileSync(join(ctx.tmpDir, 'docker-compose.l4-ports.yml'), 'utf-8');
|
||||
|
||||
expect(content1).toBe(content2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getL4PortsStatus
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getL4PortsStatus', () => {
|
||||
it('returns idle when no status file exists', () => {
|
||||
const status = getL4PortsStatus();
|
||||
expect(status.state).toBe('idle');
|
||||
});
|
||||
|
||||
it('returns idle when no status file exists even if trigger file is present', () => {
|
||||
// Trigger files are deleted by the sidecar after processing.
|
||||
// A leftover trigger file must NEVER cause "Waiting for port manager sidecar..."
|
||||
// because that message gets permanently stuck if the sidecar is slow or restarting.
|
||||
writeFileSync(join(ctx.tmpDir, 'l4-ports.trigger'), JSON.stringify({
|
||||
triggeredAt: new Date().toISOString(),
|
||||
}));
|
||||
const status = getL4PortsStatus();
|
||||
expect(status.state).toBe('idle');
|
||||
});
|
||||
|
||||
it('returns applied when status file says applied', () => {
|
||||
writeFileSync(join(ctx.tmpDir, 'l4-ports.status'), JSON.stringify({
|
||||
state: 'applied',
|
||||
message: 'Success',
|
||||
appliedAt: new Date().toISOString(),
|
||||
}));
|
||||
const status = getL4PortsStatus();
|
||||
expect(status.state).toBe('applied');
|
||||
});
|
||||
|
||||
it('returns failed when status file says failed', () => {
|
||||
writeFileSync(join(ctx.tmpDir, 'l4-ports.status'), JSON.stringify({
|
||||
state: 'failed',
|
||||
message: 'Failed',
|
||||
error: 'Container failed',
|
||||
appliedAt: new Date().toISOString(),
|
||||
}));
|
||||
const status = getL4PortsStatus();
|
||||
expect(status.state).toBe('failed');
|
||||
expect(status.error).toBe('Container failed');
|
||||
});
|
||||
|
||||
it('returns status from file regardless of trigger file presence', () => {
|
||||
// The sidecar deletes triggers after processing, so the status file is
|
||||
// the single source of truth — trigger file presence is irrelevant here.
|
||||
writeFileSync(join(ctx.tmpDir, 'l4-ports.trigger'), JSON.stringify({
|
||||
triggeredAt: '2026-03-21T12:00:00Z',
|
||||
}));
|
||||
writeFileSync(join(ctx.tmpDir, 'l4-ports.status'), JSON.stringify({
|
||||
state: 'applied',
|
||||
message: 'Done',
|
||||
appliedAt: '2026-01-01T00:00:00Z',
|
||||
}));
|
||||
const status = getL4PortsStatus();
|
||||
expect(status.state).toBe('applied');
|
||||
});
|
||||
});
|
||||
335
tests/integration/l4-proxy-hosts.test.ts
Normal file
335
tests/integration/l4-proxy-hosts.test.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb, type TestDb } from '../helpers/db';
|
||||
import { l4ProxyHosts } from '../../src/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
let db: TestDb;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDb();
|
||||
});
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
async function insertL4Host(overrides: Partial<typeof l4ProxyHosts.$inferInsert> = {}) {
|
||||
const now = nowIso();
|
||||
const [host] = await db.insert(l4ProxyHosts).values({
|
||||
name: 'Test L4 Host',
|
||||
protocol: 'tcp',
|
||||
listenAddress: ':5432',
|
||||
upstreams: JSON.stringify(['10.0.0.1:5432']),
|
||||
matcherType: 'none',
|
||||
matcherValue: null,
|
||||
tlsTermination: false,
|
||||
proxyProtocolVersion: null,
|
||||
proxyProtocolReceive: false,
|
||||
enabled: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...overrides,
|
||||
}).returning();
|
||||
return host;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Basic CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('l4-proxy-hosts integration', () => {
|
||||
it('inserts and retrieves an L4 proxy host', async () => {
|
||||
const host = await insertL4Host();
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.name).toBe('Test L4 Host');
|
||||
expect(row!.protocol).toBe('tcp');
|
||||
expect(row!.listenAddress).toBe(':5432');
|
||||
});
|
||||
|
||||
it('delete by id removes the host', async () => {
|
||||
const host = await insertL4Host();
|
||||
await db.delete(l4ProxyHosts).where(eq(l4ProxyHosts.id, host.id));
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
it('multiple L4 hosts — count is correct', async () => {
|
||||
await insertL4Host({ name: 'PG', listenAddress: ':5432' });
|
||||
await insertL4Host({ name: 'MySQL', listenAddress: ':3306' });
|
||||
await insertL4Host({ name: 'Redis', listenAddress: ':6379' });
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
expect(rows.length).toBe(3);
|
||||
});
|
||||
|
||||
it('enabled field defaults to true', async () => {
|
||||
const host = await insertL4Host();
|
||||
expect(host.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('can set enabled to false', async () => {
|
||||
const host = await insertL4Host({ enabled: false });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(Boolean(row!.enabled)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Protocol field
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('l4-proxy-hosts protocol', () => {
|
||||
it('stores TCP protocol', async () => {
|
||||
const host = await insertL4Host({ protocol: 'tcp' });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(row!.protocol).toBe('tcp');
|
||||
});
|
||||
|
||||
it('stores UDP protocol', async () => {
|
||||
const host = await insertL4Host({ protocol: 'udp' });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(row!.protocol).toBe('udp');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON fields (upstreams, matcher_value)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('l4-proxy-hosts JSON fields', () => {
|
||||
it('stores and retrieves upstreams array', async () => {
|
||||
const upstreams = ['10.0.0.1:5432', '10.0.0.2:5432', '10.0.0.3:5432'];
|
||||
const host = await insertL4Host({ upstreams: JSON.stringify(upstreams) });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(JSON.parse(row!.upstreams)).toEqual(upstreams);
|
||||
});
|
||||
|
||||
it('stores and retrieves matcher_value for TLS SNI', async () => {
|
||||
const matcherValue = ['db.example.com', 'db2.example.com'];
|
||||
const host = await insertL4Host({
|
||||
matcherType: 'tls_sni',
|
||||
matcherValue: JSON.stringify(matcherValue),
|
||||
});
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(row!.matcherType).toBe('tls_sni');
|
||||
expect(JSON.parse(row!.matcherValue!)).toEqual(matcherValue);
|
||||
});
|
||||
|
||||
it('stores and retrieves matcher_value for HTTP host', async () => {
|
||||
const matcherValue = ['api.example.com'];
|
||||
const host = await insertL4Host({
|
||||
matcherType: 'http_host',
|
||||
matcherValue: JSON.stringify(matcherValue),
|
||||
});
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(row!.matcherType).toBe('http_host');
|
||||
expect(JSON.parse(row!.matcherValue!)).toEqual(matcherValue);
|
||||
});
|
||||
|
||||
it('matcher_value is null for none matcher', async () => {
|
||||
const host = await insertL4Host({ matcherType: 'none', matcherValue: null });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(row!.matcherType).toBe('none');
|
||||
expect(row!.matcherValue).toBeNull();
|
||||
});
|
||||
|
||||
it('matcher_value is null for proxy_protocol matcher', async () => {
|
||||
const host = await insertL4Host({ matcherType: 'proxy_protocol', matcherValue: null });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(row!.matcherType).toBe('proxy_protocol');
|
||||
expect(row!.matcherValue).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Boolean fields
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('l4-proxy-hosts boolean fields', () => {
|
||||
it('tls_termination defaults to false', async () => {
|
||||
const host = await insertL4Host();
|
||||
expect(Boolean(host.tlsTermination)).toBe(false);
|
||||
});
|
||||
|
||||
it('tls_termination can be set to true', async () => {
|
||||
const host = await insertL4Host({ tlsTermination: true });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(Boolean(row!.tlsTermination)).toBe(true);
|
||||
});
|
||||
|
||||
it('proxy_protocol_receive defaults to false', async () => {
|
||||
const host = await insertL4Host();
|
||||
expect(Boolean(host.proxyProtocolReceive)).toBe(false);
|
||||
});
|
||||
|
||||
it('proxy_protocol_receive can be set to true', async () => {
|
||||
const host = await insertL4Host({ proxyProtocolReceive: true });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(Boolean(row!.proxyProtocolReceive)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Proxy protocol version
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('l4-proxy-hosts proxy protocol version', () => {
|
||||
it('proxy_protocol_version defaults to null', async () => {
|
||||
const host = await insertL4Host();
|
||||
expect(host.proxyProtocolVersion).toBeNull();
|
||||
});
|
||||
|
||||
it('stores v1 proxy protocol version', async () => {
|
||||
const host = await insertL4Host({ proxyProtocolVersion: 'v1' });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(row!.proxyProtocolVersion).toBe('v1');
|
||||
});
|
||||
|
||||
it('stores v2 proxy protocol version', async () => {
|
||||
const host = await insertL4Host({ proxyProtocolVersion: 'v2' });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(row!.proxyProtocolVersion).toBe('v2');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Meta field
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('l4-proxy-hosts meta', () => {
|
||||
it('meta can be null', async () => {
|
||||
const host = await insertL4Host({ meta: null });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(row!.meta).toBeNull();
|
||||
});
|
||||
|
||||
it('stores and retrieves load balancer config via meta', async () => {
|
||||
const meta = {
|
||||
load_balancer: {
|
||||
enabled: true,
|
||||
policy: 'round_robin',
|
||||
try_duration: '5s',
|
||||
try_interval: '250ms',
|
||||
retries: 3,
|
||||
active_health_check: { enabled: true, port: 8081, interval: '10s', timeout: '5s' },
|
||||
passive_health_check: { enabled: true, fail_duration: '30s', max_fails: 5 },
|
||||
},
|
||||
};
|
||||
const host = await insertL4Host({ meta: JSON.stringify(meta) });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
const parsed = JSON.parse(row!.meta!);
|
||||
expect(parsed.load_balancer.enabled).toBe(true);
|
||||
expect(parsed.load_balancer.policy).toBe('round_robin');
|
||||
expect(parsed.load_balancer.active_health_check.port).toBe(8081);
|
||||
expect(parsed.load_balancer.passive_health_check.max_fails).toBe(5);
|
||||
});
|
||||
|
||||
it('stores and retrieves DNS resolver config via meta', async () => {
|
||||
const meta = {
|
||||
dns_resolver: {
|
||||
enabled: true,
|
||||
resolvers: ['1.1.1.1', '8.8.8.8'],
|
||||
fallbacks: ['8.8.4.4'],
|
||||
timeout: '5s',
|
||||
},
|
||||
};
|
||||
const host = await insertL4Host({ meta: JSON.stringify(meta) });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
const parsed = JSON.parse(row!.meta!);
|
||||
expect(parsed.dns_resolver.enabled).toBe(true);
|
||||
expect(parsed.dns_resolver.resolvers).toEqual(['1.1.1.1', '8.8.8.8']);
|
||||
expect(parsed.dns_resolver.timeout).toBe('5s');
|
||||
});
|
||||
|
||||
it('stores and retrieves upstream DNS resolution config via meta', async () => {
|
||||
const meta = {
|
||||
upstream_dns_resolution: { enabled: true, family: 'ipv4' },
|
||||
};
|
||||
const host = await insertL4Host({ meta: JSON.stringify(meta) });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
const parsed = JSON.parse(row!.meta!);
|
||||
expect(parsed.upstream_dns_resolution.enabled).toBe(true);
|
||||
expect(parsed.upstream_dns_resolution.family).toBe('ipv4');
|
||||
});
|
||||
|
||||
it('stores all three meta features together', async () => {
|
||||
const meta = {
|
||||
load_balancer: { enabled: true, policy: 'ip_hash' },
|
||||
dns_resolver: { enabled: true, resolvers: ['1.1.1.1'] },
|
||||
upstream_dns_resolution: { enabled: true, family: 'both' },
|
||||
};
|
||||
const host = await insertL4Host({ meta: JSON.stringify(meta) });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
const parsed = JSON.parse(row!.meta!);
|
||||
expect(parsed.load_balancer.policy).toBe('ip_hash');
|
||||
expect(parsed.dns_resolver.resolvers).toEqual(['1.1.1.1']);
|
||||
expect(parsed.upstream_dns_resolution.family).toBe('both');
|
||||
});
|
||||
|
||||
it('stores and retrieves geo blocking config via meta', async () => {
|
||||
const meta = {
|
||||
geoblock: {
|
||||
enabled: true,
|
||||
block_countries: ['CN', 'RU', 'KP'],
|
||||
block_continents: ['AF'],
|
||||
block_asns: [12345],
|
||||
block_cidrs: ['192.0.2.0/24'],
|
||||
block_ips: ['203.0.113.1'],
|
||||
allow_countries: ['US'],
|
||||
allow_continents: [],
|
||||
allow_asns: [],
|
||||
allow_cidrs: ['10.0.0.0/8'],
|
||||
allow_ips: [],
|
||||
},
|
||||
geoblock_mode: 'override',
|
||||
};
|
||||
const host = await insertL4Host({ meta: JSON.stringify(meta) });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
const parsed = JSON.parse(row!.meta!);
|
||||
expect(parsed.geoblock.enabled).toBe(true);
|
||||
expect(parsed.geoblock.block_countries).toEqual(['CN', 'RU', 'KP']);
|
||||
expect(parsed.geoblock.allow_cidrs).toEqual(['10.0.0.0/8']);
|
||||
expect(parsed.geoblock_mode).toBe('override');
|
||||
});
|
||||
|
||||
it('stores all four meta features together', async () => {
|
||||
const meta = {
|
||||
load_balancer: { enabled: true, policy: 'round_robin' },
|
||||
dns_resolver: { enabled: true, resolvers: ['1.1.1.1'] },
|
||||
upstream_dns_resolution: { enabled: true, family: 'ipv4' },
|
||||
geoblock: { enabled: true, block_countries: ['CN'], block_continents: [], block_asns: [], block_cidrs: [], block_ips: [], allow_countries: [], allow_continents: [], allow_asns: [], allow_cidrs: [], allow_ips: [] },
|
||||
};
|
||||
const host = await insertL4Host({ meta: JSON.stringify(meta) });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
const parsed = JSON.parse(row!.meta!);
|
||||
expect(parsed.load_balancer.policy).toBe('round_robin');
|
||||
expect(parsed.geoblock.block_countries).toEqual(['CN']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Update
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('l4-proxy-hosts update', () => {
|
||||
it('updates listen address', async () => {
|
||||
const host = await insertL4Host({ listenAddress: ':5432' });
|
||||
await db.update(l4ProxyHosts).set({ listenAddress: ':3306' }).where(eq(l4ProxyHosts.id, host.id));
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(row!.listenAddress).toBe(':3306');
|
||||
});
|
||||
|
||||
it('updates protocol from tcp to udp', async () => {
|
||||
const host = await insertL4Host({ protocol: 'tcp' });
|
||||
await db.update(l4ProxyHosts).set({ protocol: 'udp' }).where(eq(l4ProxyHosts.id, host.id));
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(row!.protocol).toBe('udp');
|
||||
});
|
||||
|
||||
it('toggles enabled state', async () => {
|
||||
const host = await insertL4Host({ enabled: true });
|
||||
await db.update(l4ProxyHosts).set({ enabled: false }).where(eq(l4ProxyHosts.id, host.id));
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(Boolean(row!.enabled)).toBe(false);
|
||||
});
|
||||
});
|
||||
177
tests/unit/l4-port-manager-entrypoint.test.ts
Normal file
177
tests/unit/l4-port-manager-entrypoint.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Unit tests for the L4 port manager sidecar entrypoint script.
|
||||
*
|
||||
* Tests critical invariants of the shell script:
|
||||
* - Always applies the override on startup (not just on trigger change)
|
||||
* - Only recreates the caddy service (never other services)
|
||||
* - Uses --no-deps to prevent dependency cascades
|
||||
* - Auto-detects compose project name from caddy container labels
|
||||
* - Pre-loads LAST_TRIGGER to avoid double-applying on startup
|
||||
* - Writes status files in valid JSON
|
||||
* - Never includes test override files in production
|
||||
* - Supports both named-volume and bind-mount deployments (COMPOSE_HOST_DIR)
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const SCRIPT_PATH = resolve(__dirname, '../../docker/l4-port-manager/entrypoint.sh');
|
||||
const script = readFileSync(SCRIPT_PATH, 'utf-8');
|
||||
const lines = script.split('\n');
|
||||
|
||||
describe('L4 port manager entrypoint.sh', () => {
|
||||
it('applies override on startup (not only on trigger change)', () => {
|
||||
// The script must call do_apply before entering the while loop.
|
||||
// This ensures L4 ports are bound after any restart, because the main
|
||||
// compose stack starts caddy without the L4 ports override file.
|
||||
const firstApply = lines.findIndex(l => l.trim().startsWith('do_apply') || l.includes('do_apply'));
|
||||
const whileLoop = lines.findIndex(l => l.includes('while true'));
|
||||
expect(firstApply).toBeGreaterThan(-1);
|
||||
expect(whileLoop).toBeGreaterThan(-1);
|
||||
expect(firstApply).toBeLessThan(whileLoop);
|
||||
});
|
||||
|
||||
it('pre-loads LAST_TRIGGER after startup apply to avoid double-apply', () => {
|
||||
// After the startup apply, LAST_TRIGGER must be set from the current trigger
|
||||
// file content so the poll loop doesn't re-apply the same trigger again.
|
||||
const lastTriggerInit = lines.findIndex(l => l.includes('LAST_TRIGGER=') && l.includes('TRIGGER_FILE'));
|
||||
const whileLoop = lines.findIndex(l => l.includes('while true'));
|
||||
expect(lastTriggerInit).toBeGreaterThan(-1);
|
||||
expect(lastTriggerInit).toBeLessThan(whileLoop);
|
||||
});
|
||||
|
||||
it('only recreates the caddy service', () => {
|
||||
// The docker compose command should target only "caddy" — never "web" or other services
|
||||
const composeUpLines = lines.filter(line =>
|
||||
line.includes('docker compose') && line.includes('up')
|
||||
);
|
||||
expect(composeUpLines.length).toBeGreaterThan(0);
|
||||
for (const line of composeUpLines) {
|
||||
expect(line).toContain('caddy');
|
||||
expect(line).not.toMatch(/\bweb\b/);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses --no-deps flag to prevent dependency cascades', () => {
|
||||
const composeUpLines = lines.filter(line =>
|
||||
line.includes('docker compose') && line.includes('up')
|
||||
);
|
||||
for (const line of composeUpLines) {
|
||||
expect(line).toContain('--no-deps');
|
||||
}
|
||||
});
|
||||
|
||||
it('uses --force-recreate to ensure port changes take effect', () => {
|
||||
const composeUpLines = lines.filter(line =>
|
||||
line.includes('docker compose') && line.includes('up')
|
||||
);
|
||||
for (const line of composeUpLines) {
|
||||
expect(line).toContain('--force-recreate');
|
||||
}
|
||||
});
|
||||
|
||||
it('specifies project name to target the correct compose stack', () => {
|
||||
// Without -p, compose would infer the project from the mount directory name
|
||||
// ("/compose") rather than the actual running stack name, causing it to
|
||||
// create new containers instead of recreating the existing ones.
|
||||
expect(script).toMatch(/COMPOSE_ARGS=.*-p \$COMPOSE_PROJECT/);
|
||||
});
|
||||
|
||||
it('auto-detects project name from caddy container labels', () => {
|
||||
expect(script).toContain('com.docker.compose.project');
|
||||
expect(script).toContain('docker inspect');
|
||||
expect(script).toContain('detect_project_name');
|
||||
});
|
||||
|
||||
it('compares trigger content to avoid redundant restarts', () => {
|
||||
expect(script).toContain('LAST_TRIGGER');
|
||||
expect(script).toContain('CURRENT_TRIGGER');
|
||||
expect(script).toContain('"$CURRENT_TRIGGER" = "$LAST_TRIGGER"');
|
||||
});
|
||||
|
||||
it('does not pull images (only recreates)', () => {
|
||||
const composeUpLines = lines.filter(line =>
|
||||
line.includes('docker compose') && line.includes('up')
|
||||
);
|
||||
for (const line of composeUpLines) {
|
||||
expect(line).not.toContain('--pull');
|
||||
expect(line).not.toContain('--build');
|
||||
}
|
||||
});
|
||||
|
||||
it('waits for caddy health check after recreation', () => {
|
||||
expect(script).toContain('Health');
|
||||
expect(script).toContain('healthy');
|
||||
expect(script).toContain('HEALTH_TIMEOUT');
|
||||
});
|
||||
|
||||
it('writes status for both success and failure cases', () => {
|
||||
const statusWrites = lines.filter(l => l.trim().startsWith('write_status'));
|
||||
// At least: startup idle/applying, applying, applied/success, failed
|
||||
expect(statusWrites.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('does not include test override files in production', () => {
|
||||
// Including docker-compose.test.yml would override web env vars (triggering
|
||||
// web restart) and switch to test volume names.
|
||||
expect(script).not.toContain('docker-compose.test.yml');
|
||||
});
|
||||
|
||||
it('does not restart the web service or itself', () => {
|
||||
const dangerousPatterns = [
|
||||
/up.*\bweb\b/,
|
||||
/restart.*\bweb\b/,
|
||||
/up.*\bl4-port-manager\b/,
|
||||
/restart.*\bl4-port-manager\b/,
|
||||
];
|
||||
for (const pattern of dangerousPatterns) {
|
||||
expect(script).not.toMatch(pattern);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deployment scenario: COMPOSE_HOST_DIR (bind-mount / cloud override)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('uses --project-directory $COMPOSE_HOST_DIR when COMPOSE_HOST_DIR is set', () => {
|
||||
// Bind-mount deployments (docker-compose.override.yml replaces named volumes
|
||||
// with ./data bind mounts). Relative paths like ./geoip-data in the override
|
||||
// file must resolve against the HOST project directory, not the sidecar's
|
||||
// /compose mount. --project-directory tells the Docker daemon where to look.
|
||||
expect(script).toContain('--project-directory $COMPOSE_HOST_DIR');
|
||||
// It must be conditional — only applied when COMPOSE_HOST_DIR is non-empty
|
||||
expect(script).toMatch(/if \[ -n "\$COMPOSE_HOST_DIR" \]/);
|
||||
});
|
||||
|
||||
it('does NOT unconditionally add --project-directory (named-volume deployments work without it)', () => {
|
||||
// Standard deployments (no override file) use named volumes — no host path
|
||||
// is needed. --project-directory must NOT be hardcoded outside the conditional.
|
||||
const unconditional = lines.filter(l =>
|
||||
l.includes('--project-directory') && !l.includes('COMPOSE_HOST_DIR') && !l.trim().startsWith('#')
|
||||
);
|
||||
expect(unconditional).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('uses --env-file from $COMPOSE_DIR (container-accessible path), not $COMPOSE_HOST_DIR', () => {
|
||||
// When --project-directory points to the host path, Docker Compose looks for
|
||||
// .env at $COMPOSE_HOST_DIR/.env which is NOT mounted inside the container.
|
||||
// We must explicitly pass --env-file $COMPOSE_DIR/.env (the container mount).
|
||||
expect(script).toContain('--env-file $COMPOSE_DIR/.env');
|
||||
// Must NOT reference the host dir for the env file
|
||||
expect(script).not.toContain('--env-file $COMPOSE_HOST_DIR');
|
||||
});
|
||||
|
||||
it('always reads compose files from $COMPOSE_DIR regardless of COMPOSE_HOST_DIR', () => {
|
||||
// The sidecar mounts the project at /compose (COMPOSE_DIR). Whether or not
|
||||
// COMPOSE_HOST_DIR is set, all -f flags must reference container-accessible
|
||||
// paths under $COMPOSE_DIR, never the host path.
|
||||
const composeFileFlags = lines.filter(l =>
|
||||
l.includes('-f ') && l.includes('docker-compose')
|
||||
);
|
||||
expect(composeFileFlags.length).toBeGreaterThan(0);
|
||||
for (const line of composeFileFlags) {
|
||||
expect(line).toContain('$COMPOSE_DIR');
|
||||
expect(line).not.toContain('$COMPOSE_HOST_DIR');
|
||||
}
|
||||
});
|
||||
});
|
||||
300
tests/unit/l4-proxy-hosts-validation.test.ts
Normal file
300
tests/unit/l4-proxy-hosts-validation.test.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Unit tests for L4 proxy host input validation.
|
||||
*
|
||||
* Tests the validation logic in the L4 proxy hosts model
|
||||
* without requiring a database connection.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { TestDb } from '../helpers/db';
|
||||
|
||||
// Mock db so the model module can be imported
|
||||
const ctx = vi.hoisted(() => ({ db: null as unknown as TestDb }));
|
||||
|
||||
vi.mock('../../src/lib/db', async () => {
|
||||
const { createTestDb } = await import('../helpers/db');
|
||||
const schemaModule = await import('../../src/lib/db/schema');
|
||||
ctx.db = createTestDb();
|
||||
return {
|
||||
default: ctx.db,
|
||||
schema: schemaModule,
|
||||
nowIso: () => new Date().toISOString(),
|
||||
toIso: (value: string | Date | null | undefined): string | null => {
|
||||
if (!value) return null;
|
||||
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../src/lib/caddy', () => ({
|
||||
applyCaddyConfig: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/lib/audit', () => ({
|
||||
logAuditEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
createL4ProxyHost,
|
||||
updateL4ProxyHost,
|
||||
type L4ProxyHostInput,
|
||||
} from '../../src/lib/models/l4-proxy-hosts';
|
||||
import * as schema from '../../src/lib/db/schema';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup: insert a test user so the FK constraint on ownerUserId is satisfied
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
beforeEach(async () => {
|
||||
await ctx.db.delete(schema.l4ProxyHosts);
|
||||
await ctx.db.delete(schema.users).catch(() => {});
|
||||
await ctx.db.insert(schema.users).values({
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
role: 'admin',
|
||||
provider: 'credentials',
|
||||
subject: 'test',
|
||||
status: 'active',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation tests via createL4ProxyHost (which calls validateL4Input)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('L4 proxy host create validation', () => {
|
||||
it('rejects empty name', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: '',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':5432',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Name is required');
|
||||
});
|
||||
|
||||
it('rejects invalid protocol', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'sctp' as any,
|
||||
listen_address: ':5432',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow("Protocol must be 'tcp' or 'udp'");
|
||||
});
|
||||
|
||||
it('rejects empty listen address', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'tcp',
|
||||
listen_address: '',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Listen address is required');
|
||||
});
|
||||
|
||||
it('rejects listen address without port', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'tcp',
|
||||
listen_address: '10.0.0.1',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow("Listen address must be in format ':PORT' or 'HOST:PORT'");
|
||||
});
|
||||
|
||||
it('rejects listen address with port 0', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':0',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Port must be between 1 and 65535');
|
||||
});
|
||||
|
||||
it('rejects listen address with port > 65535', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':99999',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Port must be between 1 and 65535');
|
||||
});
|
||||
|
||||
it('rejects empty upstreams', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':5432',
|
||||
upstreams: [],
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('At least one upstream must be specified');
|
||||
});
|
||||
|
||||
it('rejects upstream without port', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':5432',
|
||||
upstreams: ['10.0.0.1'],
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow("must be in 'host:port' format");
|
||||
});
|
||||
|
||||
it('rejects TLS termination with UDP', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'udp',
|
||||
listen_address: ':5353',
|
||||
upstreams: ['8.8.8.8:53'],
|
||||
tls_termination: true,
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('TLS termination is only supported with TCP');
|
||||
});
|
||||
|
||||
it('rejects TLS SNI matcher without values', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':5432',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
matcher_type: 'tls_sni',
|
||||
matcher_value: [],
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Matcher value is required');
|
||||
});
|
||||
|
||||
it('rejects HTTP host matcher without values', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':8080',
|
||||
upstreams: ['10.0.0.1:8080'],
|
||||
matcher_type: 'http_host',
|
||||
matcher_value: [],
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Matcher value is required');
|
||||
});
|
||||
|
||||
it('rejects invalid proxy protocol version', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':5432',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
proxy_protocol_version: 'v3' as any,
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow("Proxy protocol version must be 'v1' or 'v2'");
|
||||
});
|
||||
|
||||
it('rejects invalid matcher type', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':5432',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
matcher_type: 'invalid' as any,
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Matcher type must be one of');
|
||||
});
|
||||
|
||||
it('accepts valid TCP proxy with all options', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Full Featured',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':993',
|
||||
upstreams: ['localhost:143'],
|
||||
matcher_type: 'tls_sni',
|
||||
matcher_value: ['mail.example.com'],
|
||||
tls_termination: true,
|
||||
proxy_protocol_version: 'v1',
|
||||
proxy_protocol_receive: true,
|
||||
enabled: true,
|
||||
};
|
||||
const result = await createL4ProxyHost(input, 1);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.name).toBe('Full Featured');
|
||||
expect(result.protocol).toBe('tcp');
|
||||
expect(result.listen_address).toBe(':993');
|
||||
expect(result.upstreams).toEqual(['localhost:143']);
|
||||
expect(result.matcher_type).toBe('tls_sni');
|
||||
expect(result.matcher_value).toEqual(['mail.example.com']);
|
||||
expect(result.tls_termination).toBe(true);
|
||||
expect(result.proxy_protocol_version).toBe('v1');
|
||||
expect(result.proxy_protocol_receive).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts valid UDP proxy', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'DNS',
|
||||
protocol: 'udp',
|
||||
listen_address: ':5353',
|
||||
upstreams: ['8.8.8.8:53'],
|
||||
};
|
||||
const result = await createL4ProxyHost(input, 1);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.protocol).toBe('udp');
|
||||
});
|
||||
|
||||
it('accepts host:port format for listen address', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Bound',
|
||||
protocol: 'tcp',
|
||||
listen_address: '0.0.0.0:5432',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
};
|
||||
const result = await createL4ProxyHost(input, 1);
|
||||
expect(result.listen_address).toBe('0.0.0.0:5432');
|
||||
});
|
||||
|
||||
it('accepts none matcher without matcher_value', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Catch All',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':5432',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
matcher_type: 'none',
|
||||
};
|
||||
const result = await createL4ProxyHost(input, 1);
|
||||
expect(result.matcher_type).toBe('none');
|
||||
});
|
||||
|
||||
it('accepts proxy_protocol matcher without matcher_value', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'PP Detect',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':8443',
|
||||
upstreams: ['10.0.0.1:443'],
|
||||
matcher_type: 'proxy_protocol',
|
||||
};
|
||||
const result = await createL4ProxyHost(input, 1);
|
||||
expect(result.matcher_type).toBe('proxy_protocol');
|
||||
});
|
||||
|
||||
it('trims whitespace from name and listen_address', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: ' Spacey Name ',
|
||||
protocol: 'tcp',
|
||||
listen_address: ' :5432 ',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
};
|
||||
const result = await createL4ProxyHost(input, 1);
|
||||
expect(result.name).toBe('Spacey Name');
|
||||
expect(result.listen_address).toBe(':5432');
|
||||
});
|
||||
|
||||
it('deduplicates upstreams', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Dedup',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':5432',
|
||||
upstreams: ['10.0.0.1:5432', '10.0.0.1:5432', '10.0.0.2:5432'],
|
||||
};
|
||||
const result = await createL4ProxyHost(input, 1);
|
||||
expect(result.upstreams).toEqual(['10.0.0.1:5432', '10.0.0.2:5432']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user