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

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

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