@celilo/e2e 0.12.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +5 -5
  2. package/bin/e2e-status +1 -1
  3. package/bin/e2e-up +2 -2
  4. package/config/dns/tangohost.com.zone +1 -1
  5. package/config/dns/templates/example.net.zone +3 -3
  6. package/config/dns/templates/iamtheinternet.org.zone +2 -2
  7. package/config/routing/fw-ext-routes.sh +8 -8
  8. package/config/routing/fw-isp-routes.sh +2 -2
  9. package/config/routing/fw-main-routes.sh +5 -5
  10. package/config/routing/management-routes.sh +1 -1
  11. package/config/routing/observer-setup.sh +1 -1
  12. package/config/routing/public-sim-entrypoint.sh +1 -1
  13. package/config/routing/resolver-internal-routes.sh +2 -2
  14. package/config/routing/resolver-routes.sh +1 -1
  15. package/config/routing/target-routes.sh +1 -1
  16. package/config/routing/target-setup.sh +1 -1
  17. package/config/socks/startup.sh +2 -2
  18. package/docker/Dockerfile.firewall +12 -1
  19. package/package.json +5 -4
  20. package/simulators/greenwave/server.ts +35 -0
  21. package/simulators/greenwave/state.ts +64 -11
  22. package/simulators/ip-echo/server.ts +1 -1
  23. package/src/address-plan.test.ts +4 -4
  24. package/src/cli/build.ts +30 -0
  25. package/src/cli/command-registry.ts +19 -0
  26. package/src/cli/completion.ts +16 -1
  27. package/src/cli/index.ts +85 -4
  28. package/src/container-manager.ts +191 -14
  29. package/src/docker-compose-generator.ts +55 -26
  30. package/src/doctor.test.ts +279 -0
  31. package/src/doctor.ts +421 -0
  32. package/src/extract-failure.ts +41 -0
  33. package/src/index.ts +12 -0
  34. package/src/last-run.test.ts +62 -0
  35. package/src/last-run.ts +54 -0
  36. package/src/observer.test.ts +2 -2
  37. package/src/observer.ts +1 -1
  38. package/src/router-swap.ts +121 -0
  39. package/src/run-lock.test.ts +73 -2
  40. package/src/run-lock.ts +110 -14
  41. package/src/runner.ts +99 -5
  42. package/src/socks-proxy.ts +2 -2
  43. package/src/types.ts +48 -8
  44. package/src/vantage.test.ts +1 -1
  45. package/src/zone-classifier.test.ts +11 -16
  46. package/src/zone-classifier.ts +16 -31
@@ -3,7 +3,14 @@
3
3
  * All completions are static (no DB lookups needed).
4
4
  */
5
5
 
6
- import { BUILD_INFRA_FLAGS, COMMANDS, DOWN_FLAGS, RUN_FLAGS, UP_PRESETS } from './command-registry';
6
+ import {
7
+ BUILD_INFRA_FLAGS,
8
+ COMMANDS,
9
+ DOWN_FLAGS,
10
+ JSON_FLAG_COMMANDS,
11
+ RUN_FLAGS,
12
+ UP_PRESETS,
13
+ } from './command-registry';
7
14
  import { findAllTestNames } from './module-discovery';
8
15
 
9
16
  export function getCompletions(words: string[], current: number): string[] {
@@ -62,6 +69,14 @@ export function getCompletions(words: string[], current: number): string[] {
62
69
  return filterSuggestions(['zsh', 'bash'], args[1] || '');
63
70
  }
64
71
 
72
+ if (JSON_FLAG_COMMANDS.includes(command) && currentIndex >= 1) {
73
+ const used = new Set(args.slice(1));
74
+ return filterSuggestions(
75
+ ['--json'].filter((f) => !used.has(f)),
76
+ args[currentIndex] || '',
77
+ );
78
+ }
79
+
65
80
  return [];
66
81
  }
67
82
 
package/src/cli/index.ts CHANGED
@@ -11,6 +11,8 @@
11
11
  * cele2e version
12
12
  * cele2e shell [container]
13
13
  * cele2e status
14
+ * cele2e doctor
15
+ * cele2e last [--json]
14
16
  * cele2e load
15
17
  * cele2e scaffold <test-name>
16
18
  */
@@ -19,11 +21,13 @@ import { spawnSync } from 'node:child_process';
19
21
  import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
20
22
  import { basename, dirname, join, resolve } from 'node:path';
21
23
  import { parse as parseYaml } from 'yaml';
24
+ import { diagnose, formatHolderLine, formatReport } from '../doctor';
25
+ import { readLastRun } from '../last-run';
22
26
  import {
23
27
  E2eBusyError,
28
+ type LockHolder,
24
29
  acquireRunLock,
25
30
  clearLock,
26
- formatBusy,
27
31
  lockStatus,
28
32
  markKept,
29
33
  } from '../run-lock';
@@ -129,6 +133,19 @@ function discoverSuiteTests(suiteRoot: string): { name: string; file: string; gr
129
133
  return out;
130
134
  }
131
135
 
136
+ /**
137
+ * Say out loud when we cleared this session's own kept stack. Silence would be
138
+ * worse than the old refusal: the operator asked for a kept stack, and it is
139
+ * gone now for a reason they should be able to read.
140
+ */
141
+ function noteAutoRelease(holder: LockHolder | null): void {
142
+ if (!holder) return;
143
+ console.log(
144
+ `Auto-released your own kept stack (${holder.test}, held since ${holder.startedAt}) and continuing.`,
145
+ );
146
+ console.log('Use `cele2e run --reuse` instead if you meant to run against it.');
147
+ }
148
+
132
149
  function runScript(script: string, args: string[], env?: Record<string, string>): never {
133
150
  const result = spawnSync(join(BIN_DIR, script), args, {
134
151
  stdio: 'inherit',
@@ -155,6 +172,8 @@ Commands:
155
172
  version Show cele2e version
156
173
  shell [container] Shell into a running container (default: management)
157
174
  status Show run-lock holder + network status (exit 3 if busy)
175
+ doctor Check the environment a run needs (run's implicit preflight)
176
+ last Show the most recent run's results dir + counts
158
177
  release Free a run-lock left by --keep/up (does not tear down)
159
178
  load Load cached Docker images from tarball
160
179
  scaffold <name> Generate a new test file from template
@@ -180,6 +199,9 @@ Options for \`down\`:
180
199
  --keep Stop containers but preserve volumes
181
200
  --all Also stop competing module containers
182
201
 
202
+ Options for \`doctor\` / \`last\` / \`status\`:
203
+ --json Machine-readable output
204
+
183
205
  Options for \`build-infra\`:
184
206
  --save Save Docker images to tarball after build
185
207
  --skip-modules Skip .netapp packaging (only rebuild Docker images)
@@ -341,7 +363,11 @@ switch (command) {
341
363
  // kept state (cleared by `cele2e down`/`release`) to stop a concurrent run
342
364
  // from wiping it.
343
365
  try {
344
- acquireRunLock({ test: 'up', runId: process.env.CELE2E_RUN_ID ?? crypto.randomUUID() });
366
+ const outcome = acquireRunLock({
367
+ test: 'up',
368
+ runId: process.env.CELE2E_RUN_ID ?? crypto.randomUUID(),
369
+ });
370
+ noteAutoRelease(outcome.autoReleasedOwnKept);
345
371
  markKept();
346
372
  } catch (err) {
347
373
  if (err instanceof E2eBusyError) {
@@ -385,7 +411,19 @@ switch (command) {
385
411
  if (lock.free) {
386
412
  console.log(' free');
387
413
  } else if (lock.holder) {
388
- console.log(` BUSY — ${formatBusy(lock.holder)}`);
414
+ // Heartbeat age is always printed: a wedged holder keeps a live PID, so
415
+ // the only thing separating "slow" from "hung for 32 minutes" is how long
416
+ // ago it last beat — previously visible only by reading beatAt by hand.
417
+ console.log(` BUSY — ${formatHolderLine(lock.holder)}`);
418
+ if (lock.suspect) {
419
+ console.log(
420
+ ' ⚠ SUSPECT: the process is alive but has stopped beating. It is probably wedged;',
421
+ );
422
+ console.log(` inspect or kill pid ${lock.holder.pid}, then \`cele2e down\`.`);
423
+ }
424
+ if (lock.ownKept) {
425
+ console.log(' This is YOUR OWN kept stack — the next `cele2e run` auto-releases it.');
426
+ }
389
427
  }
390
428
  console.log('');
391
429
 
@@ -410,6 +448,48 @@ switch (command) {
410
448
  break;
411
449
  }
412
450
 
451
+ case 'doctor': {
452
+ // The single "is my environment sane?" answer. `run` calls the same
453
+ // diagnose() as its preflight, so what doctor reports is exactly what a run
454
+ // will refuse on — no second, drifting copy of the rules.
455
+ const report = diagnose({ pkgDir: PKG_DIR });
456
+ if (args.includes('--json')) {
457
+ console.log(JSON.stringify(report, null, 2));
458
+ process.exit(report.ok ? 0 : 1);
459
+ }
460
+ console.log('=== cele2e doctor ===');
461
+ for (const line of formatReport(report)) console.log(line);
462
+ console.log('');
463
+ console.log(
464
+ report.ok ? 'Environment looks runnable.' : 'Environment is NOT runnable — see fixes above.',
465
+ );
466
+ process.exit(report.ok ? 0 : 1);
467
+ break;
468
+ }
469
+
470
+ case 'last': {
471
+ // Which results directory was MINE? Inferring it from `ls -t results` returns
472
+ // the newest run, which after a refused start is somebody else's — a suite
473
+ // that never executed then reads as a clean pass.
474
+ const last = readLastRun();
475
+ if (args.includes('--json')) {
476
+ console.log(JSON.stringify(last));
477
+ process.exit(last ? 0 : 1);
478
+ }
479
+ if (!last) {
480
+ console.error('No cele2e run recorded yet.');
481
+ process.exit(1);
482
+ }
483
+ console.log(`runId: ${last.runId}`);
484
+ console.log(`resultsDir: ${last.resultsDir}`);
485
+ console.log(`status: ${last.status}`);
486
+ console.log(
487
+ `results: ${last.passed} passed, ${last.failed} failed${last.skipped > 0 ? `, ${last.skipped} skipped (blocked by an earlier stage)` : ''} of ${last.total}`,
488
+ );
489
+ process.exit(0);
490
+ break;
491
+ }
492
+
413
493
  case 'load':
414
494
  runScript('e2e-load', args);
415
495
  break;
@@ -422,10 +502,11 @@ switch (command) {
422
502
  // build-infra mutates the shared Docker images — serialize against runs.
423
503
  // Released via the lock's exit handler (runBuild may process.exit on failure).
424
504
  try {
425
- acquireRunLock({
505
+ const outcome = acquireRunLock({
426
506
  test: 'build-infra',
427
507
  runId: process.env.CELE2E_RUN_ID ?? crypto.randomUUID(),
428
508
  });
509
+ noteAutoRelease(outcome.autoReleasedOwnKept);
429
510
  } catch (err) {
430
511
  if (err instanceof E2eBusyError) {
431
512
  console.error(err.message);
@@ -1,14 +1,17 @@
1
1
  import { type ExecSyncOptions, execSync, spawn } from 'node:child_process';
2
- import { existsSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { basename, join, resolve } from 'node:path';
5
+ import { parse as parseYaml } from 'yaml';
5
6
  import { startBrowser } from './browser';
6
7
  import { MIN_CLI_VERSION, checkCliVersion } from './cli-version-contract';
7
8
  import {
8
9
  SHARED_PROJECT_NAME,
10
+ firewallZoneLegs,
9
11
  generateTestComposeYaml,
10
12
  getAllMachines,
11
13
  } from './docker-compose-generator';
14
+ import { explainBuildFailure } from './doctor';
12
15
  import { ensureSharedInfra } from './shared-infra';
13
16
  import { SIMULATOR_IPS } from './simulator-ips';
14
17
  import { startSocksProxy } from './socks-proxy';
@@ -20,8 +23,15 @@ import type {
20
23
  NetworkHandle,
21
24
  ProxyOptions,
22
25
  SocksProxyHandle,
26
+ TopologyPreset,
27
+ } from './types';
28
+ import {
29
+ CeliloCommandError,
30
+ ZONE_GATEWAYS,
31
+ ZONE_SUBNETS,
32
+ greenwaveRouterIp,
33
+ internalNatIp,
23
34
  } from './types';
24
- import { CeliloCommandError, ZONE_GATEWAYS, ZONE_SUBNETS, internalNatIp } from './types';
25
35
 
26
36
  /** Package root — where docker/, config/, simulators/ live */
27
37
  const PACKAGE_ROOT = join(__dirname, '..');
@@ -499,7 +509,11 @@ async function streamingBuild(
499
509
  console.log('[progress:done] images built');
500
510
  resolve();
501
511
  } else {
502
- reject(new Error(`docker compose build failed (exit ${code}):\n${stderr.slice(-500)}`));
512
+ reject(
513
+ new Error(
514
+ `docker compose build failed (exit ${code}):\n${stderr.slice(-500)}${explainBuildFailure(stderr)}`,
515
+ ),
516
+ );
503
517
  }
504
518
  });
505
519
  });
@@ -515,11 +529,15 @@ function buildNetworkHandle(
515
529
  projectName: string,
516
530
  composeDir: string,
517
531
  celiloRoot: string | undefined,
532
+ topology: TopologyPreset,
518
533
  ): NetworkHandle {
519
534
  // Track host-side resources spawned via the handle (SOCKS proxies,
520
535
  // Playwright browsers). stop() tears these down before the docker
521
536
  // network itself.
522
537
  const activeProxies: SocksProxyHandle[] = [];
538
+ /** Services plugged into the undeclared segment, and the subnet it uses. */
539
+ const attachedToAlienSegment = new Set<string>();
540
+ let alienSegmentSubnet = '';
523
541
  const activeBrowsers: BrowserHandle[] = [];
524
542
 
525
543
  const handle: NetworkHandle = {
@@ -597,11 +615,35 @@ function buildNetworkHandle(
597
615
  await new Promise((r) => setTimeout(r, 500));
598
616
  },
599
617
 
600
- async deployFirewall(opts = {}): Promise<void> {
601
- const zones = opts.zones ?? ['dmz', 'app', 'secure'];
618
+ async deployFirewall(opts = {}): Promise<ExecResult> {
619
+ // Everything the firewall declares is derived from the topology it is
620
+ // wired for. `opts.zones` used to select which zones got
621
+ // `provided_networks`, and that cannot survive interface classification:
622
+ // `provided_networks` IS what writes `network.<zone>.subnet`, so a leg
623
+ // left out of it has no declared subnet, classifies `alien`, and the
624
+ // converge refuses on a firewall celilo has never understood.
625
+ //
626
+ // That is the real rule, not a harness quirk. A firewall holding an
627
+ // address on a segment celilo has no subnet for is one celilo cannot
628
+ // describe. So the wired legs and the declared zones are ONE list (D8),
629
+ // and a test wanting fewer legs changes the topology.
630
+ //
631
+ // The old `opts.zones` never said which NICs the box has — the compose
632
+ // topology decides that, and it wires fw-main to every segmented zone
633
+ // regardless of what a test passed. Roughly twenty call sites passed
634
+ // `['dmz']` against a four-leg firewall, so every one of them
635
+ // under-declared the hardware. Deriving from the topology makes that
636
+ // structurally impossible rather than something each test author has to
637
+ // remember, and removes a hand-maintained copy of a list the compose
638
+ // generator already owns.
639
+ //
640
+ // `external` is excluded from provided_networks: it is the residual, has
641
+ // no subnet, and is not a zone modules are placed in.
642
+ const declaredZones = firewallZoneLegs(topology);
643
+ const providedZones = declaredZones.filter((zone) => zone !== 'external');
602
644
  const firewallIp = ZONE_GATEWAYS.internal; // fw-main on internal
603
645
  const natIp = opts.natIp ?? internalNatIp();
604
- const providedNetworks = zones.map((zone) => ({
646
+ const providedNetworks = providedZones.map((zone) => ({
605
647
  zone,
606
648
  subnet: ZONE_SUBNETS[zone],
607
649
  gateway: ZONE_GATEWAYS[zone],
@@ -637,13 +679,119 @@ function buildNetworkHandle(
637
679
  await handle.celilo('module import iptables');
638
680
  await handle.celilo(`module config set iptables firewall_ip ${firewallIp}`);
639
681
  await handle.celilo(`module config set iptables nat_ip ${natIp}`);
640
- await handle.celilo(
641
- `module config set iptables zones '${JSON.stringify(['internal', ...zones])}'`,
642
- );
682
+ await handle.celilo(`module config set iptables zones '${JSON.stringify(declaredZones)}'`);
643
683
  await handle.celilo(
644
684
  `module config set iptables provided_networks '${JSON.stringify(providedNetworks)}'`,
645
685
  );
646
- await handle.celilo('module deploy iptables', 180_000);
686
+ // `check: false` returns the failed deploy instead of throwing, so a test
687
+ // can assert on WHAT the refusal said. Interface classification refuses
688
+ // by design (D12 onboarding), and a refusal is only useful if it names
689
+ // the interface — which is an assertion about output, not about an
690
+ // exception having been raised.
691
+ return opts.check === false
692
+ ? await handle.celilo('module deploy iptables', { check: false, timeoutMs: 180_000 })
693
+ : await handle.celilo('module deploy iptables', 180_000);
694
+ },
695
+
696
+ async attachAlienSegment(opts: {
697
+ subnet: string;
698
+ containers: string[];
699
+ }): Promise<Record<string, string>> {
700
+ // A REAL cable into a REAL port. Interface classification is about what
701
+ // celilo finds on the box, so a test that fakes the finding — a dummy
702
+ // link, a stubbed interface table — is testing its own fixture. This
703
+ // creates an actual docker network celilo has no subnet for and plugs
704
+ // real containers into it, which is what an operator hanging an
705
+ // unmanaged switch off a spare port produces: a new interface, a global
706
+ // address, and something live on the other side to prove reachability
707
+ // with.
708
+ //
709
+ // The subnet is the caller's, and it must not be one of ZONE_SUBNETS —
710
+ // the whole point is that celilo cannot attribute it.
711
+ const netName = `${projectName}_alien-seg`;
712
+ alienSegmentSubnet = opts.subnet;
713
+ run(`docker network create --driver bridge --subnet ${opts.subnet} ${netName}`, {
714
+ timeout: 20_000,
715
+ });
716
+
717
+ const assigned: Record<string, string> = {};
718
+ opts.containers.forEach((service, index) => {
719
+ // .2 upward: .1 is the bridge itself.
720
+ const ip = opts.subnet.replace(/\.0\/\d+$/, `.${index + 2}`);
721
+ const id = run(`docker compose -p ${projectName} -f ${COMPOSE_FILE} ps -q ${service}`, {
722
+ cwd: composeDir,
723
+ timeout: 20_000,
724
+ });
725
+ if (!id) throw new Error(`attachAlienSegment: no container for service '${service}'`);
726
+
727
+ // `docker network connect` EXITS NONZERO on a container that already has
728
+ // a default route — "failed to set gateway while updating gateway: file
729
+ // exists" — while attaching the interface perfectly well: the address
730
+ // lands, the veth is up, and traffic flows both ways. Every container in
731
+ // this topology routes through the firewall, so every attach hits it.
732
+ // `docker network inspect` is no help either; it lists no containers for
733
+ // this network whether the attach worked or not.
734
+ //
735
+ // So neither the exit code nor docker's own view can be believed, and
736
+ // the address on the box is the only trustworthy signal. Verified below
737
+ // rather than assumed — a half-completed attach really does leave an
738
+ // address behind with no working wire, which is precisely the state that
739
+ // would make a classification test pass for the wrong reason.
740
+ try {
741
+ run(`docker network connect --ip ${ip} ${netName} ${id}`, { timeout: 20_000 });
742
+ } catch {}
743
+
744
+ const check = dockerExec(projectName, composeDir, service, 'ip -o addr show scope global');
745
+ if (!check.stdout.includes(ip)) {
746
+ throw new Error(
747
+ `attachAlienSegment: '${service}' has no ${ip} after connecting to ${netName}:\n${check.stdout}`,
748
+ );
749
+ }
750
+ assigned[service] = ip;
751
+ attachedToAlienSegment.add(service);
752
+ });
753
+ return assigned;
754
+ },
755
+
756
+ async detachAlienSegment(): Promise<void> {
757
+ const netName = `${projectName}_alien-seg`;
758
+ for (const service of attachedToAlienSegment) {
759
+ const id = run(`docker compose -p ${projectName} -f ${COMPOSE_FILE} ps -q ${service}`, {
760
+ cwd: composeDir,
761
+ timeout: 20_000,
762
+ });
763
+ if (id) {
764
+ try {
765
+ run(`docker network disconnect -f ${netName} ${id}`, { timeout: 20_000 });
766
+ } catch {}
767
+ }
768
+ }
769
+ try {
770
+ run(`docker network rm ${netName}`, { timeout: 20_000 });
771
+ } catch {}
772
+
773
+ // VERIFY, for the same reason attach does. A lingering address on a dead
774
+ // wire is still an interface celilo cannot attribute, so a test that goes
775
+ // on to expect a clean converge would fail somewhere far from the cause.
776
+ for (const service of attachedToAlienSegment) {
777
+ const check = dockerExec(projectName, composeDir, service, 'ip -o addr show scope global');
778
+ const prefix = alienSegmentSubnet.replace(/\.\d+\/\d+$/, '').replace(/\./g, '\\.');
779
+ const orphan = new RegExp(`(\\S+)\\s+inet\\s+${prefix}\\.\\d+`).exec(check.stdout);
780
+ if (orphan) {
781
+ dockerExec(projectName, composeDir, service, `ip link del ${orphan[1]}`);
782
+ }
783
+ }
784
+ attachedToAlienSegment.clear();
785
+ },
786
+
787
+ async deployGreenwave(): Promise<void> {
788
+ const routerIp = greenwaveRouterIp();
789
+ await handle.celilo(`machine add ${routerIp} --ssh-user root --earmark greenwave`);
790
+ await handle.celilo('module import greenwave');
791
+ await handle.celilo(`module config set greenwave router_ip ${routerIp}`);
792
+ await handle.celilo('module secret set greenwave router_username admin');
793
+ await handle.celilo('module secret set greenwave router_password admin');
794
+ await handle.celilo('module deploy greenwave', 180_000);
647
795
  },
648
796
 
649
797
  async socksProxy(options: ProxyOptions = {}): Promise<SocksProxyHandle> {
@@ -889,7 +1037,7 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
889
1037
  // means the test is replacing the infra resolver with a module under test
890
1038
  // (e.g., knot-unbound-internal). The bare target machine has no DNS server
891
1039
  // until its module deploys, so this wait would always time out. Management
892
- // can still resolve names via the fallback `nameserver 100.100.0.1` in
1040
+ // can still resolve names via the fallback `nameserver 203.0.113.1` in
893
1041
  // its resolv.conf during deploy.
894
1042
  //
895
1043
  // The resolver-under-test may sit in ANY zone, not just `internal`: ISS-0156
@@ -1063,7 +1211,7 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
1063
1211
  config.managementZone === 'secure-mgmt' || (config.secureMgmtMachines ?? []).length > 0
1064
1212
  ? `network.secure-mgmt.subnet=${ZONE_SUBNETS['secure-mgmt']} network.secure-mgmt.gateway=${ZONE_GATEWAYS['secure-mgmt']} `
1065
1213
  : ''
1066
- }dns.primary=100.100.0.1 \
1214
+ }dns.primary=203.0.113.1 \
1067
1215
  dns.fallback=1.0.0.1,8.8.8.8`,
1068
1216
  );
1069
1217
  if (initResult.exitCode !== 0) {
@@ -1095,7 +1243,7 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
1095
1243
  // Emit project name so the runner can persist it if --keep is set
1096
1244
  console.log(`[e2e:project] ${projectName}`);
1097
1245
 
1098
- return buildNetworkHandle(projectName, composeDir, celiloRoot);
1246
+ return buildNetworkHandle(projectName, composeDir, celiloRoot, config.topology);
1099
1247
  }
1100
1248
 
1101
1249
  /**
@@ -1106,6 +1254,26 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
1106
1254
  *
1107
1255
  * If the project doesn't exist, throws with an actionable error.
1108
1256
  */
1257
+ /**
1258
+ * Which topology a already-running stack was built with, read back from its
1259
+ * generated compose file. `direct-internet` is the one where fw-main holds a leg
1260
+ * on `isp-external`; everything else is `default`.
1261
+ */
1262
+ function topologyFromComposeFile(composePath: string): TopologyPreset {
1263
+ try {
1264
+ const compose = parseYaml(readFileSync(composePath, 'utf-8')) as {
1265
+ services?: Record<string, { networks?: Record<string, unknown> }>;
1266
+ };
1267
+ return compose.services?.['fw-main']?.networks?.['isp-external']
1268
+ ? 'direct-internet'
1269
+ : 'default';
1270
+ } catch {
1271
+ // An unreadable compose file means the stack was not built by this
1272
+ // generator; `default` is the topology the fleet runs and the safer guess.
1273
+ return 'default';
1274
+ }
1275
+ }
1276
+
1109
1277
  export function reconnectNetwork(projectName: string): NetworkHandle {
1110
1278
  const composeDir = PACKAGE_ROOT;
1111
1279
 
@@ -1128,5 +1296,14 @@ export function reconnectNetwork(projectName: string): NetworkHandle {
1128
1296
  console.log(`[progress:done] reconnected to existing network (${projectName})`);
1129
1297
  console.log(`[e2e:project] ${projectName}`);
1130
1298
 
1131
- return buildNetworkHandle(projectName, composeDir, findCeliloRoot());
1299
+ // `--reuse` has no NetworkConfig to read, so the topology is recovered from the
1300
+ // compose file that built the running stack — the same artifact the generator
1301
+ // wrote. Guessing a default here would silently mis-declare the firewall's legs
1302
+ // on exactly the runs used for debugging.
1303
+ return buildNetworkHandle(
1304
+ projectName,
1305
+ composeDir,
1306
+ findCeliloRoot(),
1307
+ topologyFromComposeFile(join(composeDir, COMPOSE_FILE)),
1308
+ );
1132
1309
  }
@@ -7,6 +7,7 @@ import type { MachineSpec, NetworkConfig, TopologyPreset, Zone } from './types';
7
7
  import {
8
8
  ZONE_GATEWAYS,
9
9
  ZONE_SUBNETS,
10
+ externalWanIp,
10
11
  greenwaveRouterIp,
11
12
  internalResolverIp,
12
13
  zoneIp,
@@ -146,16 +147,50 @@ const PROTECTED_SUBNETS = (['dmz', 'app', 'secure', 'secure-mgmt'] as const)
146
147
  .map((zone) => ZONE_SUBNETS[zone])
147
148
  .join(' ');
148
149
 
150
+ /**
151
+ * The zone legs fw-main actually holds, per topology — the SINGLE source of truth
152
+ * for "which networks is the customer firewall wired to".
153
+ *
154
+ * This exists because `deployFirewall`'s `opts.zones` was doing two jobs at once:
155
+ * saying which zones get `provided_networks` written to system config, AND
156
+ * standing in as a claim about the box's hardware. It was only ever the first.
157
+ * The compose file below is what decides the second, and roughly twenty call
158
+ * sites passed `['dmz']` while the generator wired four legs regardless — so
159
+ * every one of them under-declared, silently.
160
+ *
161
+ * Both the compose services and `deployFirewall` read this, so a test cannot
162
+ * under-declare by omission and a new leg cannot be added in one place only.
163
+ * `packages/e2e/tests/firewall-zone-legs.test.ts` holds them to each other.
164
+ */
165
+ export type FirewallLeg = Zone | 'external';
166
+
167
+ export function firewallZoneLegs(topology: TopologyPreset): FirewallLeg[] {
168
+ const segmented: FirewallLeg[] = ['internal', 'dmz', 'app', 'secure'];
169
+ return topology === 'direct-internet' ? [...segmented, 'external'] : segmented;
170
+ }
171
+
172
+ /**
173
+ * The compose `networks:` block for fw-main, derived from {@link firewallZoneLegs}
174
+ * so the wiring and the declaration cannot drift. `external` is the public leg and
175
+ * lives on the `isp-external` docker network rather than a zone-named one.
176
+ */
177
+ function firewallNetworks(topology: TopologyPreset): Record<string, { ipv4_address: string }> {
178
+ const nets: Record<string, { ipv4_address: string }> = {};
179
+ for (const zone of firewallZoneLegs(topology)) {
180
+ if (zone === 'external') {
181
+ nets['isp-external'] = { ipv4_address: externalWanIp() };
182
+ } else {
183
+ nets[zone] = { ipv4_address: ZONE_GATEWAYS[zone] };
184
+ }
185
+ }
186
+ return nets;
187
+ }
188
+
149
189
  function defaultTopologyServices(config: NetworkConfig): Record<string, unknown> {
150
190
  return {
151
191
  'fw-main': baseService({
152
192
  build: { context: '.', dockerfile: 'docker/Dockerfile.firewall' },
153
- networks: {
154
- internal: { ipv4_address: ZONE_GATEWAYS.internal },
155
- dmz: { ipv4_address: ZONE_GATEWAYS.dmz },
156
- app: { ipv4_address: ZONE_GATEWAYS.app },
157
- secure: { ipv4_address: ZONE_GATEWAYS.secure },
158
- },
193
+ networks: firewallNetworks('default'),
159
194
  cap_add: ['NET_ADMIN', 'SYS_ADMIN'],
160
195
  // A firewall may also terminate a VPN (the admin tunnel the `wireguard`
161
196
  // module owns). wireguard-go needs the tun device; the kernel module is
@@ -169,7 +204,7 @@ function defaultTopologyServices(config: NetworkConfig): Record<string, unknown>
169
204
  build: { context: '.', dockerfile: 'docker/Dockerfile.greenwave-sim' },
170
205
  networks: {
171
206
  internal: { ipv4_address: greenwaveRouterIp() },
172
- 'isp-external': { ipv4_address: '100.100.0.100' },
207
+ 'isp-external': { ipv4_address: '203.0.113.100' },
173
208
  },
174
209
  cap_add: ['NET_ADMIN', 'SYS_ADMIN'],
175
210
  // A firewall may also terminate a VPN (the admin tunnel the `wireguard`
@@ -194,13 +229,7 @@ function directInternetTopologyServices(): Record<string, unknown> {
194
229
  return {
195
230
  'fw-main': baseService({
196
231
  build: { context: '.', dockerfile: 'docker/Dockerfile.firewall' },
197
- networks: {
198
- internal: { ipv4_address: ZONE_GATEWAYS.internal },
199
- dmz: { ipv4_address: ZONE_GATEWAYS.dmz },
200
- app: { ipv4_address: ZONE_GATEWAYS.app },
201
- secure: { ipv4_address: ZONE_GATEWAYS.secure },
202
- 'isp-external': { ipv4_address: '100.100.0.100' },
203
- },
232
+ networks: firewallNetworks('direct-internet'),
204
233
  cap_add: ['NET_ADMIN', 'SYS_ADMIN'],
205
234
  // A firewall may also terminate a VPN (the admin tunnel the `wireguard`
206
235
  // module owns). wireguard-go needs the tun device; the kernel module is
@@ -302,7 +331,7 @@ export function generateSharedInfraYaml(): string {
302
331
  build: { context: '.', dockerfile: 'docker/Dockerfile.pebble' },
303
332
  networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.PEBBLE } },
304
333
  cap_add: ['NET_ADMIN'],
305
- command: '-config /config/pebble-config.json -dnsserver 100.100.0.1:53',
334
+ command: '-config /config/pebble-config.json -dnsserver 203.0.113.1:53',
306
335
  volumes: [
307
336
  './config/pebble/pebble-config.json:/config/pebble-config.json:ro',
308
337
  './config/pebble/pebble-tls.crt:/config/pebble-tls.crt:ro',
@@ -325,7 +354,7 @@ export function generateSharedInfraYaml(): string {
325
354
  // celilo's `public_dns` check resolves from.
326
355
  //
327
356
  // A peer of comcast-resolver, not a replacement: the fleet is configured to
328
- // use THAT one (`system init` writes dns.primary=100.100.0.1), and celilo
357
+ // use THAT one (`system init` writes dns.primary=203.0.113.1), and celilo
329
358
  // refuses to accept a resolver it already asks as evidence about the public
330
359
  // internet — a check that used the fleet's own resolver would pass whatever
331
360
  // the internet sees, which is the celilo#626 blindness one layer up. So the
@@ -357,13 +386,13 @@ export function generateSharedInfraYaml(): string {
357
386
  // External website prober simulator — mimics isitup.org's /api.json endpoint.
358
387
  // Sits on internet-external so probe requests exercise the real public-facing
359
388
  // path (target domain → DNS → ISP → firewall → Caddy). Uses comcast-resolver
360
- // at 100.100.0.1 as DNS so it can recursively resolve the simulated public
389
+ // at 203.0.113.1 as DNS so it can recursively resolve the simulated public
361
390
  // zones (e.g. iamtheinternet.org).
362
391
  services.isitup = baseService({
363
392
  build: { context: '.', dockerfile: 'docker/Dockerfile.isitup' },
364
393
  networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.ISITUP } },
365
394
  cap_add: ['NET_ADMIN'],
366
- dns: ['100.100.0.1'],
395
+ dns: ['203.0.113.1'],
367
396
  });
368
397
 
369
398
  // celilo.computer static site simulator — serves install.sh and the docs
@@ -375,7 +404,7 @@ export function generateSharedInfraYaml(): string {
375
404
  build: { context: '.', dockerfile: 'docker/Dockerfile.celilo-website-sim' },
376
405
  networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.WEBSITE } },
377
406
  cap_add: ['NET_ADMIN'],
378
- dns: ['100.100.0.1'],
407
+ dns: ['203.0.113.1'],
379
408
  });
380
409
 
381
410
  // npm-compat registry simulator — serves @celilo/* tarballs to install.sh's
@@ -452,7 +481,7 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
452
481
  // Only when celilo-mgr lives off the internal LAN — keeps the default
453
482
  // topology's generated compose byte-identical for every existing test.
454
483
  ...(needsSecureMgmt ? { 'secure-mgmt': zoneNetworkDef('secure-mgmt') } : {}),
455
- 'isp-external': networkDef('100.100.0.0/24', '100.100.0.250'),
484
+ 'isp-external': networkDef('203.0.113.0/24', '203.0.113.250'),
456
485
  // internet-external is owned by shared infra; real-internet is per-test
457
486
  // (it just bridges fw-ext ↔ comcast-resolver, no shared service uses it).
458
487
  // real-internet uses Docker's default gateway (.1) because fw-ext's
@@ -588,7 +617,7 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
588
617
  services['fw-ext'] = baseService({
589
618
  build: { context: '.', dockerfile: 'docker/Dockerfile.router' },
590
619
  networks: {
591
- 'isp-external': { ipv4_address: '100.100.0.101' },
620
+ 'isp-external': { ipv4_address: '203.0.113.101' },
592
621
  'internet-external': { ipv4_address: '100.64.0.1' },
593
622
  'real-internet': { ipv4_address: '172.30.0.5' },
594
623
  },
@@ -599,7 +628,7 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
599
628
  services['comcast-resolver'] = baseService({
600
629
  build: { context: '.', dockerfile: 'docker/Dockerfile.resolver' },
601
630
  networks: {
602
- 'isp-external': { ipv4_address: '100.100.0.1' },
631
+ 'isp-external': { ipv4_address: '203.0.113.1' },
603
632
  'real-internet': { ipv4_address: '172.30.0.4' },
604
633
  },
605
634
  cap_add: ['NET_ADMIN'],
@@ -720,7 +749,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
720
749
  dmz: zoneNetworkDef('dmz'),
721
750
  app: zoneNetworkDef('app'),
722
751
  secure: zoneNetworkDef('secure'),
723
- 'isp-external': networkDef('100.100.0.0/24', '100.100.0.250'),
752
+ 'isp-external': networkDef('203.0.113.0/24', '203.0.113.250'),
724
753
  'internet-external': networkDef('100.64.0.0/24', '100.64.0.250'),
725
754
  'real-internet': {
726
755
  driver: 'bridge',
@@ -774,7 +803,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
774
803
  services['fw-ext'] = baseService({
775
804
  build: { context: '.', dockerfile: 'docker/Dockerfile.router' },
776
805
  networks: {
777
- 'isp-external': { ipv4_address: '100.100.0.101' },
806
+ 'isp-external': { ipv4_address: '203.0.113.101' },
778
807
  'internet-external': { ipv4_address: '100.64.0.1' },
779
808
  'real-internet': { ipv4_address: '172.30.0.5' },
780
809
  },
@@ -785,7 +814,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
785
814
  services['comcast-resolver'] = baseService({
786
815
  build: { context: '.', dockerfile: 'docker/Dockerfile.resolver' },
787
816
  networks: {
788
- 'isp-external': { ipv4_address: '100.100.0.1' },
817
+ 'isp-external': { ipv4_address: '203.0.113.1' },
789
818
  'real-internet': { ipv4_address: '172.30.0.4' },
790
819
  },
791
820
  cap_add: ['NET_ADMIN'],
@@ -847,7 +876,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
847
876
  build: { context: '.', dockerfile: 'docker/Dockerfile.pebble' },
848
877
  networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.PEBBLE } },
849
878
  cap_add: ['NET_ADMIN'],
850
- command: '-config /config/pebble-config.json -dnsserver 100.100.0.1:53',
879
+ command: '-config /config/pebble-config.json -dnsserver 203.0.113.1:53',
851
880
  volumes: [
852
881
  './config/pebble/pebble-config.json:/config/pebble-config.json:ro',
853
882
  './config/pebble/pebble-tls.crt:/config/pebble-tls.crt:ro',