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

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

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

View File

@@ -10,6 +10,11 @@ services:
ports:
- "80:80"
- "443:443"
# L4 test ports (TCP)
- "15432:15432"
- "15433:15433"
# L4 test ports (UDP)
- "15353:15353/udp"
# Lightweight echo server reachable by Caddy as "echo-server:8080".
# Returns a fixed body so tests can assert the proxy routed the request.
echo-server:
@@ -30,6 +35,23 @@ services:
image: traefik/whoami
networks:
- caddy-network
# TCP echo server for L4 proxy tests.
# Listens on port 9000 and echoes back anything sent to it with a prefix.
tcp-echo:
image: cjimti/go-echo
environment:
TCP_PORT: "9000"
NODE_NAME: "tcp-echo-ok"
networks:
- caddy-network
# UDP echo server for L4 proxy tests.
# Simple socat-based UDP echo: reflects any datagram back to sender.
udp-echo:
image: alpine/socat
command: ["UDP4-RECVFROM:9001,fork", "EXEC:cat"]
networks:
- caddy-network
volumes:
caddy-manager-data:
name: caddy-manager-data-test

View File

@@ -0,0 +1,134 @@
/**
* Functional tests: L4 (TCP/UDP) proxy routing.
*
* Creates real L4 proxy hosts pointing at echo containers,
* then sends raw TCP connections and UDP datagrams through Caddy
* and asserts the traffic reaches the upstream.
*
* Test ports exposed on the Caddy container:
* TCP: 15432, 15433
* UDP: 15353
*
* Upstream services:
* tcp-echo (cjimti/go-echo on port 9000) — echoes TCP data
* udp-echo (alpine/socat on port 9001) — echoes UDP datagrams
*/
import { test, expect } from '@playwright/test';
import { createL4ProxyHost } from '../../helpers/l4-proxy-api';
import { tcpSend, waitForTcpRoute, tcpConnect, udpSend, waitForUdpRoute } from '../../helpers/tcp';
const TCP_PORT = 15432;
const TCP_PORT_2 = 15433;
const UDP_PORT = 15353;
// ---------------------------------------------------------------------------
// TCP routing
// ---------------------------------------------------------------------------
test.describe.serial('L4 TCP Proxy Routing', () => {
test('setup: create TCP proxy host pointing at tcp-echo', async ({ page }) => {
await createL4ProxyHost(page, {
name: 'L4 TCP Echo Test',
protocol: 'tcp',
listenAddress: `:${TCP_PORT}`,
upstream: 'tcp-echo:9000',
});
await waitForTcpRoute('127.0.0.1', TCP_PORT);
});
test('routes TCP traffic to the upstream echo server', async () => {
const res = await tcpSend('127.0.0.1', TCP_PORT, 'hello from test\n');
expect(res.connected).toBe(true);
expect(res.data).toContain('hello from test');
});
test('TCP connection is accepted on the L4 port', async () => {
const connected = await tcpConnect('127.0.0.1', TCP_PORT);
expect(connected).toBe(true);
});
test('unused TCP port does not accept connections', async () => {
const connected = await tcpConnect('127.0.0.1', TCP_PORT_2, 2000);
expect(connected).toBe(false);
});
test('disabled TCP proxy host stops accepting connections', async ({ page }) => {
await page.goto('/l4-proxy-hosts');
const row = page.locator('tr', { hasText: 'L4 TCP Echo Test' });
await row.locator('input[type="checkbox"]').first().click({ force: true });
await page.waitForTimeout(3_000);
const connected = await tcpConnect('127.0.0.1', TCP_PORT, 2000);
expect(connected).toBe(false);
// Re-enable
await row.locator('input[type="checkbox"]').first().click({ force: true });
await page.waitForTimeout(2_000);
});
});
test.describe.serial('L4 Multiple TCP Hosts', () => {
test('setup: create second TCP proxy host on different port', async ({ page }) => {
await createL4ProxyHost(page, {
name: 'L4 TCP Echo Test 2',
protocol: 'tcp',
listenAddress: `:${TCP_PORT_2}`,
upstream: 'tcp-echo:9000',
});
await waitForTcpRoute('127.0.0.1', TCP_PORT_2);
});
test('both TCP ports route traffic independently', async () => {
const res1 = await tcpSend('127.0.0.1', TCP_PORT, 'port1\n');
const res2 = await tcpSend('127.0.0.1', TCP_PORT_2, 'port2\n');
expect(res1.connected).toBe(true);
expect(res2.connected).toBe(true);
expect(res1.data).toContain('port1');
expect(res2.data).toContain('port2');
});
});
// ---------------------------------------------------------------------------
// UDP routing
// ---------------------------------------------------------------------------
test.describe.serial('L4 UDP Proxy Routing', () => {
test('setup: create UDP proxy host pointing at udp-echo', async ({ page }) => {
await createL4ProxyHost(page, {
name: 'L4 UDP Echo Test',
protocol: 'udp',
listenAddress: `:${UDP_PORT}`,
upstream: 'udp-echo:9001',
});
await waitForUdpRoute('127.0.0.1', UDP_PORT);
});
test('routes UDP datagrams to the upstream echo server', async () => {
const res = await udpSend('127.0.0.1', UDP_PORT, 'hello udp');
expect(res.received).toBe(true);
expect(res.data).toContain('hello udp');
});
test('sends multiple UDP datagrams independently', async () => {
const res1 = await udpSend('127.0.0.1', UDP_PORT, 'datagram-1');
const res2 = await udpSend('127.0.0.1', UDP_PORT, 'datagram-2');
expect(res1.received).toBe(true);
expect(res2.received).toBe(true);
expect(res1.data).toContain('datagram-1');
expect(res2.data).toContain('datagram-2');
});
test('disabled UDP proxy host stops responding', async ({ page }) => {
await page.goto('/l4-proxy-hosts');
const row = page.locator('tr', { hasText: 'L4 UDP Echo Test' });
await row.locator('input[type="checkbox"]').first().click({ force: true });
await page.waitForTimeout(3_000);
const res = await udpSend('127.0.0.1', UDP_PORT, 'should-fail', 2000);
expect(res.received).toBe(false);
// Re-enable
await row.locator('input[type="checkbox"]').first().click({ force: true });
await page.waitForTimeout(2_000);
});
});

View File

@@ -0,0 +1,68 @@
/**
* E2E tests: L4 Proxy Hosts page.
*
* Verifies the L4 Proxy Hosts UI — navigation, list, create/edit/delete dialogs.
*/
import { test, expect } from '@playwright/test';
test.describe('L4 Proxy Hosts page', () => {
test('is accessible from sidebar navigation', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: /l4 proxy hosts/i }).click();
await expect(page).toHaveURL(/\/l4-proxy-hosts/);
await expect(page.getByText('L4 Proxy Hosts')).toBeVisible();
});
test('shows empty state when no L4 hosts exist', async ({ page }) => {
await page.goto('/l4-proxy-hosts');
await expect(page.getByText(/no l4 proxy hosts found/i)).toBeVisible();
});
test('create dialog opens and contains expected fields', async ({ page }) => {
await page.goto('/l4-proxy-hosts');
await page.getByRole('button', { name: /create l4 host/i }).click();
await expect(page.getByRole('dialog')).toBeVisible();
// Verify key form fields exist
await expect(page.getByLabel('Name')).toBeVisible();
await expect(page.getByLabel('Protocol')).toBeVisible();
await expect(page.getByLabel('Listen Address')).toBeVisible();
await expect(page.getByLabel('Upstreams')).toBeVisible();
await expect(page.getByLabel('Matcher')).toBeVisible();
});
test('creates a new L4 proxy host', async ({ page }) => {
await page.goto('/l4-proxy-hosts');
await page.getByRole('button', { name: /create l4 host/i }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByLabel('Name').fill('E2E Test Host');
await page.getByLabel('Listen Address').fill(':19999');
await page.getByLabel('Upstreams').fill('10.0.0.1:5432');
await page.getByRole('button', { name: /create/i }).click();
// Dialog should close and host should appear in table
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
await expect(page.getByText('E2E Test Host')).toBeVisible();
await expect(page.getByText(':19999')).toBeVisible();
});
test('deletes the created L4 proxy host', async ({ page }) => {
await page.goto('/l4-proxy-hosts');
await expect(page.getByText('E2E Test Host')).toBeVisible();
// Click delete button in the row
const row = page.locator('tr', { hasText: 'E2E Test Host' });
await row.getByRole('button', { name: /delete/i }).click();
// Confirm deletion
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText(/are you sure/i)).toBeVisible();
await page.getByRole('button', { name: /delete/i }).click();
// Host should be removed
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
await expect(page.getByText('E2E Test Host')).not.toBeVisible({ timeout: 5_000 });
});
});

View File

