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

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

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

View File

@@ -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 },

View 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>
);
}

View 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.");
}
}

View 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
View 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 }
);
}
}

View File

@@ -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

View 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"]

View 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

View 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
);

View File

@@ -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
}
]
}

View File

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

View File

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

View File

@@ -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
}
};
}

View File

@@ -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
View 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);
}

View 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();
}

View File

@@ -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

View 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);
});
});

View 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 });
});
});

View 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
View 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`);
}

View 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');
});
});

View 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');
});
});

View 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);
});
});

View 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');
}
});
});

View 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']);
});
});