diff --git a/app/(dashboard)/DashboardLayoutClient.tsx b/app/(dashboard)/DashboardLayoutClient.tsx index 97cb4aac..4e438fd9 100644 --- a/app/(dashboard)/DashboardLayoutClient.tsx +++ b/app/(dashboard)/DashboardLayoutClient.tsx @@ -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 }, diff --git a/app/(dashboard)/l4-proxy-hosts/L4ProxyHostsClient.tsx b/app/(dashboard)/l4-proxy-hosts/L4ProxyHostsClient.tsx new file mode 100644 index 00000000..70905eda --- /dev/null +++ b/app/(dashboard)/l4-proxy-hosts/L4ProxyHostsClient.tsx @@ -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(null); + const [editHost, setEditHost] = useState(null); + const [deleteHost, setDeleteHost] = useState(null); + const [searchTerm, setSearchTerm] = useState(initialSearch); + + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const debounceRef = useRef | 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) => ( + + {host.name} + + ), + }, + { + id: "protocol", + label: "Protocol", + width: 80, + render: (host: L4ProxyHost) => ( + + ), + }, + { + id: "listen", + label: "Listen", + render: (host: L4ProxyHost) => ( + + {host.listen_address} + + ), + }, + { + id: "matcher", + label: "Matcher", + render: (host: L4ProxyHost) => ( + + {formatMatcher(host)} + + ), + }, + { + id: "upstreams", + label: "Upstreams", + render: (host: L4ProxyHost) => ( + + {host.upstreams[0]} + {host.upstreams.length > 1 && ` +${host.upstreams.length - 1} more`} + + ), + }, + { + id: "actions", + label: "Actions", + align: "right" as const, + width: 150, + render: (host: L4ProxyHost) => ( + + handleToggleEnabled(host.id, e.target.checked)} + size="small" + color="success" + /> + + { + setDuplicateHost(host); + setCreateOpen(true); + }} + color="info" + > + + + + + setEditHost(host)} color="primary"> + + + + + setDeleteHost(host)} color="error"> + + + + + ), + }, + ]; + + const mobileCard = (host: L4ProxyHost) => ( + + + + + + {host.name} + + + + + handleToggleEnabled(host.id, e.target.checked)} + size="small" + color="success" + /> + + { + setDuplicateHost(host); + setCreateOpen(true); + }} + color="info" + > + + + + + setEditHost(host)} color="primary"> + + + + + setDeleteHost(host)} color="error"> + + + + + + + {host.listen_address} {"\u2192"} {host.upstreams[0]} + {host.upstreams.length > 1 ? ` +${host.upstreams.length - 1}` : ""} + + + + ); + + return ( + + + setCreateOpen(true), + }} + /> + + handleSearchChange(e.target.value)} + placeholder="Search L4 hosts..." + /> + + + + { + setCreateOpen(false); + setTimeout(() => setDuplicateHost(null), 200); + }} + initialData={duplicateHost} + /> + + {editHost && ( + setEditHost(null)} + /> + )} + + {deleteHost && ( + setDeleteHost(null)} + /> + )} + + ); +} diff --git a/app/(dashboard)/l4-proxy-hosts/actions.ts b/app/(dashboard)/l4-proxy-hosts/actions.ts new file mode 100644 index 00000000..bfdfffde --- /dev/null +++ b/app/(dashboard)/l4-proxy-hosts/actions.ts @@ -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 | 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 = {}; + 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 | 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 = {}; + 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 | 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 = {}; + 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 { + 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 { + 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 = { + 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 { + 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 { + 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."); + } +} diff --git a/app/(dashboard)/l4-proxy-hosts/page.tsx b/app/(dashboard)/l4-proxy-hosts/page.tsx new file mode 100644 index 00000000..28eefa9b --- /dev/null +++ b/app/(dashboard)/l4-proxy-hosts/page.tsx @@ -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 ( + + ); +} diff --git a/app/api/l4-ports/route.ts b/app/api/l4-ports/route.ts new file mode 100644 index 00000000..9051eaf5 --- /dev/null +++ b/app/api/l4-ports/route.ts @@ -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 } + ); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 1d4fc8a3..8c0d2a1f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docker/l4-port-manager/Dockerfile b/docker/l4-port-manager/Dockerfile new file mode 100644 index 00000000..240ca894 --- /dev/null +++ b/docker/l4-port-manager/Dockerfile @@ -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"] diff --git a/docker/l4-port-manager/entrypoint.sh b/docker/l4-port-manager/entrypoint.sh new file mode 100755 index 00000000..d5989174 --- /dev/null +++ b/docker/l4-port-manager/entrypoint.sh @@ -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" </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 diff --git a/drizzle/0015_l4_proxy_hosts.sql b/drizzle/0015_l4_proxy_hosts.sql new file mode 100644 index 00000000..7bddaa7d --- /dev/null +++ b/drizzle/0015_l4_proxy_hosts.sql @@ -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 +); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index a2d0f1f7..f6f5949c 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] } diff --git a/src/components/l4-proxy-hosts/L4HostDialogs.tsx b/src/components/l4-proxy-hosts/L4HostDialogs.tsx new file mode 100644 index 00000000..423d71ca --- /dev/null +++ b/src/components/l4-proxy-hosts/L4HostDialogs.tsx @@ -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 ( + + {state.status !== "idle" && state.message && ( + + {state.message} + + )} + + + + } + label="Enabled" + /> + + + + setProtocol(e.target.value as "tcp" | "udp")} + fullWidth + > + TCP + UDP + + + + + + + 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 + > + None (catch-all) + TLS SNI + HTTP Host + Proxy Protocol + + + {(matcherType === "tls_sni" || matcherType === "http_host") && ( + + )} + + {protocol === "tcp" && ( + + } + label="TLS Termination" + sx={{ ml: 0 }} + /> + )} + + + } + label="Accept inbound PROXY protocol" + sx={{ ml: 0 }} + /> + + + None + v1 + v2 + + + {/* Load Balancer */} + + }> + Load Balancer + + + + + + } + label="Enable Load Balancing" + /> + + Random + Round Robin + Least Connections + IP Hash + First Available + + + + + + Active Health Check + + } + label="Enable Active Health Check" + /> + + + + + Passive Health Check + + } + label="Enable Passive Health Check" + /> + + + + + + + + {/* DNS Resolver */} + + }> + Custom DNS Resolvers + + + + + + } + label="Enable Custom DNS" + /> + + + + + + + + {/* Geo Blocking */} + + }> + Geo Blocking + + + + + } + label="Enable Geo Blocking" + /> + + Merge with global settings + Override global settings + + Block Rules + + + + + + Allow Rules (override blocks) + + + + + + + At L4, geo blocking uses the client's direct IP address (no X-Forwarded-For support). Blocked connections are immediately closed. + + + + + + {/* Upstream DNS Resolution / Pinning */} + + }> + Upstream DNS Pinning + + + + + + When enabled, upstream hostnames are resolved to IP addresses at config time, pinning DNS resolution. + + + Inherit from global settings + Enabled + Disabled + + + Inherit from global settings + Both (IPv6 + IPv4) + IPv6 only + IPv4 only + + + + + + ); +} + +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 ( + { + (document.getElementById("create-l4-host-form") as HTMLFormElement)?.requestSubmit(); + }} + > + + + ); +} + +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 ( + { + (document.getElementById("edit-l4-host-form") as HTMLFormElement)?.requestSubmit(); + }} + > + + + ); +} + +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 ( + { + (document.getElementById("delete-l4-host-form") as HTMLFormElement)?.requestSubmit(); + }} + > + + {state.status !== "idle" && state.message && ( + + {state.message} + + )} + + Are you sure you want to delete the L4 proxy host {host.name}? + + + This will remove the configuration for: + + + + {"\u2022"} Protocol: {host.protocol.toUpperCase()} + + + {"\u2022"} Listen: {host.listen_address} + + + {"\u2022"} Upstreams: {host.upstreams.join(", ")} + + + + This action cannot be undone. + + + + ); +} diff --git a/src/components/l4-proxy-hosts/L4PortsApplyBanner.tsx b/src/components/l4-proxy-hosts/L4PortsApplyBanner.tsx new file mode 100644 index 00000000..44e88b25 --- /dev/null +++ b/src/components/l4-proxy-hosts/L4PortsApplyBanner.tsx @@ -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(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: , + applying: , + applied: , + failed: , + }[status.state]; + + const severity = status.state === "failed" ? "error" + : status.state === "applied" ? "success" + : diff.needsApply ? "warning" + : "info"; + + return ( + : } + > + Apply Ports + + ) : undefined + } + > + + {diff.needsApply ? ( + + Docker port changes pending. The caddy container needs to be recreated to expose L4 ports. + {diff.requiredPorts.length > 0 && ( + <> Required: {diff.requiredPorts.map(p => ( + + ))} + )} + + ) : ( + {status.message} + )} + {status.state === "failed" && status.error && ( + {status.error} + )} + + + ); +} diff --git a/src/lib/caddy.ts b/src/lib/caddy.ts index e77c7914..04410dd5 100644 --- a/src/lib/caddy.ts +++ b/src/lib/caddy.ts @@ -23,7 +23,7 @@ import { import http from "node:http"; import https from "node:https"; import db, { nowIso } from "./db"; -import { isNull } from "drizzle-orm"; +import { eq, isNull } from "drizzle-orm"; import { config } from "./config"; import { getCloudflareSettings, @@ -47,7 +47,8 @@ import { certificates, caCertificates, issuedClientCertificates, - proxyHosts + proxyHosts, + l4ProxyHosts } from "./db/schema"; import { type GeoBlockMode, type WafHostConfig, type MtlsConfig, type RedirectRule, type RewriteConfig } from "./models/proxy-hosts"; import { buildClientAuthentication, groupMtlsDomainsByCaSet } from "./caddy-mtls"; @@ -117,6 +118,14 @@ type ProxyHostMeta = { rewrite?: RewriteConfig; }; +type L4Meta = { + load_balancer?: LoadBalancerMeta; + dns_resolver?: DnsResolverMeta; + upstream_dns_resolution?: UpstreamDnsResolutionMeta; + geoblock?: GeoBlockSettings; + geoblock_mode?: GeoBlockMode; +}; + type ProxyHostAuthentikMeta = { enabled?: boolean; outpost_domain?: string; @@ -1297,6 +1306,204 @@ async function buildTlsAutomation( }; } +async function buildL4Servers(): Promise | 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(); + for (const host of l4Hosts) { + const key = host.listenAddress; + if (!serverMap.has(key)) serverMap.set(key, []); + serverMap.get(key)!.push(host); + } + + const servers: Record = {}; + let serverIdx = 0; + for (const [listenAddr, hosts] of serverMap) { + const routes: Record[] = []; + + for (const host of hosts) { + const route: Record = {}; + + // Build matchers + const matcherType = host.matcherType as string; + const matcherValues = host.matcherValue ? parseJson(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(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[] = []; + + // 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(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 = { + 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 = { + 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 = { + match: [ + { + blocker: blockerMatcher, + ...(route.match ? (route.match as Record[])[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 } }; } diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index c39ee7f5..9cbd3407 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -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(), +}); diff --git a/src/lib/l4-ports.ts b/src/lib/l4-ports.ts new file mode 100644 index 00000000..c3a7e553 --- /dev/null +++ b/src/lib/l4-ports.ts @@ -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 { + const hosts = await db + .select({ + listenAddress: l4ProxyHosts.listenAddress, + protocol: l4ProxyHosts.protocol, + }) + .from(l4ProxyHosts) + .where(eq(l4ProxyHosts.enabled, true)); + + const portSet = new Set(); + 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 { + 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 { + 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); +} diff --git a/src/lib/models/l4-proxy-hosts.ts b/src/lib/models/l4-proxy-hosts.ts new file mode 100644 index 00000000..0020bd43 --- /dev/null +++ b/src/lib/models/l4-proxy-hosts.ts @@ -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 | null; + dns_resolver?: Partial | null; + upstream_dns_resolution?: Partial | 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(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 | 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 | 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 | 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(row.meta, {}); + return { + id: row.id, + name: row.name, + protocol: row.protocol as L4Protocol, + listen_address: row.listenAddress, + upstreams: safeJsonParse(row.upstreams, []), + matcher_type: (row.matcherType as L4MatcherType) || "none", + matcher_value: safeJsonParse(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, 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 { + const hosts = await db.select().from(l4ProxyHosts).orderBy(desc(l4ProxyHosts.createdAt)); + return hosts.map(parseL4ProxyHost); +} + +export async function countL4ProxyHosts(search?: string): Promise { + 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 { + 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 { + 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, 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(); +} diff --git a/tests/docker-compose.test.yml b/tests/docker-compose.test.yml index 2afefb48..ebc7bbac 100644 --- a/tests/docker-compose.test.yml +++ b/tests/docker-compose.test.yml @@ -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 diff --git a/tests/e2e/functional/l4-proxy-routing.spec.ts b/tests/e2e/functional/l4-proxy-routing.spec.ts new file mode 100644 index 00000000..fe93c0b9 --- /dev/null +++ b/tests/e2e/functional/l4-proxy-routing.spec.ts @@ -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); + }); +}); diff --git a/tests/e2e/l4-proxy-hosts.spec.ts b/tests/e2e/l4-proxy-hosts.spec.ts new file mode 100644 index 00000000..77585088 --- /dev/null +++ b/tests/e2e/l4-proxy-hosts.spec.ts @@ -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 }); + }); +}); diff --git a/tests/helpers/l4-proxy-api.ts b/tests/helpers/l4-proxy-api.ts new file mode 100644 index 00000000..37a6a522 --- /dev/null +++ b/tests/helpers/l4-proxy-api.ts @@ -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 { + 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 = { + 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 }); +} diff --git a/tests/helpers/tcp.ts b/tests/helpers/tcp.ts new file mode 100644 index 00000000..fa2b1296 --- /dev/null +++ b/tests/helpers/tcp.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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`); +} diff --git a/tests/integration/l4-caddy-config.test.ts b/tests/integration/l4-caddy-config.test.ts new file mode 100644 index 00000000..6a4caabf --- /dev/null +++ b/tests/integration/l4-caddy-config.test.ts @@ -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 = {}) { + 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(); + for (const host of enabledRows) { + const key = host.listenAddress; + if (!serverMap.has(key)) serverMap.set(key, []); + serverMap.get(key)!.push(host); + } + + const servers: Record = {}; + let serverIdx = 0; + for (const [listenAddr, hosts] of serverMap) { + const routes = hosts.map(host => { + const route: Record = {}; + 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[] = []; + 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 = { + 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 = {}; + if (lb.active_health_check?.enabled) { + const active: Record = {}; + 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 = {}; + 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'); + }); +}); diff --git a/tests/integration/l4-ports.test.ts b/tests/integration/l4-ports.test.ts new file mode 100644 index 00000000..1bb896eb --- /dev/null +++ b/tests/integration/l4-ports.test.ts @@ -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 = {}) { + 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'); + }); +}); diff --git a/tests/integration/l4-proxy-hosts.test.ts b/tests/integration/l4-proxy-hosts.test.ts new file mode 100644 index 00000000..ea6ffc9c --- /dev/null +++ b/tests/integration/l4-proxy-hosts.test.ts @@ -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 = {}) { + 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); + }); +}); diff --git a/tests/unit/l4-port-manager-entrypoint.test.ts b/tests/unit/l4-port-manager-entrypoint.test.ts new file mode 100644 index 00000000..076f54bf --- /dev/null +++ b/tests/unit/l4-port-manager-entrypoint.test.ts @@ -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'); + } + }); +}); diff --git a/tests/unit/l4-proxy-hosts-validation.test.ts b/tests/unit/l4-proxy-hosts-validation.test.ts new file mode 100644 index 00000000..195810d6 --- /dev/null +++ b/tests/unit/l4-proxy-hosts-validation.test.ts @@ -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']); + }); +});