@celilo/e2e 0.12.0 → 0.13.0

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 (45) 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/package.json +4 -3
  19. package/simulators/greenwave/server.ts +35 -0
  20. package/simulators/greenwave/state.ts +64 -11
  21. package/simulators/ip-echo/server.ts +1 -1
  22. package/src/address-plan.test.ts +4 -4
  23. package/src/cli/build.ts +30 -0
  24. package/src/cli/command-registry.ts +19 -0
  25. package/src/cli/completion.ts +16 -1
  26. package/src/cli/index.ts +85 -4
  27. package/src/container-manager.ts +75 -12
  28. package/src/docker-compose-generator.ts +55 -26
  29. package/src/doctor.test.ts +279 -0
  30. package/src/doctor.ts +421 -0
  31. package/src/extract-failure.ts +41 -0
  32. package/src/index.ts +12 -0
  33. package/src/last-run.test.ts +62 -0
  34. package/src/last-run.ts +54 -0
  35. package/src/observer.test.ts +2 -2
  36. package/src/observer.ts +1 -1
  37. package/src/router-swap.ts +121 -0
  38. package/src/run-lock.test.ts +73 -2
  39. package/src/run-lock.ts +110 -14
  40. package/src/runner.ts +99 -5
  41. package/src/socks-proxy.ts +2 -2
  42. package/src/types.ts +25 -4
  43. package/src/vantage.test.ts +1 -1
  44. package/src/zone-classifier.test.ts +11 -16
  45. package/src/zone-classifier.ts +16 -31
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,6 +529,7 @@ 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
@@ -598,10 +613,21 @@ function buildNetworkHandle(
598
613
  },
599
614
 