@@ -0,0 +1,80 @@
/**
* Higher-level helpers for creating L4 proxy hosts in E2E tests.
*
* All helpers accept a Playwright `Page` (pre-authenticated via the
* global storageState) so they integrate cleanly with the standard
* `page` test fixture.
*/
import { expect, type Page } from '@playwright/test';
export interface L4ProxyHostConfig {
name: string;
protocol?: 'tcp' | 'udp';
listenAddress: string;
upstream: string; // e.g. "tcp-echo:9000"
matcherType?: 'none' | 'tls_sni' | 'http_host' | 'proxy_protocol';
matcherValue?: string; // comma-separated
tlsTermination?: boolean;
proxyProtocolReceive?: boolean;
proxyProtocolVersion?: 'v1' | 'v2';
}
/**
* Create an L4 proxy host via the browser UI.
*/
export async function createL4ProxyHost(page: Page, config: L4ProxyHostConfig): Promise<void> {
await page.goto('/l4-proxy-hosts');
await page.getByRole('button', { name: /create l4 host/i }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByLabel('Name').fill(config.name);
// Protocol select
if (config.protocol && config.protocol !== 'tcp') {
await page.getByLabel('Protocol').click();
await page.getByRole('option', { name: new RegExp(config.protocol, 'i') }).click();
}
await page.getByLabel('Listen Address').fill(config.listenAddress);
await page.getByLabel('Upstreams').fill(config.upstream);
// Matcher type
if (config.matcherType && config.matcherType !== 'none') {
await page.getByLabel('Matcher').click();
const matcherLabels: Record<string, RegExp> = {
tls_sni: /tls sni/i,
http_host: /http host/i,
proxy_protocol: /proxy protocol/i,
};
await page.getByRole('option', { name: matcherLabels[config.matcherType] }).click();
if (config.matcherValue && (config.matcherType === 'tls_sni' || config.matcherType === 'http_host')) {
await page.getByLabel(/hostnames/i).fill(config.matcherValue);
}
}
// TLS termination
if (config.tlsTermination) {
await page.getByLabel(/tls termination/i).check();
}
// Proxy protocol receive
if (config.proxyProtocolReceive) {
await page.getByLabel(/accept inbound proxy/i).check();
}
// Proxy protocol version
if (config.proxyProtocolVersion) {
await page.getByLabel(/send proxy protocol/i).click();
await page.getByRole('option', { name: config.proxyProtocolVersion }).click();
}
// Submit
await page.getByRole('button', { name: /create/i }).click();
// Wait for success state (dialog closes or success alert)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
// Verify host appears in the table
await expect(page.getByText(config.name)).toBeVisible({ timeout: 10_000 });
}

161
tests/helpers/tcp.ts Normal file
View File

@@ -0,0 +1,161 @@
/**
* Low-level TCP and UDP helpers for L4 proxy functional tests.
*
* Sends raw TCP connections and UDP datagrams to Caddy's L4 proxy ports
* and reads responses.
*/
import net from 'node:net';
import dgram from 'node:dgram';
export interface TcpResponse {
data: string;
connected: boolean;
}
/**
* Open a TCP connection to the given host:port, send a payload,
* and collect whatever comes back within the timeout window.
*/
export function tcpSend(
host: string,
port: number,
payload: string,
timeoutMs = 5_000
): Promise<TcpResponse> {
return new Promise((resolve, reject) => {
let data = '';
let connected = false;
const socket = net.createConnection({ host, port }, () => {
connected = true;
socket.write(payload);
});
socket.setTimeout(timeoutMs);
socket.on('data', (chunk) => {
data += chunk.toString();
});
socket.on('end', () => {
resolve({ data, connected });
});
socket.on('timeout', () => {
socket.destroy();
resolve({ data, connected });
});
socket.on('error', (err) => {
if (connected) {
resolve({ data, connected });
} else {
reject(err);
}
});
});
}
/**
* Test if a TCP port is accepting connections.
*/
export function tcpConnect(
host: string,
port: number,
timeoutMs = 5_000
): Promise<boolean> {
return new Promise((resolve) => {
const socket = net.createConnection({ host, port }, () => {
socket.destroy();
resolve(true);
});
socket.setTimeout(timeoutMs);
socket.on('timeout', () => {
socket.destroy();
resolve(false);
});
socket.on('error', () => {
resolve(false);
});
});
}
/**
* Poll until a TCP port accepts connections.
* Similar to waitForRoute() but for TCP.
*/
export async function waitForTcpRoute(
host: string,
port: number,
timeoutMs = 15_000
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const ok = await tcpConnect(host, port, 2000);
if (ok) return;
await new Promise(r => setTimeout(r, 500));
}
throw new Error(`TCP port ${host}:${port} not ready after ${timeoutMs}ms`);
}
// ---------------------------------------------------------------------------
// UDP helpers
// ---------------------------------------------------------------------------
export interface UdpResponse {
data: string;
received: boolean;
}
/**
* Send a UDP datagram and wait for a response.
*/
export function udpSend(
host: string,
port: number,
payload: string,
timeoutMs = 5_000
): Promise<UdpResponse> {
return new Promise((resolve) => {
const socket = dgram.createSocket('udp4');
let received = false;
let data = '';
const timer = setTimeout(() => {
socket.close();
resolve({ data, received });
}, timeoutMs);
socket.on('message', (msg) => {
received = true;
data += msg.toString();
clearTimeout(timer);
socket.close();
resolve({ data, received });
});
socket.on('error', () => {
clearTimeout(timer);
socket.close();
resolve({ data, received });
});
socket.send(payload, port, host);
});
}
/**
* Poll until a UDP port responds to a test datagram.
*/
export async function waitForUdpRoute(
host: string,
port: number,
timeoutMs = 15_000
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await udpSend(host, port, 'ping', 2000);
if (res.received) return;
await new Promise(r => setTimeout(r, 500));
}
throw new Error(`UDP port ${host}:${port} not ready after ${timeoutMs}ms`);
}

View File

