import { describe, expect, test } from 'bun:test'; /** * A port forward must bind the interface that actually carries the public IP. * * celilo#1436. The greenwave simulator defaulted EXTERNAL_INTERFACE to 'eth1' * and nothing set it, while on fw-isp eth0 carries 203.0.113.100 and eth1 is * the internal leg. Every DNAT was therefore bound to the interface facing away * from the internet: hairpin traffic from inside matched, inbound internet * traffic was refused, and no per-test domain could obtain a certificate. * * This reads the REAL simulator source rather than re-implementing its logic — * a test that restates the derivation would pass against a file that still * hardcodes the wrong default. */ import { join } from 'node:path'; // Resolved from THIS FILE, not from the cwd. `bun run test` runs each // workspace package's suite with that package's own cwd, so a repo-root // relative path resolves to packages/e2e/packages/e2e/... and Bun.file() // throws ENOENT -- while the same path works when run from the repo root, // which is where it gets tried by hand. A test that cannot reach the file it // reads is the shape celilo's CLAUDE.md calls "a check that cannot reach the // thing it is checking", and it is exactly what this test exists to avoid. const SRC = join(import.meta.dir, '..', 'simulators', 'greenwave', 'state.ts'); describe('celilo#1436: the greenwave sim does not guess interface names', () => { test('the port-forward DNAT is scoped by destination address, not by interface', async () => { const src = await Bun.file(SRC).text(); // Docker hands out interface names in a non-deterministic order, so // DERIVING the right one only chooses which direction breaks. The // destination address is the discriminator that cannot be raced: only // traffic actually addressed to PUBLIC_IP can match, whichever leg it // arrived on. expect(src).toMatch(/PREROUTING -d \$\{PUBLIC_IP\}/); }); test('no port-forward rule binds an interface at all', async () => { const src = await Bun.file(SRC).text(); // The control, and the reason this file exists: re-introducing `-i` on a // forwarding rule is exactly the regression, and it would otherwise pass // the assertion above unnoticed. const forwardRules = src .split('\n') .filter((l) => l.includes('PREROUTING') && l.includes('DNAT')); expect(forwardRules.length).toBeGreaterThan(0); for (const rule of forwardRules) { expect(rule).not.toMatch(/\s-i\s/); } }); test('the DHCP interface is derived from the address it serves, never hardcoded', async () => { const src = await Bun.file(SRC).text(); // dnsmasq's `interface=` DOES still need a name -- it binds a socket, so // an address cannot stand in for it. That one is derived from the router // address it is about to serve. expect(src).toContain('deriveDhcpInterface'); expect(src).toContain('interfaceCarrying'); expect(src).not.toMatch(/interface=eth[0-9]/); }); });