/** * The ingress-IP allocate-and-reserve guard (ISS-0156, celilo#879). * * This invariant had NO test. Losing it is not a crash: `module generate` * re-allocates a different address on every run, silently moving the address * internal clients use to reach the service, while every command still reports * success. `module generate` runs repeatedly over a module's life, so "on the * second run" is the normal case, not an edge one. */ import { beforeEach, describe, expect, test } from 'bun:test'; import type { DbClient } from '../db/client'; import { deallocateForModule } from '../ipam/auto-allocator'; import type { ModuleManifest } from '../manifest/schema'; import { getModuleConfigValue } from '../services/module-config'; import { setupTestDatabase } from '../test-utils/database'; import { ensureIngressIps } from './generator'; let db: DbClient; /** A manifest that opts in the way a `dns_internal` provider does. */ const wantsIngress = { variables: { owns: [{ name: 'dns_ingress_ip', source: 'infrastructure' }] }, } as unknown as ModuleManifest; /** Same shape, but the variable is operator input rather than infrastructure. */ const operatorSupplied = { variables: { owns: [{ name: 'dns_ingress_ip', source: 'user_input' }] }, } as unknown as ModuleManifest; /** How `caddy-internal` opts in — a web ingress rather than a DNS one. */ const wantsWebIngress = { variables: { owns: [{ name: 'ingress_ip', source: 'infrastructure' }] }, } as unknown as ModuleManifest; /** * Typed as `string | undefined` rather than `unknown`: every assertion here is * about an address, and an untyped read pushes a cast onto each one. */ const storedIp = (moduleId: string, variable = 'dns_ingress_ip'): string | undefined => { const value = getModuleConfigValue(moduleId, variable, db)?.value; return typeof value === 'string' ? value : undefined; }; /** Reservation reasons currently held, so a test can assert one is GONE. */ const reservedReasons = (): string[] => (db.$client.prepare('SELECT reason FROM ip_reservations').all() as Array<{ reason: string }>).map( (r) => r.reason, ); /** * What `module remove` does to a module's IPAM state. Removal also drops the * module row, which cascades the stored config value away — the tests below * clear it by hand so a "reinstall" starts from the same state a real one does. */ const removeModule = async (moduleId: string): Promise => { await deallocateForModule(moduleId, db); db.$client.prepare('DELETE FROM module_configs WHERE module_id = ?').run(moduleId); }; /** * Every module these tests name, as a real row. `module_configs` carries a * foreign key onto `modules`, so without the parent the config write * `ensureIngressIps` performs is rejected and the function reports failure — * which is what happens in production too, and never happened here while the * test helper ran with foreign keys off (celilo#1074). * * Worth knowing WHICH tests that would have broken. Only some: "two modules get * two different addresses" fails loudly, but "REUSES the same address on a * second generate" compares one unwritten value to another and passes. So a * repair that chased the red would have left this file's central guard asserting * `undefined === undefined`. */ const NAMED_MODULES = ['technitium', 'knot-unbound-internal', 'caddy', 'caddy-internal']; beforeEach(async () => { db = await setupTestDatabase(); for (const id of NAMED_MODULES) { db.$client .prepare( 'INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES (?, ?, ?, ?, ?)', ) .run(id, id, '1.0.0', `/test/${id}`, '{}'); } db.$client .prepare('INSERT OR REPLACE INTO system_config (key, value) VALUES (?, ?)') .run('network.internal.subnet', '10.226.1.0/24'); }); describe('ensureIngressIps', () => { test('allocates an address from the internal subnet on first generate', async () => { const result = await ensureIngressIps('technitium', wantsIngress, db); expect(result.success).toBe(true); expect(storedIp('technitium')).toMatch(/^10\.226\.1\.\d+$/); }); test('REUSES the same address on a second generate', async () => { // The guard itself. Re-allocating here moves the resolver's DNAT ingress // every time the module is regenerated, and nothing reports a problem. await ensureIngressIps('technitium', wantsIngress, db); const first = storedIp('technitium'); // Assert the read SUCCEEDED before comparing two of them. Without this the // test passes whenever BOTH reads fail the same way, which is exactly what // happened when foreign keys were switched on and the config write this // depends on started being rejected (celilo#1074): `undefined` equals // `undefined`, and the file's central guard stayed green while proving // nothing. expect(first).toBeDefined(); await ensureIngressIps('technitium', wantsIngress, db); const second = storedIp('technitium'); expect(second).toBe(first); }); test('stays stable across many generates, not just two', async () => { await ensureIngressIps('technitium', wantsIngress, db); const first = storedIp('technitium'); expect(first).toBeDefined(); for (let i = 0; i < 5; i++) { await ensureIngressIps('technitium', wantsIngress, db); } expect(storedIp('technitium')).toBe(first); }); test('RESERVES the address, so it is never handed out to something else', async () => { // Allocation without reservation is the same bug one step later: a // container gets the resolver's ingress address and DNS goes dark. await ensureIngressIps('technitium', wantsIngress, db); const ip = storedIp('technitium'); const reserved = db.$client .prepare('SELECT ip_start, reason FROM ip_reservations WHERE ip_start = ?') .get(ip ?? '') as { ip_start: string; reason: string } | undefined; expect(reserved?.ip_start).toBe(ip); // The reason names the owner AND the variable, so an operator reading the // table can tell what an otherwise anonymous held address is for. expect(reserved?.reason).toBe('ingress:technitium:dns_ingress_ip'); }); test('two modules get two different addresses', async () => { await ensureIngressIps('technitium', wantsIngress, db); await ensureIngressIps('knot-unbound-internal', wantsIngress, db); expect(storedIp('knot-unbound-internal')).not.toBe(storedIp('technitium')); }); test('does nothing for a module that never asked for one', async () => { const result = await ensureIngressIps('caddy', {} as ModuleManifest, db); expect(result.success).toBe(true); expect(storedIp('caddy')).toBeUndefined(); }); test('only `source: infrastructure` opts in', async () => { // A same-named variable the operator supplies is theirs to set; allocating // over it would overwrite an operator's deliberate choice. await ensureIngressIps('technitium', operatorSupplied, db); expect(storedIp('technitium')).toBeUndefined(); }); test('fails with an actionable message when the internal subnet is unset', async () => { db.$client.prepare('DELETE FROM system_config WHERE key = ?').run('network.internal.subnet'); const result = await ensureIngressIps('technitium', wantsIngress, db); expect(result.success).toBe(false); expect(result.success === false && result.error).toContain('network.internal.subnet'); }); // celilo#879. The opt-in used to be the literal name `dns_ingress_ip`, so a // dmz-resident WEB ingress had no way to ask for the same treatment — which // is how `caddy-internal` came to be pinned into the `internal` zone with a // manifest comment claiming a dmz ingress could not be reached from a LAN. test('a `ingress_ip` variable opts in the same way, for a non-DNS ingress', async () => { const result = await ensureIngressIps('caddy-internal', wantsWebIngress, db); expect(result.success).toBe(true); expect(storedIp('caddy-internal', 'ingress_ip')).toMatch(/^10\.226\.1\.\d+$/); }); }); /** * The release half (celilo#892). `ensureIngressIps` reserved the address and * nothing ever gave it back, so every install/remove cycle permanently burned * one address from the internal static range — silently, and with no way to * tell the dead row from the live one, since both carry the same reason. * * On celilo-mgr this left 192.168.0.153 and .154 both reading * `ingress:caddy-internal:ingress_ip`, only one of them real. */ describe('releasing ingress IPs on module removal', () => { test('removing a module releases its ingress reservation', async () => { await ensureIngressIps('technitium', wantsIngress, db); expect(reservedReasons()).toContain('ingress:technitium:dns_ingress_ip'); await removeModule('technitium'); expect(reservedReasons()).not.toContain('ingress:technitium:dns_ingress_ip'); }); test('a reinstall REUSES the address instead of advancing to the next one', async () => { // The user-visible symptom: without the release, the range walks forward // one address per install/remove cycle until it runs out. await ensureIngressIps('technitium', wantsIngress, db); const first = storedIp('technitium'); expect(first).toBeDefined(); await removeModule('technitium'); await ensureIngressIps('technitium', wantsIngress, db); expect(storedIp('technitium')).toBe(first); }); test('the exclusion count returns to its pre-install value', async () => { const before = reservedReasons().length; await ensureIngressIps('caddy-internal', wantsWebIngress, db); expect(reservedReasons().length).toBe(before + 1); await removeModule('caddy-internal'); expect(reservedReasons().length).toBe(before); }); test('releases the LEGACY `dns-ingress:` reason too', async () => { // Written before celilo#879 generalized the reason format. celilo-mgr holds // one of these right now, so a fix matching only the current format leaves // the installed base leaking. db.$client .prepare('INSERT INTO ip_reservations (ip_start, zone, reason) VALUES (?, ?, ?)') .run('10.226.1.42', 'internal', 'dns-ingress:technitium'); await removeModule('technitium'); expect(reservedReasons()).not.toContain('dns-ingress:technitium'); }); test('releases a module holding BOTH formats at once', async () => { // An upgraded installation: the legacy row from before celilo#879 plus a // current one written since. Releasing only one still leaks. await ensureIngressIps('technitium', wantsIngress, db); db.$client .prepare('INSERT INTO ip_reservations (ip_start, zone, reason) VALUES (?, ?, ?)') .run('10.226.1.42', 'internal', 'dns-ingress:technitium'); await removeModule('technitium'); expect(reservedReasons()).toEqual([]); }); test("leaves OTHER modules' reservations alone", async () => { await ensureIngressIps('technitium', wantsIngress, db); await ensureIngressIps('knot-unbound-internal', wantsIngress, db); await removeModule('technitium'); expect(reservedReasons()).toEqual(['ingress:knot-unbound-internal:dns_ingress_ip']); }); test('releases even when the module has no zone-IP allocation', async () => { // `deallocateForModule` returns early when there is no `ip_allocations` // row, which is the normal case for a module deployed onto a machine // rather than a celilo-provisioned container. Releasing after that early // return would skip exactly the modules that leak. await ensureIngressIps('caddy-internal', wantsWebIngress, db); const hadAllocation = await deallocateForModule('caddy-internal', db); expect(hadAllocation).toBe(false); expect(reservedReasons()).toEqual([]); }); });