diff --git a/app/(dashboard)/l4-proxy-hosts/L4ProxyHostsClient.tsx b/app/(dashboard)/l4-proxy-hosts/L4ProxyHostsClient.tsx index 70905eda..874fdd86 100644 --- a/app/(dashboard)/l4-proxy-hosts/L4ProxyHostsClient.tsx +++ b/app/(dashboard)/l4-proxy-hosts/L4ProxyHostsClient.tsx @@ -39,12 +39,15 @@ export default function L4ProxyHostsClient({ hosts, pagination, initialSearch }: const [editHost, setEditHost] = useState(null); const [deleteHost, setDeleteHost] = useState(null); const [searchTerm, setSearchTerm] = useState(initialSearch); + const [bannerRefresh, setBannerRefresh] = useState(0); const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); const debounceRef = useRef | null>(null); + const signalBannerRefresh = () => setBannerRefresh(n => n + 1); + useEffect(() => { setSearchTerm(initialSearch); }, [initialSearch]); @@ -66,6 +69,7 @@ export default function L4ProxyHostsClient({ hosts, pagination, initialSearch }: const handleToggleEnabled = async (id: number, enabled: boolean) => { await toggleL4ProxyHostAction(id, enabled); + signalBannerRefresh(); }; const columns = [ @@ -215,7 +219,7 @@ export default function L4ProxyHostsClient({ hosts, pagination, initialSearch }: return ( - + { setCreateOpen(false); setTimeout(() => setDuplicateHost(null), 200); + signalBannerRefresh(); }} initialData={duplicateHost} /> @@ -253,7 +258,10 @@ export default function L4ProxyHostsClient({ hosts, pagination, initialSearch }: setEditHost(null)} + onClose={() => { + setEditHost(null); + signalBannerRefresh(); + }} /> )} @@ -261,7 +269,10 @@ export default function L4ProxyHostsClient({ hosts, pagination, initialSearch }: setDeleteHost(null)} + onClose={() => { + setDeleteHost(null); + signalBannerRefresh(); + }} /> )} diff --git a/app/api/instances/sync/route.ts b/app/api/instances/sync/route.ts index d0a21fe9..84fb8ccc 100644 --- a/app/api/instances/sync/route.ts +++ b/app/api/instances/sync/route.ts @@ -180,6 +180,27 @@ function isProxyHost(value: unknown): value is SyncPayload["data"]["proxyHosts"] ); } +function isL4ProxyHost(value: unknown): value is SyncPayload["data"]["l4ProxyHosts"][number] { + if (!isRecord(value)) return false; + return ( + isNumber(value.id) && + isString(value.name) && + isString(value.protocol) && + isString(value.listenAddress) && + isString(value.upstreams) && + isString(value.matcherType) && + isNullableString(value.matcherValue) && + isBoolean(value.tlsTermination) && + isNullableString(value.proxyProtocolVersion) && + isBoolean(value.proxyProtocolReceive) && + isNullableNumber(value.ownerUserId) && + isNullableString(value.meta) && + isBoolean(value.enabled) && + isString(value.createdAt) && + isString(value.updatedAt) + ); +} + /** * Validates that the payload has the expected structure for syncing */ @@ -212,6 +233,11 @@ function isValidSyncPayload(payload: unknown): payload is SyncPayload { const d = data as Record; + // l4ProxyHosts is optional for backward compatibility with older master instances + if (d.l4ProxyHosts !== undefined && !validateArray(d.l4ProxyHosts, isL4ProxyHost)) { + return false; + } + return ( validateArray(d.certificates, isCertificate) && validateArray(d.caCertificates, isCaCertificate) && @@ -265,7 +291,15 @@ export async function POST(request: NextRequest) { } try { - await applySyncPayload(payload); + // Backfill l4ProxyHosts for payloads from older master instances that don't include it + const normalizedPayload: SyncPayload = { + ...payload, + data: { + ...payload.data, + l4ProxyHosts: payload.data.l4ProxyHosts ?? [], + }, + }; + await applySyncPayload(normalizedPayload); await applyCaddyConfig(); await setSlaveLastSync({ ok: true }); return NextResponse.json({ ok: true }); diff --git a/src/components/l4-proxy-hosts/L4PortsApplyBanner.tsx b/src/components/l4-proxy-hosts/L4PortsApplyBanner.tsx index 44e88b25..a14d0a03 100644 --- a/src/components/l4-proxy-hosts/L4PortsApplyBanner.tsx +++ b/src/components/l4-proxy-hosts/L4PortsApplyBanner.tsx @@ -25,7 +25,7 @@ type PortsResponse = { error?: string; }; -export function L4PortsApplyBanner() { +export function L4PortsApplyBanner({ refreshSignal }: { refreshSignal?: number }) { const [data, setData] = useState(null); const [applying, setApplying] = useState(false); const [polling, setPolling] = useState(false); @@ -41,11 +41,17 @@ export function L4PortsApplyBanner() { } }, []); - // Initial fetch and poll when pending/applying + // Initial fetch on mount useEffect(() => { fetchStatus(); }, [fetchStatus]); + // Re-fetch when the parent signals a mutation (create/edit/delete/toggle) + useEffect(() => { + if (!refreshSignal) return; + fetchStatus(); + }, [refreshSignal, fetchStatus]); + useEffect(() => { if (!data) return; const shouldPoll = data.status.state === "pending" || data.status.state === "applying"; diff --git a/src/lib/instance-sync.ts b/src/lib/instance-sync.ts index 3d4e5950..6fd72d90 100644 --- a/src/lib/instance-sync.ts +++ b/src/lib/instance-sync.ts @@ -1,8 +1,9 @@ import db, { nowIso } from "./db"; -import { accessListEntries, accessLists, caCertificates, certificates, issuedClientCertificates, proxyHosts } from "./db/schema"; +import { accessListEntries, accessLists, caCertificates, certificates, issuedClientCertificates, l4ProxyHosts, proxyHosts } from "./db/schema"; import { getSetting, setSetting } from "./settings"; import { recordInstanceSyncResult, updateInstance } from "./models/instances"; import { decryptSecret, encryptSecret, isEncryptedSecret } from "./secret"; +import { applyL4Ports, getL4PortsDiff } from "./l4-ports"; export type InstanceMode = "standalone" | "master" | "slave"; @@ -28,6 +29,8 @@ export type SyncPayload = { accessLists: Array; accessListEntries: Array; proxyHosts: Array; + /** Optional — not present in payloads from older master instances */ + l4ProxyHosts?: Array; }; }; @@ -233,13 +236,14 @@ export async function clearSyncedSetting(key: string): Promise { } export async function buildSyncPayload(): Promise { - const [certRows, caCertRows, issuedClientCertRows, accessListRows, accessEntryRows, proxyRows] = await Promise.all([ + const [certRows, caCertRows, issuedClientCertRows, accessListRows, accessEntryRows, proxyRows, l4Rows] = await Promise.all([ db.select().from(certificates), db.select().from(caCertificates), db.select().from(issuedClientCertificates), db.select().from(accessLists), db.select().from(accessListEntries), - db.select().from(proxyHosts) + db.select().from(proxyHosts), + db.select().from(l4ProxyHosts), ]); const settings = { @@ -279,6 +283,11 @@ export async function buildSyncPayload(): Promise { ownerUserId: null })); + const sanitizedL4ProxyHosts = l4Rows.map((row) => ({ + ...row, + ownerUserId: null + })); + return { generated_at: nowIso(), settings, @@ -288,7 +297,8 @@ export async function buildSyncPayload(): Promise { issuedClientCertificates: sanitizedIssuedClientCertificates, accessLists: sanitizedAccessLists, accessListEntries: accessEntryRows, - proxyHosts: sanitizedProxyHosts + proxyHosts: sanitizedProxyHosts, + l4ProxyHosts: sanitizedL4ProxyHosts, } }; } @@ -422,6 +432,7 @@ export async function applySyncPayload(payload: SyncPayload) { // better-sqlite3 is synchronous, so transaction callback must be synchronous db.transaction((tx) => { + tx.delete(l4ProxyHosts).run(); tx.delete(proxyHosts).run(); tx.delete(accessListEntries).run(); tx.delete(accessLists).run(); @@ -447,5 +458,15 @@ export async function applySyncPayload(payload: SyncPayload) { if (payload.data.proxyHosts.length > 0) { tx.insert(proxyHosts).values(payload.data.proxyHosts).run(); } + if (payload.data.l4ProxyHosts && payload.data.l4ProxyHosts.length > 0) { + tx.insert(l4ProxyHosts).values(payload.data.l4ProxyHosts).run(); + } }); + + // If the synced L4 proxy hosts require different ports than currently applied, + // write the override file and trigger the sidecar to recreate the caddy container. + const diff = await getL4PortsDiff(); + if (diff.needsApply) { + await applyL4Ports(); + } } diff --git a/tests/integration/instance-sync.test.ts b/tests/integration/instance-sync.test.ts index fc2a7424..066433ee 100644 --- a/tests/integration/instance-sync.test.ts +++ b/tests/integration/instance-sync.test.ts @@ -7,6 +7,8 @@ * any real db file. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { rmSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; import type { TestDb } from '../helpers/db'; // --------------------------------------------------------------------------- @@ -15,7 +17,15 @@ import type { TestDb } from '../helpers/db'; // factory (which also runs during hoisting) can populate it safely. // --------------------------------------------------------------------------- -const ctx = vi.hoisted(() => ({ db: null as unknown as TestDb })); +const ctx = vi.hoisted(() => { + const { mkdirSync } = require('node:fs'); + const { join } = require('node:path'); + const { tmpdir } = require('node:os'); + const dir = join(tmpdir(), `instance-sync-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'); @@ -70,6 +80,7 @@ function makeProxyHost(overrides: Partial /** Clean all relevant tables between tests to avoid cross-test contamination. */ async function clearTables() { + await ctx.db.delete(schema.l4ProxyHosts); await ctx.db.delete(schema.proxyHosts); await ctx.db.delete(schema.accessListEntries); await ctx.db.delete(schema.accessLists); @@ -79,8 +90,37 @@ async function clearTables() { await ctx.db.delete(schema.settings); } +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); + } +} + +function makeL4Host(overrides: Partial = {}) { + const now = nowIso(); + return { + name: 'Test L4 Host', + protocol: 'tcp', + listenAddress: ':5432', + upstreams: JSON.stringify(['10.0.0.1:5432']), + matcherType: 'none', + matcherValue: null, + tlsTermination: false, + proxyProtocolVersion: null, + proxyProtocolReceive: false, + ownerUserId: null, + meta: null, + enabled: true, + createdAt: now, + updatedAt: now, + ...overrides, + } satisfies typeof schema.l4ProxyHosts.$inferInsert; +} + beforeEach(async () => { await clearTables(); + cleanTmpDir(); }); // --------------------------------------------------------------------------- @@ -96,6 +136,29 @@ describe('buildSyncPayload', () => { expect(payload.data.issuedClientCertificates).toEqual([]); expect(payload.data.accessLists).toEqual([]); expect(payload.data.accessListEntries).toEqual([]); + expect(payload.data.l4ProxyHosts).toEqual([]); + }); + + it('includes L4 proxy hosts in payload', async () => { + await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({ listenAddress: ':5432' })); + const payload = await buildSyncPayload(); + expect(payload.data.l4ProxyHosts).toHaveLength(1); + expect(payload.data.l4ProxyHosts![0].listenAddress).toBe(':5432'); + }); + + it('sanitizes L4 proxy host ownerUserId to null', async () => { + await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({ ownerUserId: null })); + const payload = await buildSyncPayload(); + expect(payload.data.l4ProxyHosts![0].ownerUserId).toBeNull(); + }); + + it('includes multiple L4 proxy hosts', async () => { + 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 payload = await buildSyncPayload(); + expect(payload.data.l4ProxyHosts).toHaveLength(2); + const addresses = payload.data.l4ProxyHosts!.map(h => h.listenAddress).sort(); + expect(addresses).toEqual([':3306', ':5432']); }); it('returns null settings when no settings are stored', async () => { @@ -383,4 +446,163 @@ describe('applySyncPayload', () => { expect(entries).toHaveLength(1); expect(entries[0].username).toBe('synceduser'); }); + + // --------------------------------------------------------------------------- + // L4 proxy host replication + // --------------------------------------------------------------------------- + + it('clears existing L4 proxy hosts when payload has empty array', async () => { + await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({ name: 'Old L4 Host' })); + const before = await ctx.db.select().from(schema.l4ProxyHosts); + expect(before).toHaveLength(1); + + await applySyncPayload(emptyPayload()); + + const after = await ctx.db.select().from(schema.l4ProxyHosts); + expect(after).toHaveLength(0); + }); + + it('inserts L4 proxy hosts from payload', async () => { + const now = nowIso(); + const payload = emptyPayload(); + payload.data.l4ProxyHosts = [ + { + id: 1, + name: 'Synced PG', + protocol: 'tcp', + listenAddress: ':5432', + upstreams: JSON.stringify(['db:5432']), + matcherType: 'none', + matcherValue: null, + tlsTermination: false, + proxyProtocolVersion: null, + proxyProtocolReceive: false, + ownerUserId: null, + meta: null, + enabled: true, + createdAt: now, + updatedAt: now, + }, + ]; + + await applySyncPayload(payload); + + const rows = await ctx.db.select().from(schema.l4ProxyHosts); + expect(rows).toHaveLength(1); + expect(rows[0].name).toBe('Synced PG'); + expect(rows[0].listenAddress).toBe(':5432'); + }); + + it('replaces existing L4 proxy hosts with payload contents', async () => { + await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({ name: 'Old L4', listenAddress: ':9999' })); + + const now = nowIso(); + const payload = emptyPayload(); + payload.data.l4ProxyHosts = [ + { + id: 99, + name: 'New L4', + protocol: 'tcp', + listenAddress: ':5432', + upstreams: JSON.stringify(['db:5432']), + matcherType: 'none', + matcherValue: null, + tlsTermination: false, + proxyProtocolVersion: null, + proxyProtocolReceive: false, + ownerUserId: null, + meta: null, + enabled: true, + createdAt: now, + updatedAt: now, + }, + ]; + + await applySyncPayload(payload); + + const rows = await ctx.db.select().from(schema.l4ProxyHosts); + expect(rows).toHaveLength(1); + expect(rows[0].name).toBe('New L4'); + expect(rows[0].listenAddress).toBe(':5432'); + }); + + it('works with payload missing l4ProxyHosts (backward compat with old master)', async () => { + // Old master instances don't include l4ProxyHosts in their payload. + // The slave should still sync successfully and not crash. + await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({ name: 'Existing L4' })); + + const payload = emptyPayload(); + // Explicitly remove l4ProxyHosts to simulate old master payload + delete (payload.data as Record).l4ProxyHosts; + + await expect(applySyncPayload(payload)).resolves.toBeUndefined(); + + // Existing L4 hosts are cleared (the DELETE always runs) + const rows = await ctx.db.select().from(schema.l4ProxyHosts); + expect(rows).toHaveLength(0); + }); + + it('writes trigger file when L4 port diff requires apply after sync', async () => { + const now = nowIso(); + const payload = emptyPayload(); + payload.data.l4ProxyHosts = [ + { + id: 1, + name: 'PG Sync', + protocol: 'tcp', + listenAddress: ':5432', + upstreams: JSON.stringify(['db:5432']), + matcherType: 'none', + matcherValue: null, + tlsTermination: false, + proxyProtocolVersion: null, + proxyProtocolReceive: false, + ownerUserId: null, + meta: null, + enabled: true, + createdAt: now, + updatedAt: now, + }, + ]; + + // No override file exists yet → diff will show needsApply=true + await applySyncPayload(payload); + + const triggerPath = join(ctx.tmpDir, 'l4-ports.trigger'); + expect(existsSync(triggerPath)).toBe(true); + }); + + it('does not write trigger file when L4 ports already match after sync', async () => { + const { writeFileSync } = await import('node:fs'); + // Pre-write override file matching the incoming payload port + writeFileSync(join(ctx.tmpDir, 'docker-compose.l4-ports.yml'), `services:\n caddy:\n ports:\n - "5432:5432"\n`); + + const now = nowIso(); + const payload = emptyPayload(); + payload.data.l4ProxyHosts = [ + { + id: 1, + name: 'PG Sync', + protocol: 'tcp', + listenAddress: ':5432', + upstreams: JSON.stringify(['db:5432']), + matcherType: 'none', + matcherValue: null, + tlsTermination: false, + proxyProtocolVersion: null, + proxyProtocolReceive: false, + ownerUserId: null, + meta: null, + enabled: true, + createdAt: now, + updatedAt: now, + }, + ]; + + await applySyncPayload(payload); + + // Ports already match → no trigger needed + const triggerPath = join(ctx.tmpDir, 'l4-ports.trigger'); + expect(existsSync(triggerPath)).toBe(false); + }); }); diff --git a/tests/unit/l4-ports-apply-banner.test.ts b/tests/unit/l4-ports-apply-banner.test.ts new file mode 100644 index 00000000..e065167d --- /dev/null +++ b/tests/unit/l4-ports-apply-banner.test.ts @@ -0,0 +1,73 @@ +/** + * Unit tests for the L4PortsApplyBanner refresh-signal contract. + * + * Verifies that the banner component re-fetches port status whenever + * refreshSignal changes — so changes are reflected immediately after + * create/edit/delete/toggle without a page reload. + * + * These tests inspect the component source rather than rendering it, + * to avoid the cost of a jsdom environment. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const BANNER_PATH = resolve(__dirname, '../../src/components/l4-proxy-hosts/L4PortsApplyBanner.tsx'); +const banner = readFileSync(BANNER_PATH, 'utf-8'); + +const CLIENT_PATH = resolve(__dirname, '../../app/(dashboard)/l4-proxy-hosts/L4ProxyHostsClient.tsx'); +const client = readFileSync(CLIENT_PATH, 'utf-8'); + +describe('L4PortsApplyBanner', () => { + it('accepts a refreshSignal prop', () => { + expect(banner).toContain('refreshSignal'); + }); + + it('re-fetches when refreshSignal changes via useEffect', () => { + // Must have a useEffect that depends on refreshSignal + expect(banner).toMatch(/useEffect\s*\(\s*\(\s*\)\s*=>/); + expect(banner).toContain('refreshSignal'); + // The effect must call fetchStatus + expect(banner).toContain('fetchStatus'); + }); + + it('skips fetch when refreshSignal is falsy (avoids double-fetch on mount)', () => { + // The effect should guard against firing on initial 0/undefined value + // so the mount effect and the signal effect don't both fire on load. + expect(banner).toMatch(/if\s*\(!\s*refreshSignal\s*\)/); + }); +}); + +describe('L4ProxyHostsClient banner integration', () => { + it('tracks bannerRefresh state', () => { + expect(client).toContain('bannerRefresh'); + expect(client).toContain('setBannerRefresh'); + }); + + it('passes refreshSignal to L4PortsApplyBanner', () => { + expect(client).toContain('refreshSignal={bannerRefresh}'); + }); + + it('increments bannerRefresh after toggle', () => { + // The toggle handler must signal the banner after the action completes + expect(client).toMatch(/toggleL4ProxyHostAction[\s\S]{0,200}signalBannerRefresh/); + }); + + it('increments bannerRefresh when create dialog closes', () => { + expect(client).toMatch(/CreateL4HostDialog[\s\S]{0,400}signalBannerRefresh/); + }); + + it('increments bannerRefresh when edit dialog closes', () => { + expect(client).toMatch(/EditL4HostDialog[\s\S]{0,400}signalBannerRefresh/); + }); + + it('increments bannerRefresh when delete dialog closes', () => { + expect(client).toMatch(/DeleteL4HostDialog[\s\S]{0,400}signalBannerRefresh/); + }); + + it('defines signalBannerRefresh as a function that increments the counter', () => { + // Must use functional update form to avoid stale closure + expect(client).toMatch(/signalBannerRefresh\s*=\s*\(\s*\)\s*=>\s*setBannerRefresh\s*\(/); + expect(client).toContain('n + 1'); + }); +});