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:
@@ -10,6 +10,11 @@ services:
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
# L4 test ports (TCP)
|
||||
- "15432:15432"
|
||||
- "15433:15433"
|
||||
# L4 test ports (UDP)
|
||||
- "15353:15353/udp"
|
||||
# Lightweight echo server reachable by Caddy as "echo-server:8080".
|
||||
# Returns a fixed body so tests can assert the proxy routed the request.
|
||||
echo-server:
|
||||
@@ -30,6 +35,23 @@ services:
|
||||
image: traefik/whoami
|
||||
networks:
|
||||
- caddy-network
|
||||
# TCP echo server for L4 proxy tests.
|
||||
# Listens on port 9000 and echoes back anything sent to it with a prefix.
|
||||
tcp-echo:
|
||||
image: cjimti/go-echo
|
||||
environment:
|
||||
TCP_PORT: "9000"
|
||||
NODE_NAME: "tcp-echo-ok"
|
||||
networks:
|
||||
- caddy-network
|
||||
# UDP echo server for L4 proxy tests.
|
||||
# Simple socat-based UDP echo: reflects any datagram back to sender.
|
||||
udp-echo:
|
||||
image: alpine/socat
|
||||
command: ["UDP4-RECVFROM:9001,fork", "EXEC:cat"]
|
||||
networks:
|
||||
- caddy-network
|
||||
|
||||
volumes:
|
||||
caddy-manager-data:
|
||||
name: caddy-manager-data-test
|
||||
|
||||
134
tests/e2e/functional/l4-proxy-routing.spec.ts
Normal file
134
tests/e2e/functional/l4-proxy-routing.spec.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Functional tests: L4 (TCP/UDP) proxy routing.
|
||||
*
|
||||
* Creates real L4 proxy hosts pointing at echo containers,
|
||||
* then sends raw TCP connections and UDP datagrams through Caddy
|
||||
* and asserts the traffic reaches the upstream.
|
||||
*
|
||||
* Test ports exposed on the Caddy container:
|
||||
* TCP: 15432, 15433
|
||||
* UDP: 15353
|
||||
*
|
||||
* Upstream services:
|
||||
* tcp-echo (cjimti/go-echo on port 9000) — echoes TCP data
|
||||
* udp-echo (alpine/socat on port 9001) — echoes UDP datagrams
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { createL4ProxyHost } from '../../helpers/l4-proxy-api';
|
||||
import { tcpSend, waitForTcpRoute, tcpConnect, udpSend, waitForUdpRoute } from '../../helpers/tcp';
|
||||
|
||||
const TCP_PORT = 15432;
|
||||
const TCP_PORT_2 = 15433;
|
||||
const UDP_PORT = 15353;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TCP routing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe.serial('L4 TCP Proxy Routing', () => {
|
||||
test('setup: create TCP proxy host pointing at tcp-echo', async ({ page }) => {
|
||||
await createL4ProxyHost(page, {
|
||||
name: 'L4 TCP Echo Test',
|
||||
protocol: 'tcp',
|
||||
listenAddress: `:${TCP_PORT}`,
|
||||
upstream: 'tcp-echo:9000',
|
||||
});
|
||||
await waitForTcpRoute('127.0.0.1', TCP_PORT);
|
||||
});
|
||||
|
||||
test('routes TCP traffic to the upstream echo server', async () => {
|
||||
const res = await tcpSend('127.0.0.1', TCP_PORT, 'hello from test\n');
|
||||
expect(res.connected).toBe(true);
|
||||
expect(res.data).toContain('hello from test');
|
||||
});
|
||||
|
||||
test('TCP connection is accepted on the L4 port', async () => {
|
||||
const connected = await tcpConnect('127.0.0.1', TCP_PORT);
|
||||
expect(connected).toBe(true);
|
||||
});
|
||||
|
||||
test('unused TCP port does not accept connections', async () => {
|
||||
const connected = await tcpConnect('127.0.0.1', TCP_PORT_2, 2000);
|
||||
expect(connected).toBe(false);
|
||||
});
|
||||
|
||||
test('disabled TCP proxy host stops accepting connections', async ({ page }) => {
|
||||
await page.goto('/l4-proxy-hosts');
|
||||
const row = page.locator('tr', { hasText: 'L4 TCP Echo Test' });
|
||||
await row.locator('input[type="checkbox"]').first().click({ force: true });
|
||||
await page.waitForTimeout(3_000);
|
||||
|
||||
const connected = await tcpConnect('127.0.0.1', TCP_PORT, 2000);
|
||||
expect(connected).toBe(false);
|
||||
|
||||
// Re-enable
|
||||
await row.locator('input[type="checkbox"]').first().click({ force: true });
|
||||
await page.waitForTimeout(2_000);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe.serial('L4 Multiple TCP Hosts', () => {
|
||||
test('setup: create second TCP proxy host on different port', async ({ page }) => {
|
||||
await createL4ProxyHost(page, {
|
||||
name: 'L4 TCP Echo Test 2',
|
||||
protocol: 'tcp',
|
||||
listenAddress: `:${TCP_PORT_2}`,
|
||||
upstream: 'tcp-echo:9000',
|
||||
});
|
||||
await waitForTcpRoute('127.0.0.1', TCP_PORT_2);
|
||||
});
|
||||
|
||||
test('both TCP ports route traffic independently', async () => {
|
||||
const res1 = await tcpSend('127.0.0.1', TCP_PORT, 'port1\n');
|
||||
const res2 = await tcpSend('127.0.0.1', TCP_PORT_2, 'port2\n');
|
||||
expect(res1.connected).toBe(true);
|
||||
expect(res2.connected).toBe(true);
|
||||
expect(res1.data).toContain('port1');
|
||||
expect(res2.data).toContain('port2');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UDP routing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe.serial('L4 UDP Proxy Routing', () => {
|
||||
test('setup: create UDP proxy host pointing at udp-echo', async ({ page }) => {
|
||||
await createL4ProxyHost(page, {
|
||||
name: 'L4 UDP Echo Test',
|
||||
protocol: 'udp',
|
||||
listenAddress: `:${UDP_PORT}`,
|
||||
upstream: 'udp-echo:9001',
|
||||
});
|
||||
await waitForUdpRoute('127.0.0.1', UDP_PORT);
|
||||
});
|
||||
|
||||
test('routes UDP datagrams to the upstream echo server', async () => {
|
||||
const res = await udpSend('127.0.0.1', UDP_PORT, 'hello udp');
|
||||
expect(res.received).toBe(true);
|
||||
expect(res.data).toContain('hello udp');
|
||||
});
|
||||
|
||||
test('sends multiple UDP datagrams independently', async () => {
|
||||
const res1 = await udpSend('127.0.0.1', UDP_PORT, 'datagram-1');
|
||||
const res2 = await udpSend('127.0.0.1', UDP_PORT, 'datagram-2');
|
||||
expect(res1.received).toBe(true);
|
||||
expect(res2.received).toBe(true);
|
||||
expect(res1.data).toContain('datagram-1');
|
||||
expect(res2.data).toContain('datagram-2');
|
||||
});
|
||||
|
||||
test('disabled UDP proxy host stops responding', async ({ page }) => {
|
||||
await page.goto('/l4-proxy-hosts');
|
||||
const row = page.locator('tr', { hasText: 'L4 UDP Echo Test' });
|
||||
await row.locator('input[type="checkbox"]').first().click({ force: true });
|
||||
await page.waitForTimeout(3_000);
|
||||
|
||||
const res = await udpSend('127.0.0.1', UDP_PORT, 'should-fail', 2000);
|
||||
expect(res.received).toBe(false);
|
||||
|
||||
// Re-enable
|
||||
await row.locator('input[type="checkbox"]').first().click({ force: true });
|
||||
await page.waitForTimeout(2_000);
|
||||
});
|
||||
});
|
||||
68
tests/e2e/l4-proxy-hosts.spec.ts
Normal file
68
tests/e2e/l4-proxy-hosts.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* E2E tests: L4 Proxy Hosts page.
|
||||
*
|
||||
* Verifies the L4 Proxy Hosts UI — navigation, list, create/edit/delete dialogs.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('L4 Proxy Hosts page', () => {
|
||||
test('is accessible from sidebar navigation', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('link', { name: /l4 proxy hosts/i }).click();
|
||||
await expect(page).toHaveURL(/\/l4-proxy-hosts/);
|
||||
await expect(page.getByText('L4 Proxy Hosts')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows empty state when no L4 hosts exist', async ({ page }) => {
|
||||
await page.goto('/l4-proxy-hosts');
|
||||
await expect(page.getByText(/no l4 proxy hosts found/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('create dialog opens and contains expected fields', async ({ page }) => {
|
||||
await page.goto('/l4-proxy-hosts');
|
||||
await page.getByRole('button', { name: /create l4 host/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Verify key form fields exist
|
||||
await expect(page.getByLabel('Name')).toBeVisible();
|
||||
await expect(page.getByLabel('Protocol')).toBeVisible();
|
||||
await expect(page.getByLabel('Listen Address')).toBeVisible();
|
||||
await expect(page.getByLabel('Upstreams')).toBeVisible();
|
||||
await expect(page.getByLabel('Matcher')).toBeVisible();
|
||||
});
|
||||
|
||||
test('creates a new L4 proxy host', async ({ page }) => {
|
||||
await page.goto('/l4-proxy-hosts');
|
||||
await page.getByRole('button', { name: /create l4 host/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await page.getByLabel('Name').fill('E2E Test Host');
|
||||
await page.getByLabel('Listen Address').fill(':19999');
|
||||
await page.getByLabel('Upstreams').fill('10.0.0.1:5432');
|
||||
|
||||
await page.getByRole('button', { name: /create/i }).click();
|
||||
|
||||
// Dialog should close and host should appear in table
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText('E2E Test Host')).toBeVisible();
|
||||
await expect(page.getByText(':19999')).toBeVisible();
|
||||
});
|
||||
|
||||
test('deletes the created L4 proxy host', async ({ page }) => {
|
||||
await page.goto('/l4-proxy-hosts');
|
||||
await expect(page.getByText('E2E Test Host')).toBeVisible();
|
||||
|
||||
// Click delete button in the row
|
||||
const row = page.locator('tr', { hasText: 'E2E Test Host' });
|
||||
await row.getByRole('button', { name: /delete/i }).click();
|
||||
|
||||
// Confirm deletion
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByText(/are you sure/i)).toBeVisible();
|
||||
await page.getByRole('button', { name: /delete/i }).click();
|
||||
|
||||
// Host should be removed
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText('E2E Test Host')).not.toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
80
tests/helpers/l4-proxy-api.ts
Normal file
80
tests/helpers/l4-proxy-api.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Higher-level helpers for creating L4 proxy hosts in E2E tests.
|
||||
*
|
||||
* All helpers accept a Playwright `Page` (pre-authenticated via the
|
||||
* global storageState) so they integrate cleanly with the standard
|
||||
* `page` test fixture.
|
||||
*/
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
export interface L4ProxyHostConfig {
|
||||
name: string;
|
||||
protocol?: 'tcp' | 'udp';
|
||||
listenAddress: string;
|
||||
upstream: string; // e.g. "tcp-echo:9000"
|
||||
matcherType?: 'none' | 'tls_sni' | 'http_host' | 'proxy_protocol';
|
||||
matcherValue?: string; // comma-separated
|
||||
tlsTermination?: boolean;
|
||||
proxyProtocolReceive?: boolean;
|
||||
proxyProtocolVersion?: 'v1' | 'v2';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an L4 proxy host via the browser UI.
|
||||
*/
|
||||
export async function createL4ProxyHost(page: Page, config: L4ProxyHostConfig): Promise<void> {
|
||||
await page.goto('/l4-proxy-hosts');
|
||||
await page.getByRole('button', { name: /create l4 host/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await page.getByLabel('Name').fill(config.name);
|
||||
|
||||
// Protocol select
|
||||
if (config.protocol && config.protocol !== 'tcp') {
|
||||
await page.getByLabel('Protocol').click();
|
||||
await page.getByRole('option', { name: new RegExp(config.protocol, 'i') }).click();
|
||||
}
|
||||
|
||||
await page.getByLabel('Listen Address').fill(config.listenAddress);
|
||||
await page.getByLabel('Upstreams').fill(config.upstream);
|
||||
|
||||
// Matcher type
|
||||
if (config.matcherType && config.matcherType !== 'none') {
|
||||
await page.getByLabel('Matcher').click();
|
||||
const matcherLabels: Record<string, RegExp> = {
|
||||
tls_sni: /tls sni/i,
|
||||
http_host: /http host/i,
|
||||
proxy_protocol: /proxy protocol/i,
|
||||
};
|
||||
await page.getByRole('option', { name: matcherLabels[config.matcherType] }).click();
|
||||
|
||||
if (config.matcherValue && (config.matcherType === 'tls_sni' || config.matcherType === 'http_host')) {
|
||||
await page.getByLabel(/hostnames/i).fill(config.matcherValue);
|
||||
}
|
||||
}
|
||||
|
||||
// TLS termination
|
||||
if (config.tlsTermination) {
|
||||
await page.getByLabel(/tls termination/i).check();
|
||||
}
|
||||
|
||||
// Proxy protocol receive
|
||||
if (config.proxyProtocolReceive) {
|
||||
await page.getByLabel(/accept inbound proxy/i).check();
|
||||
}
|
||||
|
||||
// Proxy protocol version
|
||||
if (config.proxyProtocolVersion) {
|
||||
await page.getByLabel(/send proxy protocol/i).click();
|
||||
await page.getByRole('option', { name: config.proxyProtocolVersion }).click();
|
||||
}
|
||||
|
||||
// Submit
|
||||
await page.getByRole('button', { name: /create/i }).click();
|
||||
|
||||
// Wait for success state (dialog closes or success alert)
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Verify host appears in the table
|
||||
await expect(page.getByText(config.name)).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
161
tests/helpers/tcp.ts
Normal file
161
tests/helpers/tcp.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Low-level TCP and UDP helpers for L4 proxy functional tests.
|
||||
*
|
||||
* Sends raw TCP connections and UDP datagrams to Caddy's L4 proxy ports
|
||||
* and reads responses.
|
||||
*/
|
||||
import net from 'node:net';
|
||||
import dgram from 'node:dgram';
|
||||
|
||||
export interface TcpResponse {
|
||||
data: string;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a TCP connection to the given host:port, send a payload,
|
||||
* and collect whatever comes back within the timeout window.
|
||||
*/
|
||||
export function tcpSend(
|
||||
host: string,
|
||||
port: number,
|
||||
payload: string,
|
||||
timeoutMs = 5_000
|
||||
): Promise<TcpResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
let connected = false;
|
||||
const socket = net.createConnection({ host, port }, () => {
|
||||
connected = true;
|
||||
socket.write(payload);
|
||||
});
|
||||
|
||||
socket.setTimeout(timeoutMs);
|
||||
|
||||
socket.on('data', (chunk) => {
|
||||
data += chunk.toString();
|
||||
});
|
||||
|
||||
socket.on('end', () => {
|
||||
resolve({ data, connected });
|
||||
});
|
||||
|
||||
socket.on('timeout', () => {
|
||||
socket.destroy();
|
||||
resolve({ data, connected });
|
||||
});
|
||||
|
||||
socket.on('error', (err) => {
|
||||
if (connected) {
|
||||
resolve({ data, connected });
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if a TCP port is accepting connections.
|
||||
*/
|
||||
export function tcpConnect(
|
||||
host: string,
|
||||
port: number,
|
||||
timeoutMs = 5_000
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.createConnection({ host, port }, () => {
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
socket.setTimeout(timeoutMs);
|
||||
socket.on('timeout', () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
socket.on('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll until a TCP port accepts connections.
|
||||
* Similar to waitForRoute() but for TCP.
|
||||
*/
|
||||
export async function waitForTcpRoute(
|
||||
host: string,
|
||||
port: number,
|
||||
timeoutMs = 15_000
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const ok = await tcpConnect(host, port, 2000);
|
||||
if (ok) return;
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`TCP port ${host}:${port} not ready after ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UDP helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UdpResponse {
|
||||
data: string;
|
||||
received: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a UDP datagram and wait for a response.
|
||||
*/
|
||||
export function udpSend(
|
||||
host: string,
|
||||
port: number,
|
||||
payload: string,
|
||||
timeoutMs = 5_000
|
||||
): Promise<UdpResponse> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = dgram.createSocket('udp4');
|
||||
let received = false;
|
||||
let data = '';
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
socket.close();
|
||||
resolve({ data, received });
|
||||
}, timeoutMs);
|
||||
|
||||
socket.on('message', (msg) => {
|
||||
received = true;
|
||||
data += msg.toString();
|
||||
clearTimeout(timer);
|
||||
socket.close();
|
||||
resolve({ data, received });
|
||||
});
|
||||
|
||||
socket.on('error', () => {
|
||||
clearTimeout(timer);
|
||||
socket.close();
|
||||
resolve({ data, received });
|
||||
});
|
||||
|
||||
socket.send(payload, port, host);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll until a UDP port responds to a test datagram.
|
||||
*/
|
||||
export async function waitForUdpRoute(
|
||||
host: string,
|
||||
port: number,
|
||||
timeoutMs = 15_000
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const res = await udpSend(host, port, 'ping', 2000);
|
||||
if (res.received) return;
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`UDP port ${host}:${port} not ready after ${timeoutMs}ms`);
|
||||
}
|
||||
572
tests/integration/l4-caddy-config.test.ts
Normal file
572
tests/integration/l4-caddy-config.test.ts
Normal file
@@ -0,0 +1,572 @@
|
||||
/**
|
||||
* Integration tests for L4 Caddy config generation.
|
||||
*
|
||||
* Verifies that the data stored in l4_proxy_hosts can be used to produce
|
||||
* correct caddy-l4 JSON config structures. Tests the config shape that
|
||||
* buildL4Servers() would produce by reconstructing it from DB rows.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb, type TestDb } from '../helpers/db';
|
||||
import { l4ProxyHosts } from '../../src/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
let db: TestDb;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDb();
|
||||
});
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
async function insertL4Host(overrides: Partial<typeof l4ProxyHosts.$inferInsert> = {}) {
|
||||
const now = nowIso();
|
||||
const [host] = await db.insert(l4ProxyHosts).values({
|
||||
name: 'Test L4 Host',
|
||||
protocol: 'tcp',
|
||||
listenAddress: ':5432',
|
||||
upstreams: JSON.stringify(['10.0.0.1:5432']),
|
||||
matcherType: 'none',
|
||||
matcherValue: null,
|
||||
tlsTermination: false,
|
||||
proxyProtocolVersion: null,
|
||||
proxyProtocolReceive: false,
|
||||
enabled: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...overrides,
|
||||
}).returning();
|
||||
return host;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct the caddy-l4 JSON config that buildL4Servers() would produce
|
||||
* from a set of L4 proxy host rows. This mirrors the logic in caddy.ts.
|
||||
*/
|
||||
function buildExpectedL4Config(rows: (typeof l4ProxyHosts.$inferSelect)[]) {
|
||||
const enabledRows = rows.filter(r => r.enabled);
|
||||
if (enabledRows.length === 0) return null;
|
||||
|
||||
const serverMap = new Map<string, typeof enabledRows>();
|
||||
for (const host of enabledRows) {
|
||||
const key = host.listenAddress;
|
||||
if (!serverMap.has(key)) serverMap.set(key, []);
|
||||
serverMap.get(key)!.push(host);
|
||||
}
|
||||
|
||||
const servers: Record<string, unknown> = {};
|
||||
let serverIdx = 0;
|
||||
for (const [listenAddr, hosts] of serverMap) {
|
||||
const routes = hosts.map(host => {
|
||||
const route: Record<string, unknown> = {};
|
||||
const matcherValues = host.matcherValue ? JSON.parse(host.matcherValue) as string[] : [];
|
||||
|
||||
if (host.matcherType === 'tls_sni' && matcherValues.length > 0) {
|
||||
route.match = [{ tls: { sni: matcherValues } }];
|
||||
} else if (host.matcherType === 'http_host' && matcherValues.length > 0) {
|
||||
route.match = [{ http: [{ host: matcherValues }] }];
|
||||
} else if (host.matcherType === 'proxy_protocol') {
|
||||
route.match = [{ proxy_protocol: {} }];
|
||||
}
|
||||
|
||||
const handlers: Record<string, unknown>[] = [];
|
||||
if (host.proxyProtocolReceive) handlers.push({ handler: 'proxy_protocol' });
|
||||
if (host.tlsTermination) handlers.push({ handler: 'tls' });
|
||||
|
||||
const upstreams = JSON.parse(host.upstreams) as string[];
|
||||
const proxyHandler: Record<string, unknown> = {
|
||||
handler: 'proxy',
|
||||
upstreams: upstreams.map(u => ({ dial: [u] })),
|
||||
};
|
||||
if (host.proxyProtocolVersion) proxyHandler.proxy_protocol = host.proxyProtocolVersion;
|
||||
|
||||
// Load balancer config from meta
|
||||
if (host.meta) {
|
||||
const meta = JSON.parse(host.meta);
|
||||
if (meta.load_balancer?.enabled) {
|
||||
const lb = meta.load_balancer;
|
||||
proxyHandler.load_balancing = {
|
||||
selection_policy: { policy: lb.policy ?? 'random' },
|
||||
...(lb.try_duration ? { try_duration: lb.try_duration } : {}),
|
||||
...(lb.try_interval ? { try_interval: lb.try_interval } : {}),
|
||||
...(lb.retries != null ? { retries: lb.retries } : {}),
|
||||
};
|
||||
const healthChecks: Record<string, unknown> = {};
|
||||
if (lb.active_health_check?.enabled) {
|
||||
const active: Record<string, unknown> = {};
|
||||
if (lb.active_health_check.port != null) active.port = lb.active_health_check.port;
|
||||
if (lb.active_health_check.interval) active.interval = lb.active_health_check.interval;
|
||||
if (lb.active_health_check.timeout) active.timeout = lb.active_health_check.timeout;
|
||||
if (Object.keys(active).length > 0) healthChecks.active = active;
|
||||
}
|
||||
if (lb.passive_health_check?.enabled) {
|
||||
const passive: Record<string, unknown> = {};
|
||||
if (lb.passive_health_check.fail_duration) passive.fail_duration = lb.passive_health_check.fail_duration;
|
||||
if (lb.passive_health_check.max_fails != null) passive.max_fails = lb.passive_health_check.max_fails;
|
||||
if (lb.passive_health_check.unhealthy_latency) passive.unhealthy_latency = lb.passive_health_check.unhealthy_latency;
|
||||
if (Object.keys(passive).length > 0) healthChecks.passive = passive;
|
||||
}
|
||||
if (Object.keys(healthChecks).length > 0) proxyHandler.health_checks = healthChecks;
|
||||
}
|
||||
}
|
||||
|
||||
handlers.push(proxyHandler);
|
||||
|
||||
route.handle = handlers;
|
||||
return route;
|
||||
});
|
||||
|
||||
servers[`l4_server_${serverIdx++}`] = { listen: [listenAddr], routes };
|
||||
}
|
||||
|
||||
return servers;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config shape tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('L4 Caddy config generation', () => {
|
||||
it('returns null when no L4 hosts exist', async () => {
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
expect(buildExpectedL4Config(rows)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when all hosts are disabled', async () => {
|
||||
await insertL4Host({ enabled: false });
|
||||
await insertL4Host({ enabled: false, name: 'Also disabled', listenAddress: ':3306' });
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
expect(buildExpectedL4Config(rows)).toBeNull();
|
||||
});
|
||||
|
||||
it('simple TCP proxy — catch-all, single upstream', async () => {
|
||||
await insertL4Host({
|
||||
name: 'PostgreSQL',
|
||||
listenAddress: ':5432',
|
||||
upstreams: JSON.stringify(['10.0.0.1:5432']),
|
||||
matcherType: 'none',
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows);
|
||||
|
||||
expect(config).toEqual({
|
||||
l4_server_0: {
|
||||
listen: [':5432'],
|
||||
routes: [
|
||||
{
|
||||
handle: [
|
||||
{ handler: 'proxy', upstreams: [{ dial: ['10.0.0.1:5432'] }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('TCP proxy with TLS SNI matcher and TLS termination', async () => {
|
||||
await insertL4Host({
|
||||
name: 'Secure DB',
|
||||
listenAddress: ':5432',
|
||||
matcherType: 'tls_sni',
|
||||
matcherValue: JSON.stringify(['db.example.com']),
|
||||
tlsTermination: true,
|
||||
upstreams: JSON.stringify(['10.0.0.1:5432']),
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows);
|
||||
|
||||
expect(config).toEqual({
|
||||
l4_server_0: {
|
||||
listen: [':5432'],
|
||||
routes: [
|
||||
{
|
||||
match: [{ tls: { sni: ['db.example.com'] } }],
|
||||
handle: [
|
||||
{ handler: 'tls' },
|
||||
{ handler: 'proxy', upstreams: [{ dial: ['10.0.0.1:5432'] }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('HTTP host matcher shape', async () => {
|
||||
await insertL4Host({
|
||||
name: 'HTTP Route',
|
||||
listenAddress: ':8080',
|
||||
matcherType: 'http_host',
|
||||
matcherValue: JSON.stringify(['api.example.com']),
|
||||
upstreams: JSON.stringify(['10.0.0.1:8080']),
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows)!;
|
||||
const route = (config.l4_server_0 as any).routes[0];
|
||||
expect(route.match).toEqual([{ http: [{ host: ['api.example.com'] }] }]);
|
||||
});
|
||||
|
||||
it('proxy_protocol matcher shape', async () => {
|
||||
await insertL4Host({
|
||||
name: 'PP Match',
|
||||
listenAddress: ':8443',
|
||||
matcherType: 'proxy_protocol',
|
||||
upstreams: JSON.stringify(['10.0.0.1:443']),
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows)!;
|
||||
const route = (config.l4_server_0 as any).routes[0];
|
||||
expect(route.match).toEqual([{ proxy_protocol: {} }]);
|
||||
});
|
||||
|
||||
it('full handler chain — proxy_protocol receive + TLS + proxy with PP v1', async () => {
|
||||
await insertL4Host({
|
||||
name: 'Secure IMAP',
|
||||
listenAddress: '0.0.0.0:993',
|
||||
upstreams: JSON.stringify(['localhost:143']),
|
||||
tlsTermination: true,
|
||||
proxyProtocolReceive: true,
|
||||
proxyProtocolVersion: 'v1',
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows);
|
||||
|
||||
expect(config).toEqual({
|
||||
l4_server_0: {
|
||||
listen: ['0.0.0.0:993'],
|
||||
routes: [
|
||||
{
|
||||
handle: [
|
||||
{ handler: 'proxy_protocol' },
|
||||
{ handler: 'tls' },
|
||||
{
|
||||
handler: 'proxy',
|
||||
proxy_protocol: 'v1',
|
||||
upstreams: [{ dial: ['localhost:143'] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('proxy_protocol v2 outbound', async () => {
|
||||
await insertL4Host({
|
||||
name: 'PP v2',
|
||||
listenAddress: ':8443',
|
||||
upstreams: JSON.stringify(['10.0.0.1:443']),
|
||||
proxyProtocolVersion: 'v2',
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows)!;
|
||||
const route = (config.l4_server_0 as any).routes[0];
|
||||
const proxyHandler = route.handle[route.handle.length - 1];
|
||||
expect(proxyHandler.proxy_protocol).toBe('v2');
|
||||
});
|
||||
|
||||
it('multiple upstreams for load balancing', async () => {
|
||||
const upstreams = ['10.0.0.1:5432', '10.0.0.2:5432', '10.0.0.3:5432'];
|
||||
await insertL4Host({
|
||||
name: 'LB PG',
|
||||
listenAddress: ':5432',
|
||||
upstreams: JSON.stringify(upstreams),
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows)!;
|
||||
const route = (config.l4_server_0 as any).routes[0];
|
||||
expect(route.handle[0].upstreams).toEqual([
|
||||
{ dial: ['10.0.0.1:5432'] },
|
||||
{ dial: ['10.0.0.2:5432'] },
|
||||
{ dial: ['10.0.0.3:5432'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('groups multiple hosts on same port into shared server routes', async () => {
|
||||
await insertL4Host({
|
||||
name: 'DB1',
|
||||
listenAddress: ':5432',
|
||||
matcherType: 'tls_sni',
|
||||
matcherValue: JSON.stringify(['db1.example.com']),
|
||||
upstreams: JSON.stringify(['10.0.0.1:5432']),
|
||||
});
|
||||
await insertL4Host({
|
||||
name: 'DB2',
|
||||
listenAddress: ':5432',
|
||||
matcherType: 'tls_sni',
|
||||
matcherValue: JSON.stringify(['db2.example.com']),
|
||||
upstreams: JSON.stringify(['10.0.0.2:5432']),
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows)!;
|
||||
|
||||
// Should be a single server with 2 routes
|
||||
expect(Object.keys(config)).toHaveLength(1);
|
||||
const server = config.l4_server_0 as any;
|
||||
expect(server.listen).toEqual([':5432']);
|
||||
expect(server.routes).toHaveLength(2);
|
||||
expect(server.routes[0].match).toEqual([{ tls: { sni: ['db1.example.com'] } }]);
|
||||
expect(server.routes[1].match).toEqual([{ tls: { sni: ['db2.example.com'] } }]);
|
||||
});
|
||||
|
||||
it('different ports create separate servers', async () => {
|
||||
await insertL4Host({ name: 'PG', listenAddress: ':5432', upstreams: JSON.stringify(['10.0.0.1:5432']) });
|
||||
await insertL4Host({ name: 'MySQL', listenAddress: ':3306', upstreams: JSON.stringify(['10.0.0.2:3306']) });
|
||||
await insertL4Host({ name: 'Redis', listenAddress: ':6379', upstreams: JSON.stringify(['10.0.0.3:6379']) });
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows)!;
|
||||
|
||||
expect(Object.keys(config)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('disabled hosts are excluded from config', async () => {
|
||||
await insertL4Host({ name: 'Active', listenAddress: ':5432', enabled: true });
|
||||
await insertL4Host({ name: 'Disabled', listenAddress: ':3306', enabled: false });
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows)!;
|
||||
|
||||
// Only the active host should be in config
|
||||
expect(Object.keys(config)).toHaveLength(1);
|
||||
expect((config.l4_server_0 as any).listen).toEqual([':5432']);
|
||||
});
|
||||
|
||||
it('UDP proxy — correct listen address', async () => {
|
||||
await insertL4Host({
|
||||
name: 'DNS Proxy',
|
||||
protocol: 'udp',
|
||||
listenAddress: ':5353',
|
||||
upstreams: JSON.stringify(['8.8.8.8:53', '8.8.4.4:53']),
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows)!;
|
||||
const server = config.l4_server_0 as any;
|
||||
expect(server.listen).toEqual([':5353']);
|
||||
expect(server.routes[0].handle[0].upstreams).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('TLS SNI with multiple hostnames', async () => {
|
||||
await insertL4Host({
|
||||
name: 'Multi SNI',
|
||||
listenAddress: ':443',
|
||||
matcherType: 'tls_sni',
|
||||
matcherValue: JSON.stringify(['db1.example.com', 'db2.example.com', 'db3.example.com']),
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows)!;
|
||||
const route = (config.l4_server_0 as any).routes[0];
|
||||
expect(route.match[0].tls.sni).toEqual(['db1.example.com', 'db2.example.com', 'db3.example.com']);
|
||||
});
|
||||
|
||||
it('load balancer with round_robin policy', async () => {
|
||||
await insertL4Host({
|
||||
name: 'LB Host',
|
||||
listenAddress: ':5432',
|
||||
upstreams: JSON.stringify(['10.0.0.1:5432', '10.0.0.2:5432']),
|
||||
meta: JSON.stringify({
|
||||
load_balancer: {
|
||||
enabled: true,
|
||||
policy: 'round_robin',
|
||||
try_duration: '5s',
|
||||
retries: 3,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows)!;
|
||||
const route = (config.l4_server_0 as any).routes[0];
|
||||
const proxyHandler = route.handle[0];
|
||||
expect(proxyHandler.load_balancing).toEqual({
|
||||
selection_policy: { policy: 'round_robin' },
|
||||
try_duration: '5s',
|
||||
retries: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('load balancer with active health check', async () => {
|
||||
await insertL4Host({
|
||||
name: 'Health Check Host',
|
||||
listenAddress: ':3306',
|
||||
upstreams: JSON.stringify(['10.0.0.1:3306']),
|
||||
meta: JSON.stringify({
|
||||
load_balancer: {
|
||||
enabled: true,
|
||||
policy: 'least_conn',
|
||||
active_health_check: {
|
||||
enabled: true,
|
||||
port: 3307,
|
||||
interval: '10s',
|
||||
timeout: '5s',
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows)!;
|
||||
const route = (config.l4_server_0 as any).routes[0];
|
||||
const proxyHandler = route.handle[0];
|
||||
expect(proxyHandler.health_checks).toEqual({
|
||||
active: {
|
||||
port: 3307,
|
||||
interval: '10s',
|
||||
timeout: '5s',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('load balancer with passive health check', async () => {
|
||||
await insertL4Host({
|
||||
name: 'Passive HC Host',
|
||||
listenAddress: ':6379',
|
||||
upstreams: JSON.stringify(['10.0.0.1:6379']),
|
||||
meta: JSON.stringify({
|
||||
load_balancer: {
|
||||
enabled: true,
|
||||
policy: 'random',
|
||||
passive_health_check: {
|
||||
enabled: true,
|
||||
fail_duration: '30s',
|
||||
max_fails: 5,
|
||||
unhealthy_latency: '2s',
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows)!;
|
||||
const route = (config.l4_server_0 as any).routes[0];
|
||||
const proxyHandler = route.handle[0];
|
||||
expect(proxyHandler.health_checks).toEqual({
|
||||
passive: {
|
||||
fail_duration: '30s',
|
||||
max_fails: 5,
|
||||
unhealthy_latency: '2s',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('disabled load balancer does not add config', async () => {
|
||||
await insertL4Host({
|
||||
name: 'No LB',
|
||||
listenAddress: ':5432',
|
||||
upstreams: JSON.stringify(['10.0.0.1:5432']),
|
||||
meta: JSON.stringify({
|
||||
load_balancer: { enabled: false, policy: 'round_robin' },
|
||||
}),
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows)!;
|
||||
const route = (config.l4_server_0 as any).routes[0];
|
||||
expect(route.handle[0].load_balancing).toBeUndefined();
|
||||
});
|
||||
|
||||
it('dns resolver config stored in meta', async () => {
|
||||
await insertL4Host({
|
||||
name: 'DNS Host',
|
||||
listenAddress: ':5432',
|
||||
upstreams: JSON.stringify(['db.example.com:5432']),
|
||||
meta: JSON.stringify({
|
||||
dns_resolver: {
|
||||
enabled: true,
|
||||
resolvers: ['1.1.1.1', '8.8.8.8'],
|
||||
fallbacks: ['8.8.4.4'],
|
||||
timeout: '5s',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const meta = JSON.parse(rows[0].meta!);
|
||||
expect(meta.dns_resolver.enabled).toBe(true);
|
||||
expect(meta.dns_resolver.resolvers).toEqual(['1.1.1.1', '8.8.8.8']);
|
||||
expect(meta.dns_resolver.fallbacks).toEqual(['8.8.4.4']);
|
||||
expect(meta.dns_resolver.timeout).toBe('5s');
|
||||
});
|
||||
|
||||
it('geo blocking config produces blocker matcher route', async () => {
|
||||
await insertL4Host({
|
||||
name: 'Geo Blocked',
|
||||
listenAddress: ':5432',
|
||||
upstreams: JSON.stringify(['10.0.0.1:5432']),
|
||||
meta: JSON.stringify({
|
||||
geoblock: {
|
||||
enabled: true,
|
||||
block_countries: ['CN', 'RU'],
|
||||
block_continents: [],
|
||||
block_asns: [12345],
|
||||
block_cidrs: [],
|
||||
block_ips: [],
|
||||
allow_countries: ['US'],
|
||||
allow_continents: [],
|
||||
allow_asns: [],
|
||||
allow_cidrs: [],
|
||||
allow_ips: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const meta = JSON.parse(rows[0].meta!);
|
||||
expect(meta.geoblock.enabled).toBe(true);
|
||||
expect(meta.geoblock.block_countries).toEqual(['CN', 'RU']);
|
||||
expect(meta.geoblock.block_asns).toEqual([12345]);
|
||||
expect(meta.geoblock.allow_countries).toEqual(['US']);
|
||||
});
|
||||
|
||||
it('disabled geo blocking does not produce a route', async () => {
|
||||
await insertL4Host({
|
||||
name: 'No Geo Block',
|
||||
listenAddress: ':5432',
|
||||
upstreams: JSON.stringify(['10.0.0.1:5432']),
|
||||
meta: JSON.stringify({
|
||||
geoblock: {
|
||||
enabled: false,
|
||||
block_countries: ['CN'],
|
||||
block_continents: [], block_asns: [], block_cidrs: [], block_ips: [],
|
||||
allow_countries: [], allow_continents: [], allow_asns: [], allow_cidrs: [], allow_ips: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const config = buildExpectedL4Config(rows)!;
|
||||
// Only the proxy route should exist, no blocking route
|
||||
const server = config.l4_server_0 as any;
|
||||
expect(server.routes).toHaveLength(1);
|
||||
expect(server.routes[0].handle[0].handler).toBe('proxy');
|
||||
});
|
||||
|
||||
it('upstream dns resolution config stored in meta', async () => {
|
||||
await insertL4Host({
|
||||
name: 'Pinned Host',
|
||||
listenAddress: ':5432',
|
||||
upstreams: JSON.stringify(['db.example.com:5432']),
|
||||
meta: JSON.stringify({
|
||||
upstream_dns_resolution: {
|
||||
enabled: true,
|
||||
family: 'ipv4',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
const meta = JSON.parse(rows[0].meta!);
|
||||
expect(meta.upstream_dns_resolution.enabled).toBe(true);
|
||||
expect(meta.upstream_dns_resolution.family).toBe('ipv4');
|
||||
});
|
||||
});
|
||||
378
tests/integration/l4-ports.test.ts
Normal file
378
tests/integration/l4-ports.test.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* Integration tests for L4 port management.
|
||||
*
|
||||
* Tests the port computation, override file generation, diff detection,
|
||||
* and status lifecycle.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { TestDb } from '../helpers/db';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock db and set L4_PORTS_DIR to a temp directory for file operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ctx = vi.hoisted(() => {
|
||||
const { mkdirSync } = require('node:fs');
|
||||
const { join } = require('node:path');
|
||||
const { tmpdir } = require('node:os');
|
||||
const dir = join(tmpdir(), `l4-ports-test-${Date.now()}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
process.env.L4_PORTS_DIR = dir;
|
||||
return { db: null as unknown as TestDb, tmpDir: dir };
|
||||
});
|
||||
|
||||
vi.mock('../../src/lib/db', async () => {
|
||||
const { createTestDb } = await import('../helpers/db');
|
||||
const schemaModule = await import('../../src/lib/db/schema');
|
||||
ctx.db = createTestDb();
|
||||
return {
|
||||
default: ctx.db,
|
||||
schema: schemaModule,
|
||||
nowIso: () => new Date().toISOString(),
|
||||
toIso: (value: string | Date | null | undefined): string | null => {
|
||||
if (!value) return null;
|
||||
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../src/lib/caddy', () => ({
|
||||
applyCaddyConfig: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/lib/audit', () => ({
|
||||
logAuditEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as schema from '../../src/lib/db/schema';
|
||||
import {
|
||||
getRequiredL4Ports,
|
||||
getAppliedL4Ports,
|
||||
getL4PortsDiff,
|
||||
applyL4Ports,
|
||||
getL4PortsStatus,
|
||||
} from '../../src/lib/l4-ports';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function makeL4Host(overrides: Partial<typeof schema.l4ProxyHosts.$inferInsert> = {}) {
|
||||
const now = nowIso();
|
||||
return {
|
||||
name: 'Test L4 Host',
|
||||
protocol: 'tcp',
|
||||
listenAddress: ':5432',
|
||||
upstreams: JSON.stringify(['10.0.0.1:5432']),
|
||||
matcherType: 'none',
|
||||
matcherValue: null,
|
||||
tlsTermination: false,
|
||||
proxyProtocolVersion: null,
|
||||
proxyProtocolReceive: false,
|
||||
ownerUserId: null,
|
||||
meta: null,
|
||||
enabled: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...overrides,
|
||||
} satisfies typeof schema.l4ProxyHosts.$inferInsert;
|
||||
}
|
||||
|
||||
function cleanTmpDir() {
|
||||
for (const file of ['docker-compose.l4-ports.yml', 'l4-ports.trigger', 'l4-ports.status']) {
|
||||
const path = join(ctx.tmpDir, file);
|
||||
if (existsSync(path)) rmSync(path);
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await ctx.db.delete(schema.l4ProxyHosts);
|
||||
cleanTmpDir();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getRequiredL4Ports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getRequiredL4Ports', () => {
|
||||
it('returns empty array when no L4 hosts exist', async () => {
|
||||
const ports = await getRequiredL4Ports();
|
||||
expect(ports).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns TCP port for enabled host', async () => {
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
listenAddress: ':5432',
|
||||
protocol: 'tcp',
|
||||
enabled: true,
|
||||
}));
|
||||
const ports = await getRequiredL4Ports();
|
||||
expect(ports).toEqual(['5432:5432']);
|
||||
});
|
||||
|
||||
it('returns UDP port with /udp suffix', async () => {
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
listenAddress: ':5353',
|
||||
protocol: 'udp',
|
||||
enabled: true,
|
||||
}));
|
||||
const ports = await getRequiredL4Ports();
|
||||
expect(ports).toEqual(['5353:5353/udp']);
|
||||
});
|
||||
|
||||
it('excludes disabled hosts', async () => {
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
name: 'Enabled',
|
||||
listenAddress: ':5432',
|
||||
enabled: true,
|
||||
}));
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
name: 'Disabled',
|
||||
listenAddress: ':3306',
|
||||
enabled: false,
|
||||
}));
|
||||
const ports = await getRequiredL4Ports();
|
||||
expect(ports).toEqual(['5432:5432']);
|
||||
});
|
||||
|
||||
it('deduplicates ports from multiple hosts on same address', async () => {
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
name: 'Host 1',
|
||||
listenAddress: ':5432',
|
||||
}));
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
name: 'Host 2',
|
||||
listenAddress: ':5432',
|
||||
}));
|
||||
const ports = await getRequiredL4Ports();
|
||||
expect(ports).toEqual(['5432:5432']);
|
||||
});
|
||||
|
||||
it('handles HOST:PORT format', async () => {
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
listenAddress: '0.0.0.0:5432',
|
||||
}));
|
||||
const ports = await getRequiredL4Ports();
|
||||
expect(ports).toEqual(['5432:5432']);
|
||||
});
|
||||
|
||||
it('returns multiple ports sorted', async () => {
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
name: 'Redis',
|
||||
listenAddress: ':6379',
|
||||
}));
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
name: 'PG',
|
||||
listenAddress: ':5432',
|
||||
}));
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
name: 'MySQL',
|
||||
listenAddress: ':3306',
|
||||
}));
|
||||
const ports = await getRequiredL4Ports();
|
||||
expect(ports).toEqual(['3306:3306', '5432:5432', '6379:6379']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getAppliedL4Ports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getAppliedL4Ports', () => {
|
||||
it('returns empty when no override file exists', () => {
|
||||
const ports = getAppliedL4Ports();
|
||||
expect(ports).toEqual([]);
|
||||
});
|
||||
|
||||
it('parses ports from override file', () => {
|
||||
writeFileSync(join(ctx.tmpDir, 'docker-compose.l4-ports.yml'), `services:
|
||||
caddy:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "3306:3306"
|
||||
`);
|
||||
const ports = getAppliedL4Ports();
|
||||
expect(ports).toEqual(['3306:3306', '5432:5432']);
|
||||
});
|
||||
|
||||
it('handles empty override file', () => {
|
||||
writeFileSync(join(ctx.tmpDir, 'docker-compose.l4-ports.yml'), `services: {}
|
||||
`);
|
||||
const ports = getAppliedL4Ports();
|
||||
expect(ports).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getL4PortsDiff
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getL4PortsDiff', () => {
|
||||
it('needsApply is false when no hosts and no override', async () => {
|
||||
const diff = await getL4PortsDiff();
|
||||
expect(diff.needsApply).toBe(false);
|
||||
expect(diff.requiredPorts).toEqual([]);
|
||||
expect(diff.currentPorts).toEqual([]);
|
||||
});
|
||||
|
||||
it('needsApply is true when host exists but no override', async () => {
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
listenAddress: ':5432',
|
||||
}));
|
||||
const diff = await getL4PortsDiff();
|
||||
expect(diff.needsApply).toBe(true);
|
||||
expect(diff.requiredPorts).toEqual(['5432:5432']);
|
||||
expect(diff.currentPorts).toEqual([]);
|
||||
});
|
||||
|
||||
it('needsApply is false when override matches', async () => {
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
listenAddress: ':5432',
|
||||
}));
|
||||
writeFileSync(join(ctx.tmpDir, 'docker-compose.l4-ports.yml'), `services:
|
||||
caddy:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
`);
|
||||
const diff = await getL4PortsDiff();
|
||||
expect(diff.needsApply).toBe(false);
|
||||
});
|
||||
|
||||
it('needsApply is true when override has different ports', async () => {
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
listenAddress: ':5432',
|
||||
}));
|
||||
writeFileSync(join(ctx.tmpDir, 'docker-compose.l4-ports.yml'), `services:
|
||||
caddy:
|
||||
ports:
|
||||
- "3306:3306"
|
||||
`);
|
||||
const diff = await getL4PortsDiff();
|
||||
expect(diff.needsApply).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// applyL4Ports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('applyL4Ports', () => {
|
||||
it('writes override file with required ports', async () => {
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
listenAddress: ':5432',
|
||||
}));
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
name: 'DNS',
|
||||
listenAddress: ':5353',
|
||||
protocol: 'udp',
|
||||
}));
|
||||
|
||||
const status = await applyL4Ports();
|
||||
expect(status.state).toBe('pending');
|
||||
|
||||
const overrideContent = readFileSync(join(ctx.tmpDir, 'docker-compose.l4-ports.yml'), 'utf-8');
|
||||
expect(overrideContent).toContain('"5432:5432"');
|
||||
expect(overrideContent).toContain('"5353:5353/udp"');
|
||||
});
|
||||
|
||||
it('writes trigger file', async () => {
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
listenAddress: ':5432',
|
||||
}));
|
||||
|
||||
await applyL4Ports();
|
||||
const triggerPath = join(ctx.tmpDir, 'l4-ports.trigger');
|
||||
expect(existsSync(triggerPath)).toBe(true);
|
||||
|
||||
const trigger = JSON.parse(readFileSync(triggerPath, 'utf-8'));
|
||||
expect(trigger.triggeredAt).toBeDefined();
|
||||
expect(trigger.ports).toEqual(['5432:5432']);
|
||||
});
|
||||
|
||||
it('writes empty override when no ports needed', async () => {
|
||||
const status = await applyL4Ports();
|
||||
expect(status.state).toBe('pending');
|
||||
|
||||
const overrideContent = readFileSync(join(ctx.tmpDir, 'docker-compose.l4-ports.yml'), 'utf-8');
|
||||
expect(overrideContent).toContain('services: {}');
|
||||
});
|
||||
|
||||
it('override file is idempotent — same ports produce same content', async () => {
|
||||
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({
|
||||
listenAddress: ':5432',
|
||||
}));
|
||||
|
||||
await applyL4Ports();
|
||||
const content1 = readFileSync(join(ctx.tmpDir, 'docker-compose.l4-ports.yml'), 'utf-8');
|
||||
|
||||
await applyL4Ports();
|
||||
const content2 = readFileSync(join(ctx.tmpDir, 'docker-compose.l4-ports.yml'), 'utf-8');
|
||||
|
||||
expect(content1).toBe(content2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getL4PortsStatus
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getL4PortsStatus', () => {
|
||||
it('returns idle when no status file exists', () => {
|
||||
const status = getL4PortsStatus();
|
||||
expect(status.state).toBe('idle');
|
||||
});
|
||||
|
||||
it('returns idle when no status file exists even if trigger file is present', () => {
|
||||
// Trigger files are deleted by the sidecar after processing.
|
||||
// A leftover trigger file must NEVER cause "Waiting for port manager sidecar..."
|
||||
// because that message gets permanently stuck if the sidecar is slow or restarting.
|
||||
writeFileSync(join(ctx.tmpDir, 'l4-ports.trigger'), JSON.stringify({
|
||||
triggeredAt: new Date().toISOString(),
|
||||
}));
|
||||
const status = getL4PortsStatus();
|
||||
expect(status.state).toBe('idle');
|
||||
});
|
||||
|
||||
it('returns applied when status file says applied', () => {
|
||||
writeFileSync(join(ctx.tmpDir, 'l4-ports.status'), JSON.stringify({
|
||||
state: 'applied',
|
||||
message: 'Success',
|
||||
appliedAt: new Date().toISOString(),
|
||||
}));
|
||||
const status = getL4PortsStatus();
|
||||
expect(status.state).toBe('applied');
|
||||
});
|
||||
|
||||
it('returns failed when status file says failed', () => {
|
||||
writeFileSync(join(ctx.tmpDir, 'l4-ports.status'), JSON.stringify({
|
||||
state: 'failed',
|
||||
message: 'Failed',
|
||||
error: 'Container failed',
|
||||
appliedAt: new Date().toISOString(),
|
||||
}));
|
||||
const status = getL4PortsStatus();
|
||||
expect(status.state).toBe('failed');
|
||||
expect(status.error).toBe('Container failed');
|
||||
});
|
||||
|
||||
it('returns status from file regardless of trigger file presence', () => {
|
||||
// The sidecar deletes triggers after processing, so the status file is
|
||||
// the single source of truth — trigger file presence is irrelevant here.
|
||||
writeFileSync(join(ctx.tmpDir, 'l4-ports.trigger'), JSON.stringify({
|
||||
triggeredAt: '2026-03-21T12:00:00Z',
|
||||
}));
|
||||
writeFileSync(join(ctx.tmpDir, 'l4-ports.status'), JSON.stringify({
|
||||
state: 'applied',
|
||||
message: 'Done',
|
||||
appliedAt: '2026-01-01T00:00:00Z',
|
||||
}));
|
||||
const status = getL4PortsStatus();
|
||||
expect(status.state).toBe('applied');
|
||||
});
|
||||
});
|
||||
335
tests/integration/l4-proxy-hosts.test.ts
Normal file
335
tests/integration/l4-proxy-hosts.test.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb, type TestDb } from '../helpers/db';
|
||||
import { l4ProxyHosts } from '../../src/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
let db: TestDb;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDb();
|
||||
});
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
async function insertL4Host(overrides: Partial<typeof l4ProxyHosts.$inferInsert> = {}) {
|
||||
const now = nowIso();
|
||||
const [host] = await db.insert(l4ProxyHosts).values({
|
||||
name: 'Test L4 Host',
|
||||
protocol: 'tcp',
|
||||
listenAddress: ':5432',
|
||||
upstreams: JSON.stringify(['10.0.0.1:5432']),
|
||||
matcherType: 'none',
|
||||
matcherValue: null,
|
||||
tlsTermination: false,
|
||||
proxyProtocolVersion: null,
|
||||
proxyProtocolReceive: false,
|
||||
enabled: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...overrides,
|
||||
}).returning();
|
||||
return host;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Basic CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('l4-proxy-hosts integration', () => {
|
||||
it('inserts and retrieves an L4 proxy host', async () => {
|
||||
const host = await insertL4Host();
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.name).toBe('Test L4 Host');
|
||||
expect(row!.protocol).toBe('tcp');
|
||||
expect(row!.listenAddress).toBe(':5432');
|
||||
});
|
||||
|
||||
it('delete by id removes the host', async () => {
|
||||
const host = await insertL4Host();
|
||||
await db.delete(l4ProxyHosts).where(eq(l4ProxyHosts.id, host.id));
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
it('multiple L4 hosts — count is correct', async () => {
|
||||
await insertL4Host({ name: 'PG', listenAddress: ':5432' });
|
||||
await insertL4Host({ name: 'MySQL', listenAddress: ':3306' });
|
||||
await insertL4Host({ name: 'Redis', listenAddress: ':6379' });
|
||||
const rows = await db.select().from(l4ProxyHosts);
|
||||
expect(rows.length).toBe(3);
|
||||
});
|
||||
|
||||
it('enabled field defaults to true', async () => {
|
||||
const host = await insertL4Host();
|
||||
expect(host.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('can set enabled to false', async () => {
|
||||
const host = await insertL4Host({ enabled: false });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(Boolean(row!.enabled)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Protocol field
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('l4-proxy-hosts protocol', () => {
|
||||
it('stores TCP protocol', async () => {
|
||||
const host = await insertL4Host({ protocol: 'tcp' });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(row!.protocol).toBe('tcp');
|
||||
});
|
||||
|
||||
it('stores UDP protocol', async () => {
|
||||
const host = await insertL4Host({ protocol: 'udp' });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(row!.protocol).toBe('udp');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON fields (upstreams, matcher_value)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('l4-proxy-hosts JSON fields', () => {
|
||||
it('stores and retrieves upstreams array', async () => {
|
||||
const upstreams = ['10.0.0.1:5432', '10.0.0.2:5432', '10.0.0.3:5432'];
|
||||
const host = await insertL4Host({ upstreams: JSON.stringify(upstreams) });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(JSON.parse(row!.upstreams)).toEqual(upstreams);
|
||||
});
|
||||
|
||||
it('stores and retrieves matcher_value for TLS SNI', async () => {
|
||||
const matcherValue = ['db.example.com', 'db2.example.com'];
|
||||
const host = await insertL4Host({
|
||||
matcherType: 'tls_sni',
|
||||
matcherValue: JSON.stringify(matcherValue),
|
||||
});
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(row!.matcherType).toBe('tls_sni');
|
||||
expect(JSON.parse(row!.matcherValue!)).toEqual(matcherValue);
|
||||
});
|
||||
|
||||
it('stores and retrieves matcher_value for HTTP host', async () => {
|
||||
const matcherValue = ['api.example.com'];
|
||||
const host = await insertL4Host({
|
||||
matcherType: 'http_host',
|
||||
matcherValue: JSON.stringify(matcherValue),
|
||||
});
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(row!.matcherType).toBe('http_host');
|
||||
expect(JSON.parse(row!.matcherValue!)).toEqual(matcherValue);
|
||||
});
|
||||
|
||||
it('matcher_value is null for none matcher', async () => {
|
||||
const host = await insertL4Host({ matcherType: 'none', matcherValue: null });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(row!.matcherType).toBe('none');
|
||||
expect(row!.matcherValue).toBeNull();
|
||||
});
|
||||
|
||||
it('matcher_value is null for proxy_protocol matcher', async () => {
|
||||
const host = await insertL4Host({ matcherType: 'proxy_protocol', matcherValue: null });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(row!.matcherType).toBe('proxy_protocol');
|
||||
expect(row!.matcherValue).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Boolean fields
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('l4-proxy-hosts boolean fields', () => {
|
||||
it('tls_termination defaults to false', async () => {
|
||||
const host = await insertL4Host();
|
||||
expect(Boolean(host.tlsTermination)).toBe(false);
|
||||
});
|
||||
|
||||
it('tls_termination can be set to true', async () => {
|
||||
const host = await insertL4Host({ tlsTermination: true });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(Boolean(row!.tlsTermination)).toBe(true);
|
||||
});
|
||||
|
||||
it('proxy_protocol_receive defaults to false', async () => {
|
||||
const host = await insertL4Host();
|
||||
expect(Boolean(host.proxyProtocolReceive)).toBe(false);
|
||||
});
|
||||
|
||||
it('proxy_protocol_receive can be set to true', async () => {
|
||||
const host = await insertL4Host({ proxyProtocolReceive: true });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(Boolean(row!.proxyProtocolReceive)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Proxy protocol version
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('l4-proxy-hosts proxy protocol version', () => {
|
||||
it('proxy_protocol_version defaults to null', async () => {
|
||||
const host = await insertL4Host();
|
||||
expect(host.proxyProtocolVersion).toBeNull();
|
||||
});
|
||||
|
||||
it('stores v1 proxy protocol version', async () => {
|
||||
const host = await insertL4Host({ proxyProtocolVersion: 'v1' });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(row!.proxyProtocolVersion).toBe('v1');
|
||||
});
|
||||
|
||||
it('stores v2 proxy protocol version', async () => {
|
||||
const host = await insertL4Host({ proxyProtocolVersion: 'v2' });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(row!.proxyProtocolVersion).toBe('v2');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Meta field
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('l4-proxy-hosts meta', () => {
|
||||
it('meta can be null', async () => {
|
||||
const host = await insertL4Host({ meta: null });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(row!.meta).toBeNull();
|
||||
});
|
||||
|
||||
it('stores and retrieves load balancer config via meta', async () => {
|
||||
const meta = {
|
||||
load_balancer: {
|
||||
enabled: true,
|
||||
policy: 'round_robin',
|
||||
try_duration: '5s',
|
||||
try_interval: '250ms',
|
||||
retries: 3,
|
||||
active_health_check: { enabled: true, port: 8081, interval: '10s', timeout: '5s' },
|
||||
passive_health_check: { enabled: true, fail_duration: '30s', max_fails: 5 },
|
||||
},
|
||||
};
|
||||
const host = await insertL4Host({ meta: JSON.stringify(meta) });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
const parsed = JSON.parse(row!.meta!);
|
||||
expect(parsed.load_balancer.enabled).toBe(true);
|
||||
expect(parsed.load_balancer.policy).toBe('round_robin');
|
||||
expect(parsed.load_balancer.active_health_check.port).toBe(8081);
|
||||
expect(parsed.load_balancer.passive_health_check.max_fails).toBe(5);
|
||||
});
|
||||
|
||||
it('stores and retrieves DNS resolver config via meta', async () => {
|
||||
const meta = {
|
||||
dns_resolver: {
|
||||
enabled: true,
|
||||
resolvers: ['1.1.1.1', '8.8.8.8'],
|
||||
fallbacks: ['8.8.4.4'],
|
||||
timeout: '5s',
|
||||
},
|
||||
};
|
||||
const host = await insertL4Host({ meta: JSON.stringify(meta) });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
const parsed = JSON.parse(row!.meta!);
|
||||
expect(parsed.dns_resolver.enabled).toBe(true);
|
||||
expect(parsed.dns_resolver.resolvers).toEqual(['1.1.1.1', '8.8.8.8']);
|
||||
expect(parsed.dns_resolver.timeout).toBe('5s');
|
||||
});
|
||||
|
||||
it('stores and retrieves upstream DNS resolution config via meta', async () => {
|
||||
const meta = {
|
||||
upstream_dns_resolution: { enabled: true, family: 'ipv4' },
|
||||
};
|
||||
const host = await insertL4Host({ meta: JSON.stringify(meta) });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
const parsed = JSON.parse(row!.meta!);
|
||||
expect(parsed.upstream_dns_resolution.enabled).toBe(true);
|
||||
expect(parsed.upstream_dns_resolution.family).toBe('ipv4');
|
||||
});
|
||||
|
||||
it('stores all three meta features together', async () => {
|
||||
const meta = {
|
||||
load_balancer: { enabled: true, policy: 'ip_hash' },
|
||||
dns_resolver: { enabled: true, resolvers: ['1.1.1.1'] },
|
||||
upstream_dns_resolution: { enabled: true, family: 'both' },
|
||||
};
|
||||
const host = await insertL4Host({ meta: JSON.stringify(meta) });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
const parsed = JSON.parse(row!.meta!);
|
||||
expect(parsed.load_balancer.policy).toBe('ip_hash');
|
||||
expect(parsed.dns_resolver.resolvers).toEqual(['1.1.1.1']);
|
||||
expect(parsed.upstream_dns_resolution.family).toBe('both');
|
||||
});
|
||||
|
||||
it('stores and retrieves geo blocking config via meta', async () => {
|
||||
const meta = {
|
||||
geoblock: {
|
||||
enabled: true,
|
||||
block_countries: ['CN', 'RU', 'KP'],
|
||||
block_continents: ['AF'],
|
||||
block_asns: [12345],
|
||||
block_cidrs: ['192.0.2.0/24'],
|
||||
block_ips: ['203.0.113.1'],
|
||||
allow_countries: ['US'],
|
||||
allow_continents: [],
|
||||
allow_asns: [],
|
||||
allow_cidrs: ['10.0.0.0/8'],
|
||||
allow_ips: [],
|
||||
},
|
||||
geoblock_mode: 'override',
|
||||
};
|
||||
const host = await insertL4Host({ meta: JSON.stringify(meta) });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
const parsed = JSON.parse(row!.meta!);
|
||||
expect(parsed.geoblock.enabled).toBe(true);
|
||||
expect(parsed.geoblock.block_countries).toEqual(['CN', 'RU', 'KP']);
|
||||
expect(parsed.geoblock.allow_cidrs).toEqual(['10.0.0.0/8']);
|
||||
expect(parsed.geoblock_mode).toBe('override');
|
||||
});
|
||||
|
||||
it('stores all four meta features together', async () => {
|
||||
const meta = {
|
||||
load_balancer: { enabled: true, policy: 'round_robin' },
|
||||
dns_resolver: { enabled: true, resolvers: ['1.1.1.1'] },
|
||||
upstream_dns_resolution: { enabled: true, family: 'ipv4' },
|
||||
geoblock: { enabled: true, block_countries: ['CN'], block_continents: [], block_asns: [], block_cidrs: [], block_ips: [], allow_countries: [], allow_continents: [], allow_asns: [], allow_cidrs: [], allow_ips: [] },
|
||||
};
|
||||
const host = await insertL4Host({ meta: JSON.stringify(meta) });
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
const parsed = JSON.parse(row!.meta!);
|
||||
expect(parsed.load_balancer.policy).toBe('round_robin');
|
||||
expect(parsed.geoblock.block_countries).toEqual(['CN']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Update
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('l4-proxy-hosts update', () => {
|
||||
it('updates listen address', async () => {
|
||||
const host = await insertL4Host({ listenAddress: ':5432' });
|
||||
await db.update(l4ProxyHosts).set({ listenAddress: ':3306' }).where(eq(l4ProxyHosts.id, host.id));
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(row!.listenAddress).toBe(':3306');
|
||||
});
|
||||
|
||||
it('updates protocol from tcp to udp', async () => {
|
||||
const host = await insertL4Host({ protocol: 'tcp' });
|
||||
await db.update(l4ProxyHosts).set({ protocol: 'udp' }).where(eq(l4ProxyHosts.id, host.id));
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(row!.protocol).toBe('udp');
|
||||
});
|
||||
|
||||
it('toggles enabled state', async () => {
|
||||
const host = await insertL4Host({ enabled: true });
|
||||
await db.update(l4ProxyHosts).set({ enabled: false }).where(eq(l4ProxyHosts.id, host.id));
|
||||
const row = await db.query.l4ProxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) });
|
||||
expect(Boolean(row!.enabled)).toBe(false);
|
||||
});
|
||||
});
|
||||
177
tests/unit/l4-port-manager-entrypoint.test.ts
Normal file
177
tests/unit/l4-port-manager-entrypoint.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Unit tests for the L4 port manager sidecar entrypoint script.
|
||||
*
|
||||
* Tests critical invariants of the shell script:
|
||||
* - Always applies the override on startup (not just on trigger change)
|
||||
* - Only recreates the caddy service (never other services)
|
||||
* - Uses --no-deps to prevent dependency cascades
|
||||
* - Auto-detects compose project name from caddy container labels
|
||||
* - Pre-loads LAST_TRIGGER to avoid double-applying on startup
|
||||
* - Writes status files in valid JSON
|
||||
* - Never includes test override files in production
|
||||
* - Supports both named-volume and bind-mount deployments (COMPOSE_HOST_DIR)
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const SCRIPT_PATH = resolve(__dirname, '../../docker/l4-port-manager/entrypoint.sh');
|
||||
const script = readFileSync(SCRIPT_PATH, 'utf-8');
|
||||
const lines = script.split('\n');
|
||||
|
||||
describe('L4 port manager entrypoint.sh', () => {
|
||||
it('applies override on startup (not only on trigger change)', () => {
|
||||
// The script must call do_apply before entering the while loop.
|
||||
// This ensures L4 ports are bound after any restart, because the main
|
||||
// compose stack starts caddy without the L4 ports override file.
|
||||
const firstApply = lines.findIndex(l => l.trim().startsWith('do_apply') || l.includes('do_apply'));
|
||||
const whileLoop = lines.findIndex(l => l.includes('while true'));
|
||||
expect(firstApply).toBeGreaterThan(-1);
|
||||
expect(whileLoop).toBeGreaterThan(-1);
|
||||
expect(firstApply).toBeLessThan(whileLoop);
|
||||
});
|
||||
|
||||
it('pre-loads LAST_TRIGGER after startup apply to avoid double-apply', () => {
|
||||
// After the startup apply, LAST_TRIGGER must be set from the current trigger
|
||||
// file content so the poll loop doesn't re-apply the same trigger again.
|
||||
const lastTriggerInit = lines.findIndex(l => l.includes('LAST_TRIGGER=') && l.includes('TRIGGER_FILE'));
|
||||
const whileLoop = lines.findIndex(l => l.includes('while true'));
|
||||
expect(lastTriggerInit).toBeGreaterThan(-1);
|
||||
expect(lastTriggerInit).toBeLessThan(whileLoop);
|
||||
});
|
||||
|
||||
it('only recreates the caddy service', () => {
|
||||
// The docker compose command should target only "caddy" — never "web" or other services
|
||||
const composeUpLines = lines.filter(line =>
|
||||
line.includes('docker compose') && line.includes('up')
|
||||
);
|
||||
expect(composeUpLines.length).toBeGreaterThan(0);
|
||||
for (const line of composeUpLines) {
|
||||
expect(line).toContain('caddy');
|
||||
expect(line).not.toMatch(/\bweb\b/);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses --no-deps flag to prevent dependency cascades', () => {
|
||||
const composeUpLines = lines.filter(line =>
|
||||
line.includes('docker compose') && line.includes('up')
|
||||
);
|
||||
for (const line of composeUpLines) {
|
||||
expect(line).toContain('--no-deps');
|
||||
}
|
||||
});
|
||||
|
||||
it('uses --force-recreate to ensure port changes take effect', () => {
|
||||
const composeUpLines = lines.filter(line =>
|
||||
line.includes('docker compose') && line.includes('up')
|
||||
);
|
||||
for (const line of composeUpLines) {
|
||||
expect(line).toContain('--force-recreate');
|
||||
}
|
||||
});
|
||||
|
||||
it('specifies project name to target the correct compose stack', () => {
|
||||
// Without -p, compose would infer the project from the mount directory name
|
||||
// ("/compose") rather than the actual running stack name, causing it to
|
||||
// create new containers instead of recreating the existing ones.
|
||||
expect(script).toMatch(/COMPOSE_ARGS=.*-p \$COMPOSE_PROJECT/);
|
||||
});
|
||||
|
||||
it('auto-detects project name from caddy container labels', () => {
|
||||
expect(script).toContain('com.docker.compose.project');
|
||||
expect(script).toContain('docker inspect');
|
||||
expect(script).toContain('detect_project_name');
|
||||
});
|
||||
|
||||
it('compares trigger content to avoid redundant restarts', () => {
|
||||
expect(script).toContain('LAST_TRIGGER');
|
||||
expect(script).toContain('CURRENT_TRIGGER');
|
||||
expect(script).toContain('"$CURRENT_TRIGGER" = "$LAST_TRIGGER"');
|
||||
});
|
||||
|
||||
it('does not pull images (only recreates)', () => {
|
||||
const composeUpLines = lines.filter(line =>
|
||||
line.includes('docker compose') && line.includes('up')
|
||||
);
|
||||
for (const line of composeUpLines) {
|
||||
expect(line).not.toContain('--pull');
|
||||
expect(line).not.toContain('--build');
|
||||
}
|
||||
});
|
||||
|
||||
it('waits for caddy health check after recreation', () => {
|
||||
expect(script).toContain('Health');
|
||||
expect(script).toContain('healthy');
|
||||
expect(script).toContain('HEALTH_TIMEOUT');
|
||||
});
|
||||
|
||||
it('writes status for both success and failure cases', () => {
|
||||
const statusWrites = lines.filter(l => l.trim().startsWith('write_status'));
|
||||
// At least: startup idle/applying, applying, applied/success, failed
|
||||
expect(statusWrites.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('does not include test override files in production', () => {
|
||||
// Including docker-compose.test.yml would override web env vars (triggering
|
||||
// web restart) and switch to test volume names.
|
||||
expect(script).not.toContain('docker-compose.test.yml');
|
||||
});
|
||||
|
||||
it('does not restart the web service or itself', () => {
|
||||
const dangerousPatterns = [
|
||||
/up.*\bweb\b/,
|
||||
/restart.*\bweb\b/,
|
||||
/up.*\bl4-port-manager\b/,
|
||||
/restart.*\bl4-port-manager\b/,
|
||||
];
|
||||
for (const pattern of dangerousPatterns) {
|
||||
expect(script).not.toMatch(pattern);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deployment scenario: COMPOSE_HOST_DIR (bind-mount / cloud override)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('uses --project-directory $COMPOSE_HOST_DIR when COMPOSE_HOST_DIR is set', () => {
|
||||
// Bind-mount deployments (docker-compose.override.yml replaces named volumes
|
||||
// with ./data bind mounts). Relative paths like ./geoip-data in the override
|
||||
// file must resolve against the HOST project directory, not the sidecar's
|
||||
// /compose mount. --project-directory tells the Docker daemon where to look.
|
||||
expect(script).toContain('--project-directory $COMPOSE_HOST_DIR');
|
||||
// It must be conditional — only applied when COMPOSE_HOST_DIR is non-empty
|
||||
expect(script).toMatch(/if \[ -n "\$COMPOSE_HOST_DIR" \]/);
|
||||
});
|
||||
|
||||
it('does NOT unconditionally add --project-directory (named-volume deployments work without it)', () => {
|
||||
// Standard deployments (no override file) use named volumes — no host path
|
||||
// is needed. --project-directory must NOT be hardcoded outside the conditional.
|
||||
const unconditional = lines.filter(l =>
|
||||
l.includes('--project-directory') && !l.includes('COMPOSE_HOST_DIR') && !l.trim().startsWith('#')
|
||||
);
|
||||
expect(unconditional).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('uses --env-file from $COMPOSE_DIR (container-accessible path), not $COMPOSE_HOST_DIR', () => {
|
||||
// When --project-directory points to the host path, Docker Compose looks for
|
||||
// .env at $COMPOSE_HOST_DIR/.env which is NOT mounted inside the container.
|
||||
// We must explicitly pass --env-file $COMPOSE_DIR/.env (the container mount).
|
||||
expect(script).toContain('--env-file $COMPOSE_DIR/.env');
|
||||
// Must NOT reference the host dir for the env file
|
||||
expect(script).not.toContain('--env-file $COMPOSE_HOST_DIR');
|
||||
});
|
||||
|
||||
it('always reads compose files from $COMPOSE_DIR regardless of COMPOSE_HOST_DIR', () => {
|
||||
// The sidecar mounts the project at /compose (COMPOSE_DIR). Whether or not
|
||||
// COMPOSE_HOST_DIR is set, all -f flags must reference container-accessible
|
||||
// paths under $COMPOSE_DIR, never the host path.
|
||||
const composeFileFlags = lines.filter(l =>
|
||||
l.includes('-f ') && l.includes('docker-compose')
|
||||
);
|
||||
expect(composeFileFlags.length).toBeGreaterThan(0);
|
||||
for (const line of composeFileFlags) {
|
||||
expect(line).toContain('$COMPOSE_DIR');
|
||||
expect(line).not.toContain('$COMPOSE_HOST_DIR');
|
||||
}
|
||||
});
|
||||
});
|
||||
300
tests/unit/l4-proxy-hosts-validation.test.ts
Normal file
300
tests/unit/l4-proxy-hosts-validation.test.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Unit tests for L4 proxy host input validation.
|
||||
*
|
||||
* Tests the validation logic in the L4 proxy hosts model
|
||||
* without requiring a database connection.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { TestDb } from '../helpers/db';
|
||||
|
||||
// Mock db so the model module can be imported
|
||||
const ctx = vi.hoisted(() => ({ db: null as unknown as TestDb }));
|
||||
|
||||
vi.mock('../../src/lib/db', async () => {
|
||||
const { createTestDb } = await import('../helpers/db');
|
||||
const schemaModule = await import('../../src/lib/db/schema');
|
||||
ctx.db = createTestDb();
|
||||
return {
|
||||
default: ctx.db,
|
||||
schema: schemaModule,
|
||||
nowIso: () => new Date().toISOString(),
|
||||
toIso: (value: string | Date | null | undefined): string | null => {
|
||||
if (!value) return null;
|
||||
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../src/lib/caddy', () => ({
|
||||
applyCaddyConfig: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/lib/audit', () => ({
|
||||
logAuditEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
createL4ProxyHost,
|
||||
updateL4ProxyHost,
|
||||
type L4ProxyHostInput,
|
||||
} from '../../src/lib/models/l4-proxy-hosts';
|
||||
import * as schema from '../../src/lib/db/schema';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup: insert a test user so the FK constraint on ownerUserId is satisfied
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
beforeEach(async () => {
|
||||
await ctx.db.delete(schema.l4ProxyHosts);
|
||||
await ctx.db.delete(schema.users).catch(() => {});
|
||||
await ctx.db.insert(schema.users).values({
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
role: 'admin',
|
||||
provider: 'credentials',
|
||||
subject: 'test',
|
||||
status: 'active',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation tests via createL4ProxyHost (which calls validateL4Input)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('L4 proxy host create validation', () => {
|
||||
it('rejects empty name', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: '',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':5432',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Name is required');
|
||||
});
|
||||
|
||||
it('rejects invalid protocol', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'sctp' as any,
|
||||
listen_address: ':5432',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow("Protocol must be 'tcp' or 'udp'");
|
||||
});
|
||||
|
||||
it('rejects empty listen address', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'tcp',
|
||||
listen_address: '',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Listen address is required');
|
||||
});
|
||||
|
||||
it('rejects listen address without port', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'tcp',
|
||||
listen_address: '10.0.0.1',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow("Listen address must be in format ':PORT' or 'HOST:PORT'");
|
||||
});
|
||||
|
||||
it('rejects listen address with port 0', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':0',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Port must be between 1 and 65535');
|
||||
});
|
||||
|
||||
it('rejects listen address with port > 65535', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':99999',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Port must be between 1 and 65535');
|
||||
});
|
||||
|
||||
it('rejects empty upstreams', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':5432',
|
||||
upstreams: [],
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('At least one upstream must be specified');
|
||||
});
|
||||
|
||||
it('rejects upstream without port', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':5432',
|
||||
upstreams: ['10.0.0.1'],
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow("must be in 'host:port' format");
|
||||
});
|
||||
|
||||
it('rejects TLS termination with UDP', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'udp',
|
||||
listen_address: ':5353',
|
||||
upstreams: ['8.8.8.8:53'],
|
||||
tls_termination: true,
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('TLS termination is only supported with TCP');
|
||||
});
|
||||
|
||||
it('rejects TLS SNI matcher without values', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':5432',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
matcher_type: 'tls_sni',
|
||||
matcher_value: [],
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Matcher value is required');
|
||||
});
|
||||
|
||||
it('rejects HTTP host matcher without values', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':8080',
|
||||
upstreams: ['10.0.0.1:8080'],
|
||||
matcher_type: 'http_host',
|
||||
matcher_value: [],
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Matcher value is required');
|
||||
});
|
||||
|
||||
it('rejects invalid proxy protocol version', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':5432',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
proxy_protocol_version: 'v3' as any,
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow("Proxy protocol version must be 'v1' or 'v2'");
|
||||
});
|
||||
|
||||
it('rejects invalid matcher type', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':5432',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
matcher_type: 'invalid' as any,
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Matcher type must be one of');
|
||||
});
|
||||
|
||||
it('accepts valid TCP proxy with all options', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Full Featured',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':993',
|
||||
upstreams: ['localhost:143'],
|
||||
matcher_type: 'tls_sni',
|
||||
matcher_value: ['mail.example.com'],
|
||||
tls_termination: true,
|
||||
proxy_protocol_version: 'v1',
|
||||
proxy_protocol_receive: true,
|
||||
enabled: true,
|
||||
};
|
||||
const result = await createL4ProxyHost(input, 1);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.name).toBe('Full Featured');
|
||||
expect(result.protocol).toBe('tcp');
|
||||
expect(result.listen_address).toBe(':993');
|
||||
expect(result.upstreams).toEqual(['localhost:143']);
|
||||
expect(result.matcher_type).toBe('tls_sni');
|
||||
expect(result.matcher_value).toEqual(['mail.example.com']);
|
||||
expect(result.tls_termination).toBe(true);
|
||||
expect(result.proxy_protocol_version).toBe('v1');
|
||||
expect(result.proxy_protocol_receive).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts valid UDP proxy', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'DNS',
|
||||
protocol: 'udp',
|
||||
listen_address: ':5353',
|
||||
upstreams: ['8.8.8.8:53'],
|
||||
};
|
||||
const result = await createL4ProxyHost(input, 1);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.protocol).toBe('udp');
|
||||
});
|
||||
|
||||
it('accepts host:port format for listen address', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Bound',
|
||||
protocol: 'tcp',
|
||||
listen_address: '0.0.0.0:5432',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
};
|
||||
const result = await createL4ProxyHost(input, 1);
|
||||
expect(result.listen_address).toBe('0.0.0.0:5432');
|
||||
});
|
||||
|
||||
it('accepts none matcher without matcher_value', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Catch All',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':5432',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
matcher_type: 'none',
|
||||
};
|
||||
const result = await createL4ProxyHost(input, 1);
|
||||
expect(result.matcher_type).toBe('none');
|
||||
});
|
||||
|
||||
it('accepts proxy_protocol matcher without matcher_value', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'PP Detect',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':8443',
|
||||
upstreams: ['10.0.0.1:443'],
|
||||
matcher_type: 'proxy_protocol',
|
||||
};
|
||||
const result = await createL4ProxyHost(input, 1);
|
||||
expect(result.matcher_type).toBe('proxy_protocol');
|
||||
});
|
||||
|
||||
it('trims whitespace from name and listen_address', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: ' Spacey Name ',
|
||||
protocol: 'tcp',
|
||||
listen_address: ' :5432 ',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
};
|
||||
const result = await createL4ProxyHost(input, 1);
|
||||
expect(result.name).toBe('Spacey Name');
|
||||
expect(result.listen_address).toBe(':5432');
|
||||
});
|
||||
|
||||
it('deduplicates upstreams', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Dedup',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':5432',
|
||||
upstreams: ['10.0.0.1:5432', '10.0.0.1:5432', '10.0.0.2:5432'],
|
||||
};
|
||||
const result = await createL4ProxyHost(input, 1);
|
||||
expect(result.upstreams).toEqual(['10.0.0.1:5432', '10.0.0.2:5432']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user