@@ -0,0 +1,572 @@
/**
* Integration tests for L4 Caddy config generation.
*
* Verifies that the data stored in l4_proxy_hosts can be used to produce
* correct caddy-l4 JSON config structures. Tests the config shape that
* buildL4Servers() would produce by reconstructing it from DB rows.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb, type TestDb } from '../helpers/db';
import { l4ProxyHosts } from '../../src/lib/db/schema';
import { eq } from 'drizzle-orm';
let db: TestDb;
beforeEach(() => {
db = createTestDb();
});
function nowIso() {
return new Date().toISOString();
}
async function insertL4Host(overrides: Partial<typeof l4ProxyHosts.$inferInsert> = {}) {
const now = nowIso();
const [host] = await db.insert(l4ProxyHosts).values({
name: 'Test L4 Host',
protocol: 'tcp',
listenAddress: ':5432',
upstreams: JSON.stringify(['10.0.0.1:5432']),
matcherType: 'none',
matcherValue: null,
tlsTermination: false,
proxyProtocolVersion: null,
proxyProtocolReceive: false,
enabled: true,
createdAt: now,
updatedAt: now,
...overrides,
}).returning();
return host;
}
/**
* Reconstruct the caddy-l4 JSON config that buildL4Servers() would produce
* from a set of L4 proxy host rows. This mirrors the logic in caddy.ts.
*/
function buildExpectedL4Config(rows: (typeof l4ProxyHosts.$inferSelect)[]) {
const enabledRows = rows.filter(r => r.enabled);
if (enabledRows.length === 0) return null;
const serverMap = new Map<string, typeof enabledRows>();
for (const host of enabledRows) {
const key = host.listenAddress;
if (!serverMap.has(key)) serverMap.set(key, []);
serverMap.get(key)!.push(host);
}
const servers: Record<string, unknown> = {};
let serverIdx = 0;
for (const [listenAddr, hosts] of serverMap) {
const routes = hosts.map(host => {
const route: Record<string, unknown> = {};
const matcherValues = host.matcherValue ? JSON.parse(host.matcherValue) as string[] : [];
if (host.matcherType === 'tls_sni' && matcherValues.length > 0) {
route.match = [{ tls: { sni: matcherValues } }];
} else if (host.matcherType === 'http_host' && matcherValues.length > 0) {
route.match = [{ http: [{ host: matcherValues }] }];
} else if (host.matcherType === 'proxy_protocol') {
route.match = [{ proxy_protocol: {} }];
}
const handlers: Record<string, unknown>[] = [];
if (host.proxyProtocolReceive) handlers.push({ handler: 'proxy_protocol' });
if (host.tlsTermination) handlers.push({ handler: 'tls' });
const upstreams = JSON.parse(host.upstreams) as string[];
const proxyHandler: Record<string, unknown> = {
handler: 'proxy',
upstreams: upstreams.map(u => ({ dial: [u] })),
};
if (host.proxyProtocolVersion) proxyHandler.proxy_protocol = host.proxyProtocolVersion;
// Load balancer config from meta
if (host.meta) {
const meta = JSON.parse(host.meta);
if (meta.load_balancer?.enabled) {
const lb = meta.load_balancer;
proxyHandler.load_balancing = {
selection_policy: { policy: lb.policy ?? 'random' },
...(lb.try_duration ? { try_duration: lb.try_duration } : {}),
...(lb.try_interval ? { try_interval: lb.try_interval } : {}),
...(lb.retries != null ? { retries: lb.retries } : {}),
};
const healthChecks: Record<string, unknown> = {};
if (lb.active_health_check?.enabled) {
const active: Record<string, unknown> = {};
if (lb.active_health_check.port != null) active.port = lb.active_health_check.port;
if (lb.active_health_check.interval) active.interval = lb.active_health_check.interval;
if (lb.active_health_check.timeout) active.timeout = lb.active_health_check.timeout;
if (Object.keys(active).length > 0) healthChecks.active = active;
}
if (lb.passive_health_check?.enabled) {
const passive: Record<string, unknown> = {};
if (lb.passive_health_check.fail_duration) passive.fail_duration = lb.passive_health_check.fail_duration;
if (lb.passive_health_check.max_fails != null) passive.max_fails = lb.passive_health_check.max_fails;
if (lb.passive_health_check.unhealthy_latency) passive.unhealthy_latency = lb.passive_health_check.unhealthy_latency;
if (Object.keys(passive).length > 0) healthChecks.passive = passive;
}
if (Object.keys(healthChecks).length > 0) proxyHandler.health_checks = healthChecks;
}
}
handlers.push(proxyHandler);
route.handle = handlers;
return route;
});
servers[`l4_server_${serverIdx++}`] = { listen: [listenAddr], routes };
}
return servers;
}
// ---------------------------------------------------------------------------
// Config shape tests
// ---------------------------------------------------------------------------
describe('L4 Caddy config generation', () => {
it('returns null when no L4 hosts exist', async () => {
const rows = await db.select().from(l4ProxyHosts);
expect(buildExpectedL4Config(rows)).toBeNull();
});
it('returns null when all hosts are disabled', async () => {
await insertL4Host({ enabled: false });
await insertL4Host({ enabled: false, name: 'Also disabled', listenAddress: ':3306' });
const rows = await db.select().from(l4ProxyHosts);
expect(buildExpectedL4Config(rows)).toBeNull();
});
it('simple TCP proxy — catch-all, single upstream', async () => {
await insertL4Host({
name: 'PostgreSQL',
listenAddress: ':5432',
upstreams: JSON.stringify(['10.0.0.1:5432']),
matcherType: 'none',
});
const rows = await db.select().from(l4ProxyHosts);
const config = buildExpectedL4Config(rows);
expect(config).toEqual({
l4_server_0: {
listen: [':5432'],
routes: [
{
handle: [
{ handler: 'proxy', upstreams: [{ dial: ['10.0.0.1:5432'] }] },
],
},
],
},
});
});
it('TCP proxy with TLS SNI matcher and TLS termination', async () => {
await insertL4Host({
name: 'Secure DB',
listenAddress: ':5432',
matcherType: 'tls_sni',
matcherValue: JSON.stringify(['db.example.com']),
tlsTermination: true,
upstreams: JSON.stringify(['10.0.0.1:5432']),
});
const rows = await db.select().from(l4ProxyHosts);
const config = buildExpectedL4Config(rows);
expect(config).toEqual({
l4_server_0: {
listen: [':5432'],
routes: [
{
match: [{ tls: { sni: ['db.example.com'] } }],
handle: [
{ handler: 'tls' },
{ handler: 'proxy', upstreams: [{ dial: ['10.0.0.1:5432'] }] },
],
},
],
},
});
});
it('HTTP host matcher shape', async () => {
await insertL4Host({
name: 'HTTP Route',
listenAddress: ':8080',
matcherType: 'http_host',
matcherValue: JSON.stringify(['api.example.com']),
upstreams: JSON.stringify(['10.0.0.1:8080']),
});
const rows = await db.select().from(l4ProxyHosts);
const config = buildExpectedL4Config(rows)!;
const route = (config.l4_server_0 as any).routes[0];
expect(route.match).toEqual([{ http: [{ host: ['api.example.com'] }] }]);
});
it('proxy_protocol matcher shape', async () => {
await insertL4Host({
name: 'PP Match',
listenAddress: ':8443',
matcherType: 'proxy_protocol',
upstreams: JSON.stringify(['10.0.0.1:443']),
});
const rows = await db.select().from(l4ProxyHosts);
const config = buildExpectedL4Config(rows)!;
const route = (config.l4_server_0 as any).routes[0];
expect(route.match).toEqual([{ proxy_protocol: {} }]);
});
it('full handler chain — proxy_protocol receive + TLS + proxy with PP v1', async () => {
await insertL4Host({
name: 'Secure IMAP',
listenAddress: '0.0.0.0:993',
upstreams: JSON.stringify(['localhost:143']),
tlsTermination: true,
proxyProtocolReceive: true,
proxyProtocolVersion: 'v1',
});
const rows = await db.select().from(l4ProxyHosts);
const config = buildExpectedL4Config(rows);
expect(config).toEqual({
l4_server_0: {
listen: ['0.0.0.0:993'],
routes: [
{
handle: [
{ handler: 'proxy_protocol' },
{ handler: 'tls' },
{
handler: 'proxy',
proxy_protocol: 'v1',
upstreams: [{ dial: ['localhost:143'] }],
},
],
},
],
},
});
});
it('proxy_protocol v2 outbound', async () => {
await insertL4Host({
name: 'PP v2',
listenAddress: ':8443',
upstreams: JSON.stringify(['10.0.0.1:443']),
proxyProtocolVersion: 'v2',
});
const rows = await db.select().from(l4ProxyHosts);
const config = buildExpectedL4Config(rows)!;
const route = (config.l4_server_0 as any).routes[0];
const proxyHandler = route.handle[route.handle.length - 1];
expect(proxyHandler.proxy_protocol).toBe('v2');
});
it('multiple upstreams for load balancing', async () => {
const upstreams = ['10.0.0.1:5432', '10.0.0.2:5432', '10.0.0.3:5432'];
await insertL4Host({
name: 'LB PG',
listenAddress: ':5432',
upstreams: JSON.stringify(upstreams),
});
const rows = await db.select().from(l4ProxyHosts);
const config = buildExpectedL4Config(rows)!;
const route = (config.l4_server_0 as any).routes[0];
expect(route.handle[0].upstreams).toEqual([
{ dial: ['10.0.0.1:5432'] },
{ dial: ['10.0.0.2:5432'] },
{ dial: ['10.0.0.3:5432'] },
]);
});
it('groups multiple hosts on same port into shared server routes', async () => {
await insertL4Host({
name: 'DB1',
listenAddress: ':5432',
matcherType: 'tls_sni',
matcherValue: JSON.stringify(['db1.example.com']),
upstreams: JSON.stringify(['10.0.0.1:5432']),
});
await insertL4Host({
name: 'DB2',
listenAddress: ':5432',
matcherType: 'tls_sni',
matcherValue: JSON.stringify(['db2.example.com']),
upstreams: JSON.stringify(['10.0.0.2:5432']),
});
const rows = await db.select().from(l4ProxyHosts);
const config = buildExpectedL4Config(rows)!;
// Should be a single server with 2 routes
expect(Object.keys(config)).toHaveLength(1);
const server = config.l4_server_0 as any;
expect(server.listen).toEqual([':5432']);
expect(server.routes).toHaveLength(2);
expect(server.routes[0].match).toEqual([{ tls: { sni: ['db1.example.com'] } }]);
expect(server.routes[1].match).toEqual([{ tls: { sni: ['db2.example.com'] } }]);
});
it('different ports create separate servers', async () => {
await insertL4Host({ name: 'PG', listenAddress: ':5432', upstreams: JSON.stringify(['10.0.0.1:5432']) });
await insertL4Host({ name: 'MySQL', listenAddress: ':3306', upstreams: JSON.stringify(['10.0.0.2:3306']) });
await insertL4Host({ name: 'Redis', listenAddress: ':6379', upstreams: JSON.stringify(['10.0.0.3:6379']) });
const rows = await db.select().from(l4ProxyHosts);
const config = buildExpectedL4Config(rows)!;
expect(Object.keys(config)).toHaveLength(3);
});
it('disabled hosts are excluded from config', async () => {
await insertL4Host({ name: 'Active', listenAddress: ':5432', enabled: true });
await insertL4Host({ name: 'Disabled', listenAddress: ':3306', enabled: false });
const rows = await db.select().from(l4ProxyHosts);
const config = buildExpectedL4Config(rows)!;
// Only the active host should be in config
expect(Object.keys(config)).toHaveLength(1);
expect((config.l4_server_0 as any).listen).toEqual([':5432']);
});
it('UDP proxy — correct listen address', async () => {
await insertL4Host({
name: 'DNS Proxy',
protocol: 'udp',
listenAddress: ':5353',
upstreams: JSON.stringify(['8.8.8.8:53', '8.8.4.4:53']),
});
const rows = await db.select().from(l4ProxyHosts);
const config = buildExpectedL4Config(rows)!;
const server = config.l4_server_0 as any;
expect(server.listen).toEqual([':5353']);
expect(server.routes[0].handle[0].upstreams).toHaveLength(2);
});
it('TLS SNI with multiple hostnames', async () => {
await insertL4Host({
name: 'Multi SNI',
listenAddress: ':443',
matcherType: 'tls_sni',
matcherValue: JSON.stringify(['db1.example.com', 'db2.example.com', 'db3.example.com']),
});
const rows = await db.select().from(l4ProxyHosts);
const config = buildExpectedL4Config(rows)!;
const route = (config.l4_server_0 as any).routes[0];
expect(route.match[0].tls.sni).toEqual(['db1.example.com', 'db2.example.com', 'db3.example.com']);
});
it('load balancer with round_robin policy', async () => {
await insertL4Host({
name: 'LB Host',
listenAddress: ':5432',
upstreams: JSON.stringify(['10.0.0.1:5432', '10.0.0.2:5432']),
meta: JSON.stringify({
load_balancer: {
enabled: true,
policy: 'round_robin',
try_duration: '5s',
retries: 3,
},
}),
});
const rows = await db.select().from(l4ProxyHosts);
const config = buildExpectedL4Config(rows)!;
const route = (config.l4_server_0 as any).routes[0];
const proxyHandler = route.handle[0];
expect(proxyHandler.load_balancing).toEqual({
selection_policy: { policy: 'round_robin' },
try_duration: '5s',
retries: 3,
});
});
it('load balancer with active health check', async () => {
await insertL4Host({
name: 'Health Check Host',
listenAddress: ':3306',
upstreams: JSON.stringify(['10.0.0.1:3306']),
meta: JSON.stringify({
load_balancer: {
enabled: true,
policy: 'least_conn',
active_health_check: {
enabled: true,
port: 3307,
interval: '10s',
timeout: '5s',
},
},
}),
});
const rows = await db.select().from(l4ProxyHosts);
const config = buildExpectedL4Config(rows)!;
const route = (config.l4_server_0 as any).routes[0];
const proxyHandler = route.handle[0];
expect(proxyHandler.health_checks).toEqual({
active: {
port: 3307,
interval: '10s',
timeout: '5s',
},
});
});
it('load balancer with passive health check', async () => {
await insertL4Host({
name: 'Passive HC Host',
listenAddress: ':6379',
upstreams: JSON.stringify(['10.0.0.1:6379']),
meta: JSON.stringify({
load_balancer: {
enabled: true,
policy: 'random',
passive_health_check: {
enabled: true,
fail_duration: '30s',
max_fails: 5,
unhealthy_latency: '2s',
},
},
}),
});
const rows = await db.select().from(l4ProxyHosts);
const config = buildExpectedL4Config(rows)!;
const route = (config.l4_server_0 as any).routes[0];
const proxyHandler = route.handle[0];
expect(proxyHandler.health_checks).toEqual({
passive: {
fail_duration: '30s',
max_fails: 5,
unhealthy_latency: '2s',
},
});
});
it('disabled load balancer does not add config', async () => {
await insertL4Host({
name: 'No LB',
listenAddress: ':5432',
upstreams: JSON.stringify(['10.0.0.1:5432']),
meta: JSON.stringify({
load_balancer: { enabled: false, policy: 'round_robin' },
}),
});
const rows = await db.select().from(l4ProxyHosts);
const config = buildExpectedL4Config(rows)!;
const route = (config.l4_server_0 as any).routes[0];
expect(route.handle[0].load_balancing).toBeUndefined();
});
it('dns resolver config stored in meta', async () => {
await insertL4Host({
name: 'DNS Host',
listenAddress: ':5432',
upstreams: JSON.stringify(['db.example.com:5432']),
meta: JSON.stringify({
dns_resolver: {
enabled: true,
resolvers: ['1.1.1.1', '8.8.8.8'],
fallbacks: ['8.8.4.4'],
timeout: '5s',
},
}),
});
const rows = await db.select().from(l4ProxyHosts);
const meta = JSON.parse(rows[0].meta!);
expect(meta.dns_resolver.enabled).toBe(true);
expect(meta.dns_resolver.resolvers).toEqual(['1.1.1.1', '8.8.8.8']);
expect(meta.dns_resolver.fallbacks).toEqual(['8.8.4.4']);
expect(meta.dns_resolver.timeout).toBe('5s');
});
it('geo blocking config produces blocker matcher route', async () => {
await insertL4Host({
name: 'Geo Blocked',
listenAddress: ':5432',
upstreams: JSON.stringify(['10.0.0.1:5432']),
meta: JSON.stringify({
geoblock: {
enabled: true,
block_countries: ['CN', 'RU'],
block_continents: [],
block_asns: [12345],
block_cidrs: [],
block_ips: [],
allow_countries: ['US'],
allow_continents: [],
allow_asns: [],
allow_cidrs: [],
allow_ips: [],
},
}),
});
const rows = await db.select().from(l4ProxyHosts);
const meta = JSON.parse(rows[0].meta!);
expect(meta.geoblock.enabled).toBe(true);
expect(meta.geoblock.block_countries).toEqual(['CN', 'RU']);
expect(meta.geoblock.block_asns).toEqual([12345]);
expect(meta.geoblock.allow_countries).toEqual(['US']);
});
it('disabled geo blocking does not produce a route', async () => {
await insertL4Host({
name: 'No Geo Block',
listenAddress: ':5432',
upstreams: JSON.stringify(['10.0.0.1:5432']),
meta: JSON.stringify({
geoblock: {
enabled: false,
block_countries: ['CN'],
block_continents: [], block_asns: [], block_cidrs: [], block_ips: [],
allow_countries: [], allow_continents: [], allow_asns: [], allow_cidrs: [], allow_ips: [],
},
}),
});
const rows = await db.select().from(l4ProxyHosts);
const config = buildExpectedL4Config(rows)!;
// Only the proxy route should exist, no blocking route
const server = config.l4_server_0 as any;
expect(server.routes).toHaveLength(1);
expect(server.routes[0].handle[0].handler).toBe('proxy');
});
it('upstream dns resolution config stored in meta', async () => {
await insertL4Host({
name: 'Pinned Host',
listenAddress: ':5432',
upstreams: JSON.stringify(['db.example.com:5432']),
meta: JSON.stringify({
upstream_dns_resolution: {
enabled: true,
family: 'ipv4',
},
}),
});
const rows = await db.select().from(l4ProxyHosts);
const meta = JSON.parse(rows[0].meta!);
expect(meta.upstream_dns_resolution.enabled).toBe(true);
expect(meta.upstream_dns_resolution.family).toBe('ipv4');
});
});

View File

@@ -0,0 +1,378 @@
/**
* Integration tests for L4 port management.
*
* Tests the port computation, override file generation, diff detection,
* and status lifecycle.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import type { TestDb } from '../helpers/db';
// ---------------------------------------------------------------------------
// Mock db and set L4_PORTS_DIR to a temp directory for file operations
// ---------------------------------------------------------------------------
const ctx = vi.hoisted(() => {
const { mkdirSync } = require('node:fs');
const { join } = require('node:path');
const { tmpdir } = require('node:os');
const dir = join(tmpdir(), `l4-ports-test-${Date.now()}`);
mkdirSync(dir, { recursive: true });
process.env.L4_PORTS_DIR = dir;
return { db: null as unknown as TestDb, tmpDir: dir };
});
vi.mock('../../src/lib/db', async () => {
const { createTestDb } = await import('../helpers/db');
const schemaModule = await import('../../src/lib/db/schema');
ctx.db = createTestDb();
return {
default: ctx.db,
schema: schemaModule,
nowIso: () => new Date().toISOString(),
toIso: (value: string | Date | null | undefined): string | null => {
if (!value) return null;
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
},
};
});
vi.mock('../../src/lib/caddy', () => ({
applyCaddyConfig: vi.fn().mockResolvedValue({ ok: true }),
}));
vi.mock('../../src/lib/audit', () => ({
logAuditEvent: vi.fn(),
}));
import * as schema from '../../src/lib/db/schema';
import {
getRequiredL4Ports,
getAppliedL4Ports,
getL4PortsDiff,
applyL4Ports,
getL4PortsStatus,
} from '../../src/lib/l4-ports';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function nowIso() {
return new Date().toISOString();
}
function makeL4Host(overrides: Partial<typeof schema.l4ProxyHosts.$inferInsert> = {}) {
const now = nowIso();
return {
name: 'Test L4 Host',
protocol: 'tcp',
listenAddress: ':5432',
upstreams: JSON.stringify(['10.0.0.1:5432']),
matcherType: 'none',
matcherValue: null,
tlsTermination: false,
proxyProtocolVersion: null,
proxyProtocolReceive: false,
ownerUserId: null,
meta: null,
enabled: true,
createdAt: now,
updatedAt: now,
...overrides,
} satisfies typeof schema.l4ProxyHosts.$inferInsert;
}
function cleanTmpDir() {
for (const file of ['docker-compose.l4-ports.yml', 'l4-ports.trigger', 'l4-ports.status']) {
const path = join(ctx.tmpDir, file);
if (existsSync(path)) rmSync(path);
}
}
beforeEach(async () => {
await ctx.db.delete(schema.l4ProxyHosts);
cleanTmpDir();
});
// ---------------------------------------------------------------------------
// getRequiredL4Ports
// ---------------------------------------------------------------------------
describe('getRequiredL4Ports', () => {
it('returns empty array when no L4 hosts exist', async () => {
const ports = await getRequiredL4Ports();
expect(ports).toEqual([]);
});
it('returns TCP port for enabled host', async () => {
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
listenAddress: ':5432',
protocol: 'tcp',
enabled: true,
}));
const ports = await getRequiredL4Ports();
expect(ports).toEqual(['5432:5432']);
});
it('returns UDP port with /udp suffix', async () => {
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
listenAddress: ':5353',
protocol: 'udp',
enabled: true,
}));
const ports = await getRequiredL4Ports();
expect(ports).toEqual(['5353:5353/udp']);
});
it('excludes disabled hosts', async () => {
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
name: 'Enabled',
listenAddress: ':5432',
enabled: true,
}));
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
name: 'Disabled',
listenAddress: ':3306',
enabled: false,
}));
const ports = await getRequiredL4Ports();
expect(ports).toEqual(['5432:5432']);
});
it('deduplicates ports from multiple hosts on same address', async () => {
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
name: 'Host 1',
listenAddress: ':5432',
}));
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
name: 'Host 2',
listenAddress: ':5432',
}));
const ports = await getRequiredL4Ports();
expect(ports).toEqual(['5432:5432']);
});
it('handles HOST:PORT format', async () => {
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
listenAddress: '0.0.0.0:5432',
}));
const ports = await getRequiredL4Ports();
expect(ports).toEqual(['5432:5432']);
});
it('returns multiple ports sorted', async () => {
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
name: 'Redis',
listenAddress: ':6379',
}));
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
name: 'PG',
listenAddress: ':5432',
}));
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
name: 'MySQL',
listenAddress: ':3306',
}));
const ports = await getRequiredL4Ports();
expect(ports).toEqual(['3306:3306', '5432:5432', '6379:6379']);
});
});
// ---------------------------------------------------------------------------
// getAppliedL4Ports
// ---------------------------------------------------------------------------
describe('getAppliedL4Ports', () => {
it('returns empty when no override file exists', () => {
const ports = getAppliedL4Ports();
expect(ports).toEqual([]);
});
it('parses ports from override file', () => {
writeFileSync(join(ctx.tmpDir, 'docker-compose.l4-ports.yml'), `services:
caddy:
ports:
- "5432:5432"
- "3306:3306"
`);
const ports = getAppliedL4Ports();
expect(ports).toEqual(['3306:3306', '5432:5432']);
});
it('handles empty override file', () => {
writeFileSync(join(ctx.tmpDir, 'docker-compose.l4-ports.yml'), `services: {}
`);
const ports = getAppliedL4Ports();
expect(ports).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// getL4PortsDiff
// ---------------------------------------------------------------------------
describe('getL4PortsDiff', () => {
it('needsApply is false when no hosts and no override', async () => {
const diff = await getL4PortsDiff();
expect(diff.needsApply).toBe(false);
expect(diff.requiredPorts).toEqual([]);
expect(diff.currentPorts).toEqual([]);
});
it('needsApply is true when host exists but no override', async () => {
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
listenAddress: ':5432',
}));
const diff = await getL4PortsDiff();
expect(diff.needsApply).toBe(true);
expect(diff.requiredPorts).toEqual(['5432:5432']);
expect(diff.currentPorts).toEqual([]);
});
it('needsApply is false when override matches', async () => {
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
listenAddress: ':5432',
}));
writeFileSync(join(ctx.tmpDir, 'docker-compose.l4-ports.yml'), `services:
caddy:
ports:
- "5432:5432"
`);
const diff = await getL4PortsDiff();
expect(diff.needsApply).toBe(false);
});
it('needsApply is true when override has different ports', async () => {
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
listenAddress: ':5432',
}));
writeFileSync(join(ctx.tmpDir, 'docker-compose.l4-ports.yml'), `services:
caddy:
ports:
- "3306:3306"
`);
const diff = await getL4PortsDiff();
expect(diff.needsApply).toBe(true);
});
});
// ---------------------------------------------------------------------------
// applyL4Ports
// ---------------------------------------------------------------------------
describe('applyL4Ports', () => {
it('writes override file with required ports', async () => {
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
listenAddress: ':5432',
}));
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
name: 'DNS',
listenAddress: ':5353',
protocol: 'udp',
}));
const status = await applyL4Ports();
expect(status.state).toBe('pending');
const overrideContent = readFileSync(join(ctx.tmpDir, 'docker-compose.l4-ports.yml'), 'utf-8');
expect(overrideContent).toContain('"5432:5432"');
expect(overrideContent).toContain('"5353:5353/udp"');
});
it('writes trigger file', async () => {
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
listenAddress: ':5432',
}));
await applyL4Ports();
const triggerPath = join(ctx.tmpDir, 'l4-ports.trigger');
expect(existsSync(triggerPath)).toBe(true);
const trigger = JSON.parse(readFileSync(triggerPath, 'utf-8'));
expect(trigger.triggeredAt).toBeDefined();
expect(trigger.ports).toEqual(['5432:5432']);
});
it('writes empty override when no ports needed', async () => {
const status = await applyL4Ports();
expect(status.state).toBe('pending');
const overrideContent = readFileSync(join(ctx.tmpDir, 'docker-compose.l4-ports.yml'), 'utf-8');
expect(overrideContent).toContain('services: {}');
});
it('override file is idempotent — same ports produce same content', async () => {
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
listenAddress: ':5432',
}));
await applyL4Ports();
const content1 = readFileSync(join(ctx.tmpDir, 'docker-compose.l4-ports.yml'), 'utf-8');
await applyL4Ports();
const content2 = readFileSync(join(ctx.tmpDir, 'docker-compose.l4-ports.yml'), 'utf-8');
expect(content1).toBe(content2);
});
});
// ---------------------------------------------------------------------------
// getL4PortsStatus
// ---------------------------------------------------------------------------
describe('getL4PortsStatus', () => {
it('returns idle when no status file exists', () => {
const status = getL4PortsStatus();
expect(status.state).toBe('idle');
});
it('returns idle when no status file exists even if trigger file is present', () => {
// Trigger files are deleted by the sidecar after processing.
// A leftover trigger file must NEVER cause "Waiting for port manager sidecar..."
// because that message gets permanently stuck if the sidecar is slow or restarting.
writeFileSync(join(ctx.tmpDir, 'l4-ports.trigger'), JSON.stringify({
triggeredAt: new Date().toISOString(),
}));
const status = getL4PortsStatus();
expect(status.state).toBe('idle');
});
it('returns applied when status file says applied', () => {
writeFileSync(join(ctx.tmpDir, 'l4-ports.status'), JSON.stringify({
state: 'applied',
message: 'Success',
appliedAt: new Date().toISOString(),
}));
const status = getL4PortsStatus();
expect(status.state).toBe('applied');
});
it('returns failed when status file says failed', () => {
writeFileSync(join(ctx.tmpDir, 'l4-ports.status'), JSON.stringify({
state: 'failed',
message: 'Failed',
error: 'Container failed',
appliedAt: new Date().toISOString(),
}));
const status = getL4PortsStatus();
expect(status.state).toBe('failed');
expect(status.error).toBe('Container failed');
});
it('returns status from file regardless of trigger file presence', () => {
// The sidecar deletes triggers after processing, so the status file is
// the single source of truth — trigger file presence is irrelevant here.
writeFileSync(join(ctx.tmpDir, 'l4-ports.trigger'), JSON.stringify({
triggeredAt: '2026-03-21T12:00:00Z',
}));
writeFileSync(join(ctx.tmpDir, 'l4-ports.status'), JSON.stringify({
state: 'applied',
message: 'Done',
appliedAt: '2026-01-01T00:00:00Z',
}));
const status = getL4PortsStatus();
expect(status.state).toBe('applied');
});
});

View File

@@ -0,0 +1,335 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb, type TestDb } from '../helpers/db';
import { l4ProxyHosts } from '../../src/lib/db/schema';
import { eq } from 'drizzle-orm';
let db: TestDb;
beforeEach(() => {
db = createTestDb();
});
function nowIso() {
return new Date().toISOString();
}
async function insertL4Host(overrides: Partial<typeof l4ProxyHosts.$inferInsert> = {}) {
const now = nowIso();
const [host] = await db.insert(l4ProxyHosts).values({
name: 'Test L4 Host',
protocol: 'tcp',
listenAddress: ':5432',
upstreams: JSON.stringify(['10.0.0.1:5432']),
matcherType: 'none',
matcherValue: null,
tlsTermination: false,
proxyProtocolVersion: null,
proxyProtocolReceive: false,
enabled: true,
createdAt: now,
updatedAt: now,
...overrides,
}).returning();
return host;
}
// ---------------------------------------------------------------------------
// Basic CRUD
// ---------------------------------------------------------------------------
describe('l4-proxy-hosts integration', () => {
it('inserts and retrieves an L4 proxy host', async () => {
const host = await insertL4Host();
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
expect(row).toBeDefined();
expect(row!.name).toBe('Test L4 Host');
expect(row!.protocol).toBe('tcp');
expect(row!.listenAddress).toBe(':5432');
});
it('delete by id removes the host', async () => {
const host = await insertL4Host();
await db.delete(l4ProxyHosts).where(eq(l4ProxyHosts.id, host.id));
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
expect(row).toBeUndefined();
});
it('multiple L4 hosts — count is correct', async () => {
await insertL4Host({ name: 'PG', listenAddress: ':5432' });
await insertL4Host({ name: 'MySQL', listenAddress: ':3306' });
await insertL4Host({ name: 'Redis', listenAddress: ':6379' });
const rows = await db.select().from(l4ProxyHosts);
expect(rows.length).toBe(3);
});
it('enabled field defaults to true', async () => {
const host = await insertL4Host();
expect(host.enabled).toBe(true);
});
it('can set enabled to false', async () => {
const host = await insertL4Host({ enabled: false });
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
expect(Boolean(row!.enabled)).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Protocol field
// ---------------------------------------------------------------------------
describe('l4-proxy-hosts protocol', () => {
it('stores TCP protocol', async () => {
const host = await insertL4Host({ protocol: 'tcp' });
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
expect(row!.protocol).toBe('tcp');
});
it('stores UDP protocol', async () => {
const host = await insertL4Host({ protocol: 'udp' });
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
expect(row!.protocol).toBe('udp');
});
});
// ---------------------------------------------------------------------------
// JSON fields (upstreams, matcher_value)
// ---------------------------------------------------------------------------
describe('l4-proxy-hosts JSON fields', () => {
it('stores and retrieves upstreams array', async () => {
const upstreams = ['10.0.0.1:5432', '10.0.0.2:5432', '10.0.0.3:5432'];
const host = await insertL4Host({ upstreams: JSON.stringify(upstreams) });
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
expect(JSON.parse(row!.upstreams)).toEqual(upstreams);
});
it('stores and retrieves matcher_value for TLS SNI', async () => {
const matcherValue = ['db.example.com', 'db2.example.com'];
const host = await insertL4Host({
matcherType: 'tls_sni',
matcherValue: JSON.stringify(matcherValue),
});
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
expect(row!.matcherType).toBe('tls_sni');
expect(JSON.parse(row!.matcherValue!)).toEqual(matcherValue);
});
it('stores and retrieves matcher_value for HTTP host', async () => {
const matcherValue = ['api.example.com'];
const host = await insertL4Host({
matcherType: 'http_host',
matcherValue: JSON.stringify(matcherValue),
});
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
expect(row!.matcherType).toBe('http_host');
expect(JSON.parse(row!.matcherValue!)).toEqual(matcherValue);
});
it('matcher_value is null for none matcher', async () => {
const host = await insertL4Host({ matcherType: 'none', matcherValue: null });
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
expect(row!.matcherType).toBe('none');
expect(row!.matcherValue).toBeNull();
});
it('matcher_value is null for proxy_protocol matcher', async () => {
const host = await insertL4Host({ matcherType: 'proxy_protocol', matcherValue: null });
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
expect(row!.matcherType).toBe('proxy_protocol');
expect(row!.matcherValue).toBeNull();
});
});
// ---------------------------------------------------------------------------
// Boolean fields
// ---------------------------------------------------------------------------
describe('l4-proxy-hosts boolean fields', () => {
it('tls_termination defaults to false', async () => {
const host = await insertL4Host();
expect(Boolean(host.tlsTermination)).toBe(false);
});
it('tls_termination can be set to true', async () => {
const host = await insertL4Host({ tlsTermination: true });
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
expect(Boolean(row!.tlsTermination)).toBe(true);
});
it('proxy_protocol_receive defaults to false', async () => {
const host = await insertL4Host();
expect(Boolean(host.proxyProtocolReceive)).toBe(false);
});
it('proxy_protocol_receive can be set to true', async () => {
const host = await insertL4Host({ proxyProtocolReceive: true });
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
expect(Boolean(row!.proxyProtocolReceive)).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Proxy protocol version
// ---------------------------------------------------------------------------
describe('l4-proxy-hosts proxy protocol version', () => {
it('proxy_protocol_version defaults to null', async () => {
const host = await insertL4Host();
expect(host.proxyProtocolVersion).toBeNull();
});
it('stores v1 proxy protocol version', async () => {
const host = await insertL4Host({ proxyProtocolVersion: 'v1' });
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
expect(row!.proxyProtocolVersion).toBe('v1');
});
it('stores v2 proxy protocol version', async () => {
const host = await insertL4Host({ proxyProtocolVersion: 'v2' });
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
expect(row!.proxyProtocolVersion).toBe('v2');
});
});
// ---------------------------------------------------------------------------
// Meta field
// ---------------------------------------------------------------------------
describe('l4-proxy-hosts meta', () => {
it('meta can be null', async () => {
const host = await insertL4Host({ meta: null });
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
expect(row!.meta).toBeNull();
});
it('stores and retrieves load balancer config via meta', async () => {
const meta = {
load_balancer: {
enabled: true,
policy: 'round_robin',
try_duration: '5s',
try_interval: '250ms',
retries: 3,
active_health_check: { enabled: true, port: 8081, interval: '10s', timeout: '5s' },
passive_health_check: { enabled: true, fail_duration: '30s', max_fails: 5 },
},
};
const host = await insertL4Host({ meta: JSON.stringify(meta) });
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
const parsed = JSON.parse(row!.meta!);
expect(parsed.load_balancer.enabled).toBe(true);
expect(parsed.load_balancer.policy).toBe('round_robin');
expect(parsed.load_balancer.active_health_check.port).toBe(8081);
expect(parsed.load_balancer.passive_health_check.max_fails).toBe(5);
});
it('stores and retrieves DNS resolver config via meta', async () => {
const meta = {
dns_resolver: {
enabled: true,
resolvers: ['1.1.1.1', '8.8.8.8'],
fallbacks: ['8.8.4.4'],
timeout: '5s',
},
};
const host = await insertL4Host({ meta: JSON.stringify(meta) });
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
const parsed = JSON.parse(row!.meta!);
expect(parsed.dns_resolver.enabled).toBe(true);
expect(parsed.dns_resolver.resolvers).toEqual(['1.1.1.1', '8.8.8.8']);
expect(parsed.dns_resolver.timeout).toBe('5s');
});
it('stores and retrieves upstream DNS resolution config via meta', async () => {
const meta = {
upstream_dns_resolution: { enabled: true, family: 'ipv4' },
};
const host = await insertL4Host({ meta: JSON.stringify(meta) });
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
const parsed = JSON.parse(row!.meta!);
expect(parsed.upstream_dns_resolution.enabled).toBe(true);
expect(parsed.upstream_dns_resolution.family).toBe('ipv4');
});
it('stores all three meta features together', async () => {
const meta = {
load_balancer: { enabled: true, policy: 'ip_hash' },
dns_resolver: { enabled: true, resolvers: ['1.1.1.1'] },
upstream_dns_resolution: { enabled: true, family: 'both' },
};
const host = await insertL4Host({ meta: JSON.stringify(meta) });
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
const parsed = JSON.parse(row!.meta!);
expect(parsed.load_balancer.policy).toBe('ip_hash');
expect(parsed.dns_resolver.resolvers).toEqual(['1.1.1.1']);
expect(parsed.upstream_dns_resolution.family).toBe('both');
});
it('stores and retrieves geo blocking config via meta', async () => {
const meta = {
geoblock: {
enabled: true,
block_countries: ['CN', 'RU', 'KP'],
block_continents: ['AF'],
block_asns: [12345],
block_cidrs: ['192.0.2.0/24'],
block_ips: ['203.0.113.1'],
allow_countries: ['US'],
allow_continents: [],
allow_asns: [],
allow_cidrs: ['10.0.0.0/8'],
allow_ips: [],
},
geoblock_mode: 'override',
};
const host = await insertL4Host({ meta: JSON.stringify(meta) });
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
const parsed = JSON.parse(row!.meta!);
expect(parsed.geoblock.enabled).toBe(true);
expect(parsed.geoblock.block_countries).toEqual(['CN', 'RU', 'KP']);
expect(parsed.geoblock.allow_cidrs).toEqual(['10.0.0.0/8']);
expect(parsed.geoblock_mode).toBe('override');
});
it('stores all four meta features together', async () => {
const meta = {
load_balancer: { enabled: true, policy: 'round_robin' },
dns_resolver: { enabled: true, resolvers: ['1.1.1.1'] },
upstream_dns_resolution: { enabled: true, family: 'ipv4' },
geoblock: { enabled: true, block_countries: ['CN'], block_continents: [], block_asns: [], block_cidrs: [], block_ips: [], allow_countries: [], allow_continents: [], allow_asns: [], allow_cidrs: [], allow_ips: [] },
};
const host = await insertL4Host({ meta: JSON.stringify(meta) });
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
const parsed = JSON.parse(row!.meta!);
expect(parsed.load_balancer.policy).toBe('round_robin');
expect(parsed.geoblock.block_countries).toEqual(['CN']);
});
});
// ---------------------------------------------------------------------------
// Update
// ---------------------------------------------------------------------------
describe('l4-proxy-hosts update', () => {
it('updates listen address', async () => {
const host = await insertL4Host({ listenAddress: ':5432' });
await db.update(l4ProxyHosts).set({ listenAddress: ':3306' }).where(eq(l4ProxyHosts.id, host.id));
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
expect(row!.listenAddress).toBe(':3306');
});
it('updates protocol from tcp to udp', async () => {
const host = await insertL4Host({ protocol: 'tcp' });
await db.update(l4ProxyHosts).set({ protocol: 'udp' }).where(eq(l4ProxyHosts.id, host.id));
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
expect(row!.protocol).toBe('udp');
});
it('toggles enabled state', async () => {
const host = await insertL4Host({ enabled: true });
await db.update(l4ProxyHosts).set({ enabled: false }).where(eq(l4ProxyHosts.id, host.id));
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
expect(Boolean(row!.enabled)).toBe(false);
});
});

View File

@@ -0,0 +1,177 @@
/**
* Unit tests for the L4 port manager sidecar entrypoint script.
*
* Tests critical invariants of the shell script:
* - Always applies the override on startup (not just on trigger change)
* - Only recreates the caddy service (never other services)
* - Uses --no-deps to prevent dependency cascades
* - Auto-detects compose project name from caddy container labels
* - Pre-loads LAST_TRIGGER to avoid double-applying on startup
* - Writes status files in valid JSON
* - Never includes test override files in production
* - Supports both named-volume and bind-mount deployments (COMPOSE_HOST_DIR)
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const SCRIPT_PATH = resolve(__dirname, '../../docker/l4-port-manager/entrypoint.sh');
const script = readFileSync(SCRIPT_PATH, 'utf-8');
const lines = script.split('\n');
describe('L4 port manager entrypoint.sh', () => {
it('applies override on startup (not only on trigger change)', () => {
// The script must call do_apply before entering the while loop.
// This ensures L4 ports are bound after any restart, because the main
// compose stack starts caddy without the L4 ports override file.
const firstApply = lines.findIndex(l => l.trim().startsWith('do_apply') || l.includes('do_apply'));
const whileLoop = lines.findIndex(l => l.includes('while true'));
expect(firstApply).toBeGreaterThan(-1);
expect(whileLoop).toBeGreaterThan(-1);
expect(firstApply).toBeLessThan(whileLoop);
});
it('pre-loads LAST_TRIGGER after startup apply to avoid double-apply', () => {
// After the startup apply, LAST_TRIGGER must be set from the current trigger
// file content so the poll loop doesn't re-apply the same trigger again.
const lastTriggerInit = lines.findIndex(l => l.includes('LAST_TRIGGER=') && l.includes('TRIGGER_FILE'));
const whileLoop = lines.findIndex(l => l.includes('while true'));
expect(lastTriggerInit).toBeGreaterThan(-1);
expect(lastTriggerInit).toBeLessThan(whileLoop);
});
it('only recreates the caddy service', () => {
// The docker compose command should target only "caddy" — never "web" or other services
const composeUpLines = lines.filter(line =>
line.includes('docker compose') && line.includes('up')
);
expect(composeUpLines.length).toBeGreaterThan(0);
for (const line of composeUpLines) {
expect(line).toContain('caddy');
expect(line).not.toMatch(/\bweb\b/);
}
});
it('uses --no-deps flag to prevent dependency cascades', () => {
const composeUpLines = lines.filter(line =>
line.includes('docker compose') && line.includes('up')
);
for (const line of composeUpLines) {
expect(line).toContain('--no-deps');
}
});
it('uses --force-recreate to ensure port changes take effect', () => {
const composeUpLines = lines.filter(line =>
line.includes('docker compose') && line.includes('up')
);
for (const line of composeUpLines) {
expect(line).toContain('--force-recreate');
}
});
it('specifies project name to target the correct compose stack', () => {
// Without -p, compose would infer the project from the mount directory name
// ("/compose") rather than the actual running stack name, causing it to
// create new containers instead of recreating the existing ones.
expect(script).toMatch(/COMPOSE_ARGS=.*-p \$COMPOSE_PROJECT/);
});
it('auto-detects project name from caddy container labels', () => {
expect(script).toContain('com.docker.compose.project');
expect(script).toContain('docker inspect');
expect(script).toContain('detect_project_name');
});
it('compares trigger content to avoid redundant restarts', () => {
expect(script).toContain('LAST_TRIGGER');
expect(script).toContain('CURRENT_TRIGGER');
expect(script).toContain('"$CURRENT_TRIGGER" = "$LAST_TRIGGER"');
});
it('does not pull images (only recreates)', () => {
const composeUpLines = lines.filter(line =>
line.includes('docker compose') && line.includes('up')
);
for (const line of composeUpLines) {
expect(line).not.toContain('--pull');
expect(line).not.toContain('--build');
}
});
it('waits for caddy health check after recreation', () => {
expect(script).toContain('Health');
expect(script).toContain('healthy');
expect(script).toContain('HEALTH_TIMEOUT');
});
it('writes status for both success and failure cases', () => {
const statusWrites = lines.filter(l => l.trim().startsWith('write_status'));
// At least: startup idle/applying, applying, applied/success, failed
expect(statusWrites.length).toBeGreaterThanOrEqual(4);
});
it('does not include test override files in production', () => {
// Including docker-compose.test.yml would override web env vars (triggering
// web restart) and switch to test volume names.
expect(script).not.toContain('docker-compose.test.yml');
});
it('does not restart the web service or itself', () => {
const dangerousPatterns = [
/up.*\bweb\b/,
/restart.*\bweb\b/,
/up.*\bl4-port-manager\b/,
/restart.*\bl4-port-manager\b/,
];
for (const pattern of dangerousPatterns) {
expect(script).not.toMatch(pattern);
}
});
// ---------------------------------------------------------------------------
// Deployment scenario: COMPOSE_HOST_DIR (bind-mount / cloud override)
// ---------------------------------------------------------------------------
it('uses --project-directory $COMPOSE_HOST_DIR when COMPOSE_HOST_DIR is set', () => {
// Bind-mount deployments (docker-compose.override.yml replaces named volumes
// with ./data bind mounts). Relative paths like ./geoip-data in the override
// file must resolve against the HOST project directory, not the sidecar's
// /compose mount. --project-directory tells the Docker daemon where to look.
expect(script).toContain('--project-directory $COMPOSE_HOST_DIR');
// It must be conditional — only applied when COMPOSE_HOST_DIR is non-empty
expect(script).toMatch(/if \[ -n "\$COMPOSE_HOST_DIR" \]/);
});
it('does NOT unconditionally add --project-directory (named-volume deployments work without it)', () => {
// Standard deployments (no override file) use named volumes — no host path
// is needed. --project-directory must NOT be hardcoded outside the conditional.
const unconditional = lines.filter(l =>
l.includes('--project-directory') && !l.includes('COMPOSE_HOST_DIR') && !l.trim().startsWith('#')
);
expect(unconditional).toHaveLength(0);
});
it('uses --env-file from $COMPOSE_DIR (container-accessible path), not $COMPOSE_HOST_DIR', () => {
// When --project-directory points to the host path, Docker Compose looks for
// .env at $COMPOSE_HOST_DIR/.env which is NOT mounted inside the container.
// We must explicitly pass --env-file $COMPOSE_DIR/.env (the container mount).
expect(script).toContain('--env-file $COMPOSE_DIR/.env');
// Must NOT reference the host dir for the env file
expect(script).not.toContain('--env-file $COMPOSE_HOST_DIR');
});
it('always reads compose files from $COMPOSE_DIR regardless of COMPOSE_HOST_DIR', () => {
// The sidecar mounts the project at /compose (COMPOSE_DIR). Whether or not
// COMPOSE_HOST_DIR is set, all -f flags must reference container-accessible
// paths under $COMPOSE_DIR, never the host path.
const composeFileFlags = lines.filter(l =>
l.includes('-f ') && l.includes('docker-compose')
);
expect(composeFileFlags.length).toBeGreaterThan(0);
for (const line of composeFileFlags) {
expect(line).toContain('$COMPOSE_DIR');
expect(line).not.toContain('$COMPOSE_HOST_DIR');
}
});
});

View File

@@ -0,0 +1,300 @@
/**
* Unit tests for L4 proxy host input validation.
*
* Tests the validation logic in the L4 proxy hosts model
* without requiring a database connection.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { TestDb } from '../helpers/db';
// Mock db so the model module can be imported
const ctx = vi.hoisted(() => ({ db: null as unknown as TestDb }));
vi.mock('../../src/lib/db', async () => {
const { createTestDb } = await import('../helpers/db');
const schemaModule = await import('../../src/lib/db/schema');
ctx.db = createTestDb();
return {
default: ctx.db,
schema: schemaModule,
nowIso: () => new Date().toISOString(),
toIso: (value: string | Date | null | undefined): string | null => {
if (!value) return null;
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
},
};
});
vi.mock('../../src/lib/caddy', () => ({
applyCaddyConfig: vi.fn().mockResolvedValue({ ok: true }),
}));
vi.mock('../../src/lib/audit', () => ({
logAuditEvent: vi.fn(),
}));
import {
createL4ProxyHost,
updateL4ProxyHost,
type L4ProxyHostInput,
} from '../../src/lib/models/l4-proxy-hosts';
import * as schema from '../../src/lib/db/schema';
// ---------------------------------------------------------------------------
// Setup: insert a test user so the FK constraint on ownerUserId is satisfied
// ---------------------------------------------------------------------------
beforeEach(async () => {
await ctx.db.delete(schema.l4ProxyHosts);
await ctx.db.delete(schema.users).catch(() => {});
await ctx.db.insert(schema.users).values({
id: 1,
email: 'test@example.com',
name: 'Test User',
role: 'admin',
provider: 'credentials',
subject: 'test',
status: 'active',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
});
// ---------------------------------------------------------------------------
// Validation tests via createL4ProxyHost (which calls validateL4Input)
// ---------------------------------------------------------------------------
describe('L4 proxy host create validation', () => {
it('rejects empty name', async () => {
const input: L4ProxyHostInput = {
name: '',
protocol: 'tcp',
listen_address: ':5432',
upstreams: ['10.0.0.1:5432'],
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Name is required');
});
it('rejects invalid protocol', async () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'sctp' as any,
listen_address: ':5432',
upstreams: ['10.0.0.1:5432'],
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow("Protocol must be 'tcp' or 'udp'");
});
it('rejects empty listen address', async () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'tcp',
listen_address: '',
upstreams: ['10.0.0.1:5432'],
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Listen address is required');
});
it('rejects listen address without port', async () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'tcp',
listen_address: '10.0.0.1',
upstreams: ['10.0.0.1:5432'],
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow("Listen address must be in format ':PORT' or 'HOST:PORT'");
});
it('rejects listen address with port 0', async () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'tcp',
listen_address: ':0',
upstreams: ['10.0.0.1:5432'],
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Port must be between 1 and 65535');
});
it('rejects listen address with port > 65535', async () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'tcp',
listen_address: ':99999',
upstreams: ['10.0.0.1:5432'],
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Port must be between 1 and 65535');
});
it('rejects empty upstreams', async () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'tcp',
listen_address: ':5432',
upstreams: [],
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('At least one upstream must be specified');
});
it('rejects upstream without port', async () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'tcp',
listen_address: ':5432',
upstreams: ['10.0.0.1'],
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow("must be in 'host:port' format");
});
it('rejects TLS termination with UDP', async () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'udp',
listen_address: ':5353',
upstreams: ['8.8.8.8:53'],
tls_termination: true,
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('TLS termination is only supported with TCP');
});
it('rejects TLS SNI matcher without values', async () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'tcp',
listen_address: ':5432',
upstreams: ['10.0.0.1:5432'],
matcher_type: 'tls_sni',
matcher_value: [],
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Matcher value is required');
});
it('rejects HTTP host matcher without values', async () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'tcp',
listen_address: ':8080',
upstreams: ['10.0.0.1:8080'],
matcher_type: 'http_host',
matcher_value: [],
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Matcher value is required');
});
it('rejects invalid proxy protocol version', async () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'tcp',
listen_address: ':5432',
upstreams: ['10.0.0.1:5432'],
proxy_protocol_version: 'v3' as any,
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow("Proxy protocol version must be 'v1' or 'v2'");
});
it('rejects invalid matcher type', async () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'tcp',
listen_address: ':5432',
upstreams: ['10.0.0.1:5432'],
matcher_type: 'invalid' as any,
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Matcher type must be one of');
});
it('accepts valid TCP proxy with all options', async () => {
const input: L4ProxyHostInput = {
name: 'Full Featured',
protocol: 'tcp',
listen_address: ':993',
upstreams: ['localhost:143'],
matcher_type: 'tls_sni',
matcher_value: ['mail.example.com'],
tls_termination: true,
proxy_protocol_version: 'v1',
proxy_protocol_receive: true,
enabled: true,
};
const result = await createL4ProxyHost(input, 1);
expect(result).toBeDefined();
expect(result.name).toBe('Full Featured');
expect(result.protocol).toBe('tcp');
expect(result.listen_address).toBe(':993');
expect(result.upstreams).toEqual(['localhost:143']);
expect(result.matcher_type).toBe('tls_sni');
expect(result.matcher_value).toEqual(['mail.example.com']);
expect(result.tls_termination).toBe(true);
expect(result.proxy_protocol_version).toBe('v1');
expect(result.proxy_protocol_receive).toBe(true);
});
it('accepts valid UDP proxy', async () => {
const input: L4ProxyHostInput = {
name: 'DNS',
protocol: 'udp',
listen_address: ':5353',
upstreams: ['8.8.8.8:53'],
};
const result = await createL4ProxyHost(input, 1);
expect(result).toBeDefined();
expect(result.protocol).toBe('udp');
});
it('accepts host:port format for listen address', async () => {
const input: L4ProxyHostInput = {
name: 'Bound',
protocol: 'tcp',
listen_address: '0.0.0.0:5432',
upstreams: ['10.0.0.1:5432'],
};
const result = await createL4ProxyHost(input, 1);
expect(result.listen_address).toBe('0.0.0.0:5432');
});
it('accepts none matcher without matcher_value', async () => {
const input: L4ProxyHostInput = {
name: 'Catch All',
protocol: 'tcp',
listen_address: ':5432',
upstreams: ['10.0.0.1:5432'],
matcher_type: 'none',
};
const result = await createL4ProxyHost(input, 1);
expect(result.matcher_type).toBe('none');
});
it('accepts proxy_protocol matcher without matcher_value', async () => {
const input: L4ProxyHostInput = {
name: 'PP Detect',
protocol: 'tcp',
listen_address: ':8443',
upstreams: ['10.0.0.1:443'],
matcher_type: 'proxy_protocol',
};
const result = await createL4ProxyHost(input, 1);
expect(result.matcher_type).toBe('proxy_protocol');
});
it('trims whitespace from name and listen_address', async () => {
const input: L4ProxyHostInput = {
name: ' Spacey Name ',
protocol: 'tcp',
listen_address: ' :5432 ',
upstreams: ['10.0.0.1:5432'],
};
const result = await createL4ProxyHost(input, 1);
expect(result.name).toBe('Spacey Name');
expect(result.listen_address).toBe(':5432');
});
it('deduplicates upstreams', async () => {
const input: L4ProxyHostInput = {
name: 'Dedup',
protocol: 'tcp',
listen_address: ':5432',
upstreams: ['10.0.0.1:5432', '10.0.0.1:5432', '10.0.0.2:5432'],
};
const result = await createL4ProxyHost(input, 1);
expect(result.upstreams).toEqual(['10.0.0.1:5432', '10.0.0.2:5432']);
});
});