600
615
  async deployFirewall(opts = {}): Promise<void> {
601
- const zones = opts.zones ?? ['dmz', 'app', 'secure'];
616
+ // `opts.zones` says which zones get `provided_networks` written to system
617
+ // config. It does NOT say which NICs the box has — the compose topology
618
+ // decides that, and it wires fw-main to every segmented zone regardless of
619
+ // what a test passed. Roughly twenty call sites passed `['dmz']` against a
620
+ // four-leg firewall, so every one of them under-declared the hardware.
621
+ //
622
+ // Deriving the declared list from the topology makes under-declaring
623
+ // structurally impossible rather than something each future test author has
624
+ // to remember, and removes one more hand-maintained copy of a list the
625
+ // compose generator already owns.
626
+ const providedZones = opts.zones ?? ['dmz', 'app', 'secure'];
627
+ const declaredZones = firewallZoneLegs(topology);
602
628
  const firewallIp = ZONE_GATEWAYS.internal; // fw-main on internal
603
629
  const natIp = opts.natIp ?? internalNatIp();
604
- const providedNetworks = zones.map((zone) => ({
630
+ const providedNetworks = providedZones.map((zone) => ({
605
631
  zone,
606
632
  subnet: ZONE_SUBNETS[zone],
607
633
  gateway: ZONE_GATEWAYS[zone],
@@ -637,15 +663,23 @@ function buildNetworkHandle(
637
663
  await handle.celilo('module import iptables');
638
664
  await handle.celilo(`module config set iptables firewall_ip ${firewallIp}`);
639
665
  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
- );
666
+ await handle.celilo(`module config set iptables zones '${JSON.stringify(declaredZones)}'`);
643
667
  await handle.celilo(
644
668
  `module config set iptables provided_networks '${JSON.stringify(providedNetworks)}'`,
645
669
  );
646
670
  await handle.celilo('module deploy iptables', 180_000);
647
671
  },
648
672
 
673
+ async deployGreenwave(): Promise<void> {
674
+ const routerIp = greenwaveRouterIp();
675
+ await handle.celilo(`machine add ${routerIp} --ssh-user root --earmark greenwave`);
676
+ await handle.celilo('module import greenwave');
677
+ await handle.celilo(`module config set greenwave router_ip ${routerIp}`);
678
+ await handle.celilo('module secret set greenwave router_username admin');
679
+ await handle.celilo('module secret set greenwave router_password admin');
680
+ await handle.celilo('module deploy greenwave', 180_000);
681
+ },
682
+
649
683
  async socksProxy(options: ProxyOptions = {}): Promise<SocksProxyHandle> {
650
684
  const proxy = await startSocksProxy(projectName, options);
651
685
  activeProxies.push(proxy);
@@ -889,7 +923,7 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
889
923
  // means the test is replacing the infra resolver with a module under test
890
924
  // (e.g., knot-unbound-internal). The bare target machine has no DNS server
891
925
  // 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
926
+ // can still resolve names via the fallback `nameserver 203.0.113.1` in
893
927
  // its resolv.conf during deploy.
894
928
  //
895
929
  // The resolver-under-test may sit in ANY zone, not just `internal`: ISS-0156
@@ -1063,7 +1097,7 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
1063
1097
  config.managementZone === 'secure-mgmt' || (config.secureMgmtMachines ?? []).length > 0
1064
1098
  ? `network.secure-mgmt.subnet=${ZONE_SUBNETS['secure-mgmt']} network.secure-mgmt.gateway=${ZONE_GATEWAYS['secure-mgmt']} `
1065
1099
  : ''
1066
- }dns.primary=100.100.0.1 \
1100
+ }dns.primary=203.0.113.1 \
1067
1101
  dns.fallback=1.0.0.1,8.8.8.8`,
1068
1102
  );
1069
1103
  if (initResult.exitCode !== 0) {
@@ -1095,7 +1129,7 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
1095
1129
  // Emit project name so the runner can persist it if --keep is set
1096
1130
  console.log(`[e2e:project] ${projectName}`);
1097
1131
 
1098
- return buildNetworkHandle(projectName, composeDir, celiloRoot);
1132
+ return buildNetworkHandle(projectName, composeDir, celiloRoot, config.topology);
1099
1133
  }
1100
1134
 
1101
1135
  /**
@@ -1106,6 +1140,26 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
1106
1140
  *
1107
1141
  * If the project doesn't exist, throws with an actionable error.
1108
1142
  */
1143
+ /**
1144
+ * Which topology a already-running stack was built with, read back from its
1145
+ * generated compose file. `direct-internet` is the one where fw-main holds a leg
1146
+ * on `isp-external`; everything else is `default`.
1147
+ */
1148
+ function topologyFromComposeFile(composePath: string): TopologyPreset {
1149
+ try {
1150
+ const compose = parseYaml(readFileSync(composePath, 'utf-8')) as {
1151
+ services?: Record<string, { networks?: Record<string, unknown> }>;
1152
+ };
1153
+ return compose.services?.['fw-main']?.networks?.['isp-external']
1154
+ ? 'direct-internet'
1155
+ : 'default';
1156
+ } catch {
1157
+ // An unreadable compose file means the stack was not built by this
1158
+ // generator; `default` is the topology the fleet runs and the safer guess.
1159
+ return 'default';
1160
+ }
1161
+ }
1162
+
1109
1163
  export function reconnectNetwork(projectName: string): NetworkHandle {
1110
1164
  const composeDir = PACKAGE_ROOT;
1111
1165
 
@@ -1128,5 +1182,14 @@ export function reconnectNetwork(projectName: string): NetworkHandle {
1128
1182
  console.log(`[progress:done] reconnected to existing network (${projectName})`);
1129
1183
  console.log(`[e2e:project] ${projectName}`);
1130
1184
 
1131
- return buildNetworkHandle(projectName, composeDir, findCeliloRoot());
1185
+ // `--reuse` has no NetworkConfig to read, so the topology is recovered from the
1186
+ // compose file that built the running stack — the same artifact the generator
1187
+ // wrote. Guessing a default here would silently mis-declare the firewall's legs
1188
+ // on exactly the runs used for debugging.
1189
+ return buildNetworkHandle(
1190
+ projectName,
1191
+ composeDir,
1192
+ findCeliloRoot(),
1193
+ topologyFromComposeFile(join(composeDir, COMPOSE_FILE)),
1194
+ );
1132
1195
  }
@@ -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',