/** * Tests for firewall chain building in capability loader * * Tests the buildFirewallChain logic that wires iptables → greenwave. */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdirSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { HookLogger } from '@celilo/capabilities'; import type { DbClient } from '../db/client'; import { upsertModuleConfig } from '../services/module-config'; import { cleanupTestDatabase, setupTestDatabase } from '../test-utils/database'; import { loadCapabilityFunctions } from './capability-loader'; const noopLogger: HookLogger = { info() {}, warn() {}, error() {}, success() {}, }; // Mock greenwave firewall factory using defineCapabilityFunction (the // post-Phase-8 pattern). The compiled factory takes a single // `{ config, secrets, logger }` context and the framework wraps the // returned method table with auto-logging. // // We use an absolute import path to the workspace package because the // temp dir where the mock is dropped has no node_modules link to // resolve the bare `@celilo/capabilities` specifier. const CAPABILITIES_PKG_PATH = join(__dirname, '../../../..', 'packages/capabilities/src/index.ts'); const MOCK_GREENWAVE_MODULE = ` import { defineCapabilityFunction } from '${CAPABILITIES_PKG_PATH}'; export default defineCapabilityFunction({ capability: 'firewall', handler: ({ config, secrets }) => ({ exposeService: async (opts) => ({ externalIp: '203.0.113.10', natIp: opts.internalIp, }), unexposeService: async () => {}, listExposedServices: async () => [], }), }); `; // Mock iptables firewall factory — kept on the legacy // `createFirewall(config, store, upstreamFirewall, logger)` shape because its // upstream-injection signature doesn't fit the // defineCapabilityFunction `{ config, secrets, logger }` context. // buildFirewallChain injects the port-forward store (2nd arg) and wires the // upstream (3rd arg) manually. The mock ignores store (no converge here). const MOCK_IPTABLES_MODULE = ` export function createFirewall(config, store, upstreamFirewall, logger) { return { exposeService: async (opts) => { if (!upstreamFirewall) { throw new Error('No upstream firewall'); } // Delegate upstream first (with NAT IP) const upstream = await upstreamFirewall.exposeService({ ...opts, internalIp: config.natIp, }); // Then "create" local rules (just tracking for test) return { externalIp: upstream.externalIp, natIp: config.natIp, localRulesCreated: true, originalIp: opts.internalIp, }; }, unexposeService: async () => {}, listExposedServices: async () => [], }; } `; /** * Reports back the FirewallConfig it was handed, so a test can assert on what * the loader actually forwarded rather than on a downstream effect. * * The real iptables module reads `defaultRouteZone` and `isolateTransitNetwork` * off this object and nothing else in celilo constructs one. So if the loader * drops a field, the setting is simply dead: the operator sets it, the config * row exists, `module config get` shows it, and no rule changes. That is what * happened to both of these, and it is invisible from every surface except the * rendered ruleset. */ const MOCK_CONFIG_REPORTER = ` export function createFirewall(config, store, upstreamFirewall, logger) { return { receivedConfig: () => config, exposeService: async (opts) => ({ externalIp: '203.0.113.10', natIp: config.natIp }), unexposeService: async () => {}, listExposedServices: async () => [], }; } `; describe('Firewall Chain Building', () => { let db: DbClient; let tempDir: string; beforeEach(async () => { db = await setupTestDatabase(); tempDir = join(tmpdir(), `celilo-fw-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); mkdirSync(tempDir, { recursive: true }); }); afterEach(async () => { await cleanupTestDatabase(db); }); test('loads single firewall provider (greenwave only)', async () => { const gwPath = join(tempDir, 'greenwave'); const gwScripts = join(gwPath, 'scripts'); mkdirSync(gwScripts, { recursive: true }); writeFileSync(join(gwScripts, 'firewall-functions.ts'), MOCK_GREENWAVE_MODULE); db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('greenwave', 'GreenWave', '1.0.0', '${gwPath}', '{}')`, ); db.$client.run( `INSERT INTO capabilities (module_id, capability_name, version, data, zones) VALUES ('greenwave', 'firewall', '1.0.0', '{"has_external":true}', '["internal"]')`, ); upsertModuleConfig(db, 'greenwave', 'router_ip', '192.168.0.1'); // Set up encrypted secrets const { encryptSecret } = await import('../secrets/encryption'); const { getOrCreateMasterKey } = await import('../secrets/master-key'); const masterKey = await getOrCreateMasterKey(); for (const [name, value] of [ ['router_username', 'admin'], ['router_password', 'test'], ]) { const enc = encryptSecret(value, masterKey); db.$client.run( `INSERT INTO secrets (module_id, name, encrypted_value, iv, auth_tag) VALUES ('greenwave', '${name}', '${enc.encryptedValue}', '${enc.iv}', '${enc.authTag}')`, ); } const result = await loadCapabilityFunctions('test-consumer', db, noopLogger); expect(result.firewall).toBeTruthy(); // Test the interface const fw = result.firewall as { exposeService: (opts: { internalIp: string; ports: number[]; description: string; }) => Promise<{ externalIp: string }>; }; const exposed = await fw.exposeService({ internalIp: '10.0.10.10', ports: [80], description: 'test', }); expect(exposed.externalIp).toBe('203.0.113.10'); }); test('builds chain with two providers (iptables → greenwave)', async () => { // Set up greenwave const gwPath = join(tempDir, 'greenwave'); const gwScripts = join(gwPath, 'scripts'); mkdirSync(gwScripts, { recursive: true }); writeFileSync(join(gwScripts, 'firewall-functions.ts'), MOCK_GREENWAVE_MODULE); db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('greenwave', 'GreenWave', '1.0.0', '${gwPath}', '{}')`, ); db.$client.run( `INSERT INTO capabilities (module_id, capability_name, version, data, zones) VALUES ('greenwave', 'firewall', '1.0.0', '{"has_external":true}', '["internal"]')`, ); upsertModuleConfig(db, 'greenwave', 'router_ip', '192.168.0.1'); const { encryptSecret } = await import('../secrets/encryption'); const { getOrCreateMasterKey } = await import('../secrets/master-key'); const masterKey = await getOrCreateMasterKey(); for (const [name, value] of [ ['router_username', 'admin'], ['router_password', 'test'], ]) { const enc = encryptSecret(value, masterKey); db.$client.run( `INSERT INTO secrets (module_id, name, encrypted_value, iv, auth_tag) VALUES ('greenwave', '${name}', '${enc.encryptedValue}', '${enc.iv}', '${enc.authTag}')`, ); } // Set up iptables const iptPath = join(tempDir, 'iptables'); const iptScripts = join(iptPath, 'scripts'); mkdirSync(iptScripts, { recursive: true }); writeFileSync(join(iptScripts, 'firewall-functions.ts'), MOCK_IPTABLES_MODULE); db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('iptables', 'iptables', '1.0.0', '${iptPath}', '{}')`, ); db.$client.run( `INSERT INTO capabilities (module_id, capability_name, version, data, zones) VALUES ('iptables', 'firewall', '1.0.0', '{}', '["dmz","app","secure"]')`, ); upsertModuleConfig(db, 'iptables', 'firewall_ip', '192.168.0.254'); upsertModuleConfig(db, 'iptables', 'nat_ip', '192.168.0.253'); upsertModuleConfig(db, 'iptables', 'upstream_zone', 'internal'); const result = await loadCapabilityFunctions('test-consumer', db, noopLogger); expect(result.firewall).toBeTruthy(); // Test the chain: iptables delegates to greenwave const fw = result.firewall as { exposeService: (opts: { internalIp: string; ports: number[]; description: string; }) => Promise<{ externalIp: string; natIp: string; localRulesCreated: boolean; originalIp: string; }>; }; const exposed = await fw.exposeService({ internalIp: '10.0.10.10', ports: [80], description: 'Caddy', }); // External IP came from greenwave (leaf) expect(exposed.externalIp).toBe('203.0.113.10'); // NAT IP is iptables' NAT address expect(exposed.natIp).toBe('192.168.0.253'); // iptables created local rules expect(exposed.localRulesCreated).toBe(true); // iptables tracked the original internal IP expect(exposed.originalIp).toBe('10.0.10.10'); }); test('returns null when no firewall has external interface', async () => { const iptPath = join(tempDir, 'iptables'); const iptScripts = join(iptPath, 'scripts'); mkdirSync(iptScripts, { recursive: true }); writeFileSync(join(iptScripts, 'firewall-functions.ts'), MOCK_IPTABLES_MODULE); db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('iptables', 'iptables', '1.0.0', '${iptPath}', '{}')`, ); // Note: has_external is NOT set db.$client.run( `INSERT INTO capabilities (module_id, capability_name, version, data, zones) VALUES ('iptables', 'firewall', '1.0.0', '{}', '["dmz"]')`, ); const _result = await loadCapabilityFunctions('test-consumer', db, noopLogger); // Single provider without has_external — still loads (just can't delegate) // But buildFirewallChain is only called with >1 providers // Single provider loads normally via the standard path }); describe('operator settings reach the module', () => { /** * Both settings are opt-in and both default to a no-op, which is exactly * why losing them is silent. A dropped `isolateTransitNetwork` renders the * ruleset celilo has always rendered, and a dropped `defaultRouteZone` * falls back to `internal`, which is the common case. Nothing errors, * nothing warns, and the only observable difference is a DROP that is * absent from a chain nobody reads. */ function installReporter(moduleId: string, capabilityData: string, zones: string) { const path = join(tempDir, moduleId); const scripts = join(path, 'scripts'); mkdirSync(scripts, { recursive: true }); writeFileSync(join(scripts, 'firewall-functions.ts'), MOCK_CONFIG_REPORTER); db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('${moduleId}', '${moduleId}', '1.0.0', '${path}', '{}')`, ); db.$client.run( `INSERT INTO capabilities (module_id, capability_name, version, data, zones) VALUES ('${moduleId}', 'firewall', '1.0.0', '${capabilityData}', '${zones}')`, ); } async function loadedConfig(): Promise> { const result = await loadCapabilityFunctions('test-consumer', db, noopLogger); const fw = result.firewall as { receivedConfig: () => Record }; expect(fw, 'no firewall capability loaded').toBeTruthy(); return fw.receivedConfig(); } test('single provider: default_route_zone and isolate_transit_network are forwarded', async () => { installReporter('iptables', '{"has_external":true}', '["dmz","app","secure"]'); upsertModuleConfig(db, 'iptables', 'firewall_ip', '192.168.0.254'); upsertModuleConfig(db, 'iptables', 'nat_ip', '192.168.0.253'); upsertModuleConfig(db, 'iptables', 'default_route_zone', 'isp-transit'); upsertModuleConfig(db, 'iptables', 'isolate_transit_network', true); upsertModuleConfig(db, 'iptables', 'interface_baseline', 'eth0,eth2'); upsertModuleConfig(db, 'iptables', 'interface_zone_map', 'eth0=isp-transit,eth2=internal'); const config = await loadedConfig(); expect(config.defaultRouteZone).toBe('isp-transit'); // A REAL boolean, not the string 'true'. `parseStoredConfigValue` // preserves the manifest-declared type, and the module tests // `state.isolateTransitNetwork && ...` — where 'false' would be truthy. expect(config.isolateTransitNetwork).toBe(true); expect(config.interfaceBaseline).toEqual(['eth0', 'eth2']); expect(config.interfaceZoneMap).toBe('eth0=isp-transit,eth2=internal'); }); test('single provider: the recorded interface_baseline is forwarded as a parsed list', async () => { // The recorded baseline is what turns an undeclared interface from a // refusal into an isolation. The single-provider iptables firewall (the // production shape: the ISP router is not a celilo module) is built by // buildCapabilityInterface, and THIS is the construction site that // dropped the field — the baseline was written to module config and // never read back, so the module converged in onboarding mode forever // (ce-qzxm). installReporter('iptables', '{"has_external":true}', '["dmz","app","secure"]'); upsertModuleConfig(db, 'iptables', 'firewall_ip', '192.168.0.254'); upsertModuleConfig(db, 'iptables', 'nat_ip', '192.168.0.253'); upsertModuleConfig(db, 'iptables', 'interface_baseline', 'eth0, eth1, eth2, eth3'); const config = await loadedConfig(); // A parsed array of NAMES, as the module's D5 split consumes it — not // the raw comma string, and not with the write-side padding intact. expect(config.interfaceBaseline).toEqual(['eth0', 'eth1', 'eth2', 'eth3']); }); test('single provider: an empty interface_baseline arrives undefined, meaning onboarding', async () => { // Empty and absent are the same thing, and both mean "never converged // cleanly": treating an empty string as a baseline of nothing would make // every interface new. The loader normalizes this so the module sees // one shape for one meaning. installReporter('iptables', '{"has_external":true}', '["dmz"]'); upsertModuleConfig(db, 'iptables', 'firewall_ip', '192.168.0.254'); upsertModuleConfig(db, 'iptables', 'nat_ip', '192.168.0.253'); upsertModuleConfig(db, 'iptables', 'interface_baseline', ''); const config = await loadedConfig(); expect(config.interfaceBaseline).toBeUndefined(); }); test('chained provider: the downstream layer gets them too', async () => { // greenwave owns the WAN, so iptables is built through buildFirewallChain // rather than the single-provider path. That is a SECOND construction // site with its own field list, and it is the one a real downstream // firewall goes through. const gwPath = join(tempDir, 'greenwave'); const gwScripts = join(gwPath, 'scripts'); mkdirSync(gwScripts, { recursive: true }); writeFileSync(join(gwScripts, 'firewall-functions.ts'), MOCK_GREENWAVE_MODULE); db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('greenwave', 'GreenWave', '1.0.0', '${gwPath}', '{}')`, ); db.$client.run( `INSERT INTO capabilities (module_id, capability_name, version, data, zones) VALUES ('greenwave', 'firewall', '1.0.0', '{"has_external":true}', '["internal"]')`, ); upsertModuleConfig(db, 'greenwave', 'router_ip', '192.168.0.1'); installReporter('iptables', '{}', '["dmz","app","secure"]'); upsertModuleConfig(db, 'iptables', 'firewall_ip', '192.168.0.254'); upsertModuleConfig(db, 'iptables', 'nat_ip', '192.168.0.253'); upsertModuleConfig(db, 'iptables', 'default_route_zone', 'internal'); upsertModuleConfig(db, 'iptables', 'isolate_transit_network', true); upsertModuleConfig(db, 'iptables', 'interface_baseline', 'eth0,eth1'); upsertModuleConfig(db, 'iptables', 'interface_zone_map', 'eth0=isp-transit,eth1=internal'); const config = await loadedConfig(); expect(config.defaultRouteZone).toBe('internal'); expect(config.isolateTransitNetwork).toBe(true); // The downstream construction site already forwarded these; pinned so the // two sites cannot drift apart again. expect(config.interfaceBaseline).toEqual(['eth0', 'eth1']); expect(config.interfaceZoneMap).toBe('eth0=isp-transit,eth1=internal'); }); test('unset settings arrive undefined, not as a wrong default', async () => { installReporter('iptables', '{"has_external":true}', '["dmz"]'); upsertModuleConfig(db, 'iptables', 'firewall_ip', '192.168.0.254'); upsertModuleConfig(db, 'iptables', 'nat_ip', '192.168.0.253'); const config = await loadedConfig(); // The module owns both defaults (`?? 'internal'` and `?? false`). The // loader must not invent one, or a future change to the module's default // would be silently overridden by a stale copy here. expect(config.defaultRouteZone).toBeUndefined(); expect(config.isolateTransitNetwork).toBeUndefined(); expect(config.interfaceBaseline).toBeUndefined(); expect(config.interfaceZoneMap).toBeUndefined(); }); }); });