@celilo/e2e 0.9.3 → 0.10.2

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 (62) hide show
  1. package/config/cpanel-host/cpanel-host-ca.crt +20 -0
  2. package/config/cpanel-host/docroot/.htaccess +14 -0
  3. package/config/cpanel-host/docroot/cms.html +4 -0
  4. package/config/cpanel-host/site-tls.crt +20 -0
  5. package/config/cpanel-host/site-tls.key +28 -0
  6. package/config/cpanel-host/site.conf +27 -0
  7. package/config/dns/com.zone +7 -0
  8. package/config/dns/knot-namecheap.conf +6 -0
  9. package/config/dns/tangohost.com.zone +14 -0
  10. package/config/resolver/unbound.conf +26 -0
  11. package/config/routing/fw-ext-routes.sh +16 -1
  12. package/config/routing/management-routes.sh +14 -4
  13. package/config/routing/minio-startup.sh +9 -0
  14. package/config/routing/public-sim-entrypoint.sh +24 -0
  15. package/config/routing/resolver-internal-routes.sh +10 -0
  16. package/docker/Dockerfile.apt-repo-sim +8 -0
  17. package/docker/Dockerfile.cpanel-host-sim +75 -0
  18. package/docker/Dockerfile.firewall +12 -0
  19. package/docker/Dockerfile.management +6 -0
  20. package/docker/Dockerfile.minio +7 -0
  21. package/docker/Dockerfile.npm-registry-sim +8 -0
  22. package/docker/Dockerfile.observer +11 -0
  23. package/docker/Dockerfile.registry +8 -0
  24. package/docker/Dockerfile.signal-cli +60 -0
  25. package/docker/Dockerfile.signal-release +44 -0
  26. package/docker/Dockerfile.signal-sim +17 -0
  27. package/docker/Dockerfile.target-machine +9 -0
  28. package/package.json +3 -3
  29. package/registry-server/package.json +17 -0
  30. package/registry-server/src/auth.test.ts +76 -0
  31. package/registry-server/src/auth.ts +105 -0
  32. package/registry-server/src/bootstrap-packaging.test.ts +71 -0
  33. package/registry-server/src/bootstrap.ts +250 -0
  34. package/registry-server/src/index.ts +16 -0
  35. package/registry-server/src/introspection.test.ts +247 -0
  36. package/registry-server/src/introspection.ts +204 -0
  37. package/registry-server/src/landing.ts +90 -0
  38. package/registry-server/src/module-owner-store.test.ts +85 -0
  39. package/registry-server/src/module-owner-store.ts +108 -0
  40. package/registry-server/src/rate-limit.test.ts +62 -0
  41. package/registry-server/src/rate-limit.ts +83 -0
  42. package/registry-server/src/scoped-token-store.test.ts +93 -0
  43. package/registry-server/src/scoped-token-store.ts +110 -0
  44. package/registry-server/src/server.test.ts +771 -0
  45. package/registry-server/src/server.ts +701 -0
  46. package/registry-server/src/storage.test.ts +148 -0
  47. package/registry-server/src/storage.ts +150 -0
  48. package/registry-server/src/validation.test.ts +86 -0
  49. package/registry-server/src/validation.ts +60 -0
  50. package/registry-server/tsconfig.json +15 -0
  51. package/scripts/stage-libsignal.ts +116 -0
  52. package/simulators/signal-cli/server.ts +328 -0
  53. package/src/cli/build.ts +7 -0
  54. package/src/cli/index.ts +6 -0
  55. package/src/container-manager.ts +47 -10
  56. package/src/docker-compose-generator.ts +120 -16
  57. package/src/exit-cleanup.test.ts +56 -0
  58. package/src/index.ts +7 -0
  59. package/src/network-builder.ts +66 -0
  60. package/src/public-sim-routes.test.ts +117 -0
  61. package/src/simulator-ips.ts +9 -0
  62. package/src/types.ts +71 -1
@@ -79,6 +79,8 @@ function baseService(opts: {
79
79
  tmpfs?: string[];
80
80
  privileged?: boolean;
81
81
  dns?: string[];
82
+ /** Host devices to expose, e.g. `/dev/net/tun` for a userspace WireGuard tunnel. */
83
+ devices?: string[];
82
84
  }) {
83
85
  // Auto-assign an image tag from the Dockerfile name so Docker can
84
86
  // skip builds when pre-built images exist.
@@ -109,6 +111,16 @@ export const SHARED_NETWORKS = {
109
111
  * Default topology: fw-isp (greenwave) + fw-main (iptables)
110
112
  * Two-layer NAT: internet → fw-isp → fw-main → target
111
113
  */
114
+ /**
115
+ * celilo's own control-plane network, used only when `managementZone` is
116
+ * 'secure-mgmt'. Deliberately NOT one of the segmented data-plane tiers: it is
117
+ * where celilo-mgr itself lives, mirroring the production topology the suite
118
+ * previously could not express.
119
+ */
120
+ const SECURE_MGMT_SUBNET = '10.0.120.0/24';
121
+ const SECURE_MGMT_GATEWAY = '10.0.120.1';
122
+ const SECURE_MGMT_MANAGEMENT_IP = '10.0.120.100';
123
+
112
124
  function defaultTopologyServices(): Record<string, unknown> {
113
125
  return {
114
126
  'fw-main': baseService({
@@ -120,6 +132,10 @@ function defaultTopologyServices(): Record<string, unknown> {
120
132
  secure: { ipv4_address: '10.0.30.1' },
121
133
  },
122
134
  cap_add: ['NET_ADMIN', 'SYS_ADMIN'],
135
+ // A firewall may also terminate a VPN (the admin tunnel the `wireguard`
136
+ // module owns). wireguard-go needs the tun device; the kernel module is
137
+ // deliberately not relied on — see Dockerfile.firewall.
138
+ devices: ['/dev/net/tun'],
123
139
  sysctls: ROUTER_SYSCTLS,
124
140
  volumes: ['ssh-keys:/ssh-keys:ro'],
125
141
  }),
@@ -130,6 +146,10 @@ function defaultTopologyServices(): Record<string, unknown> {
130
146
  'isp-external': { ipv4_address: '100.100.0.100' },
131
147
  },
132
148
  cap_add: ['NET_ADMIN', 'SYS_ADMIN'],
149
+ // A firewall may also terminate a VPN (the admin tunnel the `wireguard`
150
+ // module owns). wireguard-go needs the tun device; the kernel module is
151
+ // deliberately not relied on — see Dockerfile.firewall.
152
+ devices: ['/dev/net/tun'],
133
153
  sysctls: ROUTER_SYSCTLS,
134
154
  volumes: ['ssh-keys:/ssh-keys:ro'],
135
155
  }),
@@ -153,6 +173,10 @@ function directInternetTopologyServices(): Record<string, unknown> {
153
173
  'isp-external': { ipv4_address: '100.100.0.100' },
154
174
  },
155
175
  cap_add: ['NET_ADMIN', 'SYS_ADMIN'],
176
+ // A firewall may also terminate a VPN (the admin tunnel the `wireguard`
177
+ // module owns). wireguard-go needs the tun device; the kernel module is
178
+ // deliberately not relied on — see Dockerfile.firewall.
179
+ devices: ['/dev/net/tun'],
156
180
  sysctls: ROUTER_SYSCTLS,
157
181
  volumes: ['ssh-keys:/ssh-keys:ro'],
158
182
  }),
@@ -232,6 +256,7 @@ export function generateSharedInfraYaml(): string {
232
256
  './config/dns/celilo.computer.zone:/seed/celilo.computer.zone:ro',
233
257
  './config/dns/example.net.zone:/seed/example.net.zone:ro',
234
258
  './config/dns/park-your-domain.com.zone:/seed/park-your-domain.com.zone:ro',
259
+ './config/dns/tangohost.com.zone:/seed/tangohost.com.zone:ro',
235
260
  ],
236
261
  });
237
262
 
@@ -292,6 +317,17 @@ export function generateSharedInfraYaml(): string {
292
317
  // packages/e2e/scripts/pack-celilo-packages.ts (cli, capabilities,
293
318
  // cli-display, e2e, event-bus). Reachable at http://npm-registry.lab from
294
319
  // any container that uses the internal resolver.
320
+ // OFF-FLEET web host (external_web). A PUBLIC PEER on internet-external, not
321
+ // a fleet member and not behind the customer firewall: celilo reaches it by
322
+ // SSH egress the way it would reach any host on the real internet, and public
323
+ // DNS resolves tangohost.com straight to this address. It runs its own Apache
324
+ // and its own pre-installed TLS — celilo governs none of it.
325
+ services['cpanel-host'] = baseService({
326
+ build: { context: '.', dockerfile: 'docker/Dockerfile.cpanel-host-sim' },
327
+ networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.CPANEL_HOST } },
328
+ cap_add: ['NET_ADMIN'],
329
+ });
330
+
295
331
  services['npm-registry'] = baseService({
296
332
  build: { context: '.', dockerfile: 'docker/Dockerfile.npm-registry-sim' },
297
333
  networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.NPM_REGISTRY } },
@@ -337,11 +373,21 @@ export function generateSharedInfraYaml(): string {
337
373
  * Creates its own internal, dmz, app, secure networks.
338
374
  */
339
375
  export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: string): string {
376
+ const mgmtOnOwnNetwork = config.managementZone === 'secure-mgmt';
377
+ // The control-plane network exists if celilo-mgr lives there OR if any
378
+ // machine does — a module declaring `zone: secure-mgmt` needs somewhere to
379
+ // land whether or not the management box shares the network (#436).
380
+ const needsSecureMgmt = mgmtOnOwnNetwork || (config.secureMgmtMachines ?? []).length > 0;
340
381
  const networks: Record<string, unknown> = {
341
382
  internal: networkDef('192.168.0.0/24', '192.168.0.250'),
342
383
  dmz: networkDef('10.0.10.0/24', '10.0.10.250'),
343
384
  app: networkDef('10.0.20.0/24', '10.0.20.250'),
344
385
  secure: networkDef('10.0.30.0/24', '10.0.30.250'),
386
+ // Only when celilo-mgr lives off the internal LAN — keeps the default
387
+ // topology's generated compose byte-identical for every existing test.
388
+ ...(needsSecureMgmt
389
+ ? { 'secure-mgmt': networkDef(SECURE_MGMT_SUBNET, '10.0.120.250') }
390
+ : {}),
345
391
  'isp-external': networkDef('100.100.0.0/24', '100.100.0.250'),
346
392
  // internet-external is owned by shared infra; real-internet is per-test
347
393
  // (it just bridges fw-ext ↔ comcast-resolver, no shared service uses it).
@@ -392,11 +438,15 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
392
438
  // first. container-manager's celilo-init + routing-verify target the
393
439
  // `management` container by name, so `celilo-mgr-2` comes up fresh and
394
440
  // un-init'd — the empty target restore needs.
395
- const emitManagement = (variant: 'default' | 'vanilla' | undefined, ip: string) =>
441
+ const emitManagement = (
442
+ variant: 'default' | 'vanilla' | undefined,
443
+ ip: string,
444
+ zone: 'internal' | 'secure-mgmt' = 'internal',
445
+ ) =>
396
446
  baseService({
397
447
  image:
398
448
  variant === 'vanilla' ? 'celilo-e2e/management:vanilla' : 'celilo-e2e/management:latest',
399
- networks: { internal: { ipv4_address: ip } },
449
+ networks: { [zone]: { ipv4_address: ip } },
400
450
  cap_add: ['NET_ADMIN'],
401
451
  // ISS-0157: `module import`'s `bun install` hard-links packages from the
402
452
  // bun cache into node_modules, which on overlayfs forces a copy-up + fsync
@@ -416,12 +466,17 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
416
466
  ],
417
467
  environment: {
418
468
  DDNS_ENDPOINT: `http://${SIMULATOR_IPS.NAMECHEAP_DNS}:8080/update`,
419
- DEFAULT_GATEWAY: managementDefaultGw,
469
+ // On its own control-plane network the only router in reach is fw-main's
470
+ // leg there — both for the default route and for the segmented zones.
471
+ DEFAULT_GATEWAY: zone === 'secure-mgmt' ? SECURE_MGMT_GATEWAY : managementDefaultGw,
472
+ ...(zone === 'secure-mgmt' ? { FW_MAIN_HOP: SECURE_MGMT_GATEWAY } : {}),
420
473
  CELILO_REGISTRY_URL: 'http://e2e-registry.lab',
421
474
  },
422
475
  });
423
476
 
424
- services.management = emitManagement(config.managementVariant, '192.168.0.100');
477
+ services.management = mgmtOnOwnNetwork
478
+ ? emitManagement(config.managementVariant, SECURE_MGMT_MANAGEMENT_IP, 'secure-mgmt')
479
+ : emitManagement(config.managementVariant, '192.168.0.100');
425
480
 
426
481
  if (config.secondaryManagementVariant) {
427
482
  services['celilo-mgr-2'] = emitManagement(config.secondaryManagementVariant, '192.168.0.101');
@@ -451,6 +506,17 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
451
506
  const topologyFn = TOPOLOGY_SERVICES[config.topology] || TOPOLOGY_SERVICES.default;
452
507
  Object.assign(services, topologyFn());
453
508
 
509
+ // Give fw-main a leg on the control-plane network when celilo-mgr lives there,
510
+ // so the management box is routed like any other segmented network rather than
511
+ // being stranded. Applied as a post-step so the default topology's generated
512
+ // compose is untouched.
513
+ if (needsSecureMgmt) {
514
+ const fwMain = services['fw-main'] as { networks?: Record<string, unknown> } | undefined;
515
+ if (fwMain?.networks) {
516
+ fwMain.networks['secure-mgmt'] = { ipv4_address: SECURE_MGMT_GATEWAY };
517
+ }
518
+ }
519
+
454
520
  // --- Routing infrastructure (bridges per-test to shared infra) ---
455
521
  services['fw-ext'] = baseService({
456
522
  build: { context: '.', dockerfile: 'docker/Dockerfile.router' },
@@ -473,12 +539,7 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
473
539
  });
474
540
 
475
541
  // --- Dynamic test machines ---
476
- const allMachines = [
477
- ...config.dmzMachines,
478
- ...config.appMachines,
479
- ...config.secureMachines,
480
- ...config.internalMachines,
481
- ];
542
+ const allMachines = getAllMachines(config);
482
543
 
483
544
  for (const machine of allMachines) {
484
545
  const zoneNetwork = machine.zone;
@@ -518,6 +579,49 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
518
579
  });
519
580
  }
520
581
 
582
+ // --- Optional real signal-cli daemon ---
583
+ // On `internal`, matching the signal module's declared zone — it is a local
584
+ // service celilo talks to, not an internet-facing one, so it does not belong
585
+ // on internet-external. Unlinked: the JSON-RPC surface needs no account.
586
+ if (config.signalCli) {
587
+ services['signal-cli'] = baseService({
588
+ build: { context: '.', dockerfile: 'docker/Dockerfile.signal-cli' },
589
+ networks: { internal: { ipv4_address: '192.168.0.90' } },
590
+ cap_add: ['NET_ADMIN'],
591
+ });
592
+ }
593
+
594
+ // --- Optional signal-cli release host ---
595
+ // On internet-external because that is what it models: an artifact the
596
+ // module downloads FROM the internet at deploy time.
597
+ if (config.signalRelease) {
598
+ services['signal-release'] = baseService({
599
+ build: { context: '.', dockerfile: 'docker/Dockerfile.signal-release' },
600
+ networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.SIGNAL_RELEASE } },
601
+ cap_add: ['NET_ADMIN'],
602
+ });
603
+ }
604
+
605
+ // --- Optional signal-cli simulator ---
606
+ // Same network placement as the real daemon so a test can swap one for the
607
+ // other without touching the module's configured endpoint.
608
+ if (config.signalSim) {
609
+ services['signal-sim'] = baseService({
610
+ build: { context: '.', dockerfile: 'docker/Dockerfile.signal-sim' },
611
+ networks: { internal: { ipv4_address: '192.168.0.91' } },
612
+ cap_add: ['NET_ADMIN'],
613
+ environment: {
614
+ SIGNAL_ACCOUNT: '+15551234567',
615
+ SIGNAL_KNOWN_RECIPIENTS: '+15550001,+15550002',
616
+ // Mirrors the flag the module's systemd unit must pass. Without it the
617
+ // simulator refuses `receive`, exactly as the real daemon does — which
618
+ // is the point: a test may only read replies from a daemon started the
619
+ // way celilo has to deploy it.
620
+ SIGNAL_RECEIVE_MODE: 'manual',
621
+ },
622
+ });
623
+ }
624
+
521
625
  // --- Observer vantages (passive spies; ISS-0117) ---
522
626
  // Each is the SAME image at a different network location with a routing profile that
523
627
  // mirrors a real device there — that routing profile is what makes it a faithful seat
@@ -528,6 +632,9 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
528
632
  build: { context: '.', dockerfile: 'docker/Dockerfile.observer' },
529
633
  networks: { [p.network]: { ipv4_address: p.ip } },
530
634
  cap_add: ['NET_ADMIN'],
635
+ // A vantage may dial a VPN and probe from INSIDE the tunnel — the only way
636
+ // to assert VPN reach by real signal instead of by reading a rule string.
637
+ devices: ['/dev/net/tun'],
531
638
  environment: observerEnv(p),
532
639
  });
533
640
  }
@@ -660,6 +767,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
660
767
  './config/dns/celilo.computer.zone:/seed/celilo.computer.zone:ro',
661
768
  './config/dns/example.net.zone:/seed/example.net.zone:ro',
662
769
  './config/dns/park-your-domain.com.zone:/seed/park-your-domain.com.zone:ro',
770
+ './config/dns/tangohost.com.zone:/seed/tangohost.com.zone:ro',
663
771
  ],
664
772
  });
665
773
 
@@ -691,12 +799,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
691
799
  volumes: getRegistryVolumes(),
692
800
  });
693
801
 
694
- const allMachines = [
695
- ...config.dmzMachines,
696
- ...config.appMachines,
697
- ...config.secureMachines,
698
- ...config.internalMachines,
699
- ];
802
+ const allMachines = getAllMachines(config);
700
803
 
701
804
  for (const machine of allMachines) {
702
805
  const zoneNetwork = machine.zone;
@@ -743,5 +846,6 @@ export function getAllMachines(config: NetworkConfig): MachineSpec[] {
743
846
  ...config.appMachines,
744
847
  ...config.secureMachines,
745
848
  ...config.internalMachines,
849
+ ...(config.secureMgmtMachines ?? []),
746
850
  ];
747
851
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Recurrence gate for #508: a failed e2e run must not leak its Docker networks.
3
+ *
4
+ * The sim's networks use the same subnets as a real celilo fleet, so on a host
5
+ * that lives in those subnets (celilo's forgejo-builder does) a stranded bridge
6
+ * leaves a duplicate route for a production prefix and blackholes later
7
+ * containers' traffic to the real address. Three consecutive release runs died
8
+ * that way — in `git fetch`, ~132s connect timeout — well after the e2e run
9
+ * that stranded the network had finished.
10
+ *
11
+ * The leak's mechanism was a wiring bug, so the gate is on the wiring.
12
+ */
13
+
14
+ import { readFileSync } from 'node:fs';
15
+ import { dirname, join } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { expect, test } from 'bun:test';
18
+
19
+ const SRC = readFileSync(
20
+ join(dirname(fileURLToPath(import.meta.url)), 'container-manager.ts'),
21
+ 'utf-8',
22
+ );
23
+
24
+ test("teardown is wired to 'exit', which is the event an explicit process.exit() fires", () => {
25
+ // `beforeExit` alone was the bug: the runner ends with
26
+ // `process.exit(failed > 0 ? 1 : 0)`, so on the failure path — where the test
27
+ // never reaches handle.stop() — nothing ever tore the network down.
28
+ expect(SRC).toContain("process.on('exit'");
29
+ });
30
+
31
+ test('cleanup honors --keep / --reuse so a failed run stays debuggable', () => {
32
+ // Without this guard, adding the `exit` hook would destroy exactly the stack
33
+ // an operator asked to keep, since --keep matters most after a failure.
34
+ const body = SRC.slice(
35
+ SRC.indexOf('function cleanupOnExit'),
36
+ SRC.indexOf('process.on(\'SIGTERM\''),
37
+ );
38
+ expect(body).toContain('CELILO_E2E_KEEP');
39
+ expect(body).toContain('CELILO_E2E_REUSE');
40
+ });
41
+
42
+ test("the premise holds: process.exit() fires 'exit' but NOT 'beforeExit'", async () => {
43
+ // The whole fix rests on this runtime behavior. If it ever changes, the
44
+ // reasoning above is void and this should fail loudly rather than silently
45
+ // leaving the leak unfixed.
46
+ const proc = Bun.spawn([
47
+ 'bun',
48
+ '-e',
49
+ "process.on('beforeExit', () => console.log('BEFORE_EXIT')); process.on('exit', () => console.log('EXIT')); process.exit(0);",
50
+ ]);
51
+ const out = await new Response(proc.stdout).text();
52
+ await proc.exited;
53
+
54
+ expect(out).toContain('EXIT');
55
+ expect(out).not.toContain('BEFORE_EXIT');
56
+ });
package/src/index.ts CHANGED
@@ -48,6 +48,8 @@ export type {
48
48
  } from './types';
49
49
  export {
50
50
  CeliloCommandError,
51
+ externalWanIp,
52
+ externalWanSubnet,
51
53
  greenwaveRouterIp,
52
54
  internalNatIp,
53
55
  internalResolverIp,
@@ -84,6 +86,11 @@ export {
84
86
  TECHNITIUM_DHCP_STACK,
85
87
  } from './fixtures';
86
88
 
89
+ // Simulator addresses on `internet-external` — canonical for code, so a test
90
+ // asserting against the authoritative DNS server never hardcodes its IP.
91
+ export { SIMULATOR_IPS, SIMULATOR_IP_ENTRIES } from './simulator-ips';
92
+ export type { SimulatorIpName } from './simulator-ips';
93
+
87
94
  // Shared infrastructure management (for test runners)
88
95
  export { ensureSharedInfra, stopSharedInfra, isSharedInfraRunning } from './shared-infra';
89
96
 
@@ -38,7 +38,11 @@ export class NetworkBuilder {
38
38
  appMachines: [],
39
39
  secureMachines: [],
40
40
  internalMachines: [],
41
+ secureMgmtMachines: [],
41
42
  dhcpClient: false,
43
+ signalCli: false,
44
+ signalSim: false,
45
+ signalRelease: false,
42
46
  observers: [],
43
47
  domain: 'iamtheinternet.org',
44
48
  ddnsPassword: 'test123',
@@ -51,6 +55,17 @@ export class NetworkBuilder {
51
55
  return this;
52
56
  }
53
57
 
58
+ /**
59
+ * Place the celilo management container in a given zone. Defaults to
60
+ * `internal`; `secure-mgmt` gives celilo-mgr its own control-plane network
61
+ * (the production topology), so a test can exercise celilo reaching and
62
+ * resolving for a fleet from OFF the internal LAN.
63
+ */
64
+ managementZone(zone: 'internal' | 'secure-mgmt'): this {
65
+ this.config.managementZone = zone;
66
+ return this;
67
+ }
68
+
54
69
  /** Append `{name, ip, zone, ...extra}` for each entry to a zone's machine list. */
55
70
  private addMachines(
56
71
  target: MachineSpec[],
@@ -80,6 +95,19 @@ export class NetworkBuilder {
80
95
  return this.addMachines(this.config.internalMachines, 'internal', machines);
81
96
  }
82
97
 
98
+ /**
99
+ * Add machines on celilo's control plane (`secure-mgmt`).
100
+ *
101
+ * For a module whose manifest declares `zone: secure-mgmt` — the signal
102
+ * transport does, because it holds the operator's Signal identity and must
103
+ * depend on nothing it reports about. Without this the module deploys onto
104
+ * whatever zone a test happened to add a machine in, which is the placement
105
+ * defect #435 is about, or fails selection outright.
106
+ */
107
+ secureMgmt(machines: Record<string, string>): this {
108
+ return this.addMachines(this.config.secureMgmtMachines, 'secure-mgmt', machines);
109
+ }
110
+
83
111
  /** Add internal-zone machines that need the Docker-capable image (e.g., Technitium) */
84
112
  internalDocker(machines: Record<string, string>): this {
85
113
  return this.addMachines(this.config.internalMachines, 'internal', machines, { docker: true });
@@ -97,6 +125,44 @@ export class NetworkBuilder {
97
125
  return this;
98
126
  }
99
127
 
128
+ /**
129
+ * Add a real, UNLINKED signal-cli daemon on the internal network.
130
+ *
131
+ * For verifying the JSON-RPC contract against the actual binary. It cannot
132
+ * send to a phone (no account, and CDSI/SVR2 attestation makes a local
133
+ * Signal substitute impossible), but every endpoint, envelope and error
134
+ * shape celilo's client relies on is checkable without one.
135
+ */
136
+ withSignalCli(): this {
137
+ this.config.signalCli = true;
138
+ return this;
139
+ }
140
+
141
+ /**
142
+ * Add the signal-cli SIMULATOR on the internal network.
143
+ *
144
+ * Use this for anything that drives the delivery or ack loop: unlike the
145
+ * real daemon it exposes a control surface (`/_control/inbound`,
146
+ * `/_control/sent`, `/_control/unlink`) so a test can make a human "reply"
147
+ * and then assert on what celilo did about it.
148
+ */
149
+ withSignalSim(): this {
150
+ this.config.signalSim = true;
151
+ return this;
152
+ }
153
+
154
+ /**
155
+ * Serve the signal-cli release tarball from the simulated internet.
156
+ *
157
+ * Needed to DEPLOY the signal module: its ansible role downloads signal-cli
158
+ * at deploy time, and the e2e network is sealed. Point the module at it with
159
+ * `module config set signal release_base_url http://signal-release.lab`.
160
+ */
161
+ withSignalRelease(): this {
162
+ this.config.signalRelease = true;
163
+ return this;
164
+ }
165
+
100
166
  /**
101
167
  * Inject one or more observer vantages (ISS-0117) — passive spies carrying the probe
102
168
  * toolbox, each placed at its vantage's network location with a routing profile that
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Recurrence gate: every simulator on `internet-external` must route the
3
+ * customer's public prefix via the ISP edge.
4
+ *
5
+ * `internet-external` models the public internet. A host there replies to a
6
+ * customer's public address through its ISP — modelled by defaulting via
7
+ * fw-ext (100.64.0.1). Docker's bridge gateway has no path across networks, so
8
+ * a simulator without that route silently blackholes every reply.
9
+ *
10
+ * This was invisible for as long as fw-ext MASQUERADEd the customer to its own
11
+ * on-link 100.64.0.1: nothing needed routing, so five simulators shipped
12
+ * without it. Removing that second NAT (an ISP routes a subscriber's public
13
+ * prefix, it does not NAT it) turned the omission into a hang — first in
14
+ * `bun add -g` against the sim npm registry, then in the signal module's
15
+ * release download. Both cost a full build-and-run cycle to find.
16
+ *
17
+ * A static check because the honest runtime one is impossible here: fw-ext is
18
+ * per-test, so at shared-infra readiness the route's next hop does not exist
19
+ * yet. Reading the compose the generator actually emits (rather than a
20
+ * hand-kept list) means a NEW simulator is covered the day it is added.
21
+ */
22
+
23
+ import { describe, expect, test } from 'bun:test';
24
+ import { readFileSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+ import { parse } from 'yaml';
27
+ import { generateSharedInfraYaml, generateTestComposeYaml } from './docker-compose-generator';
28
+ import type { NetworkConfig } from './types';
29
+
30
+ /** fw-ext's address on internet-external — the ISP edge every public host exits by. */
31
+ const ISP_EDGE = '100.64.0.1';
32
+
33
+ /** The one container that IS the edge, so it cannot route through itself. */
34
+ const EDGE_SERVICE = 'fw-ext';
35
+
36
+ interface ComposeService {
37
+ build?: { dockerfile?: string };
38
+ networks?: Record<string, unknown>;
39
+ }
40
+
41
+ /** Every optional simulator switched on, so none escapes the sweep. */
42
+ const EVERYTHING: NetworkConfig = {
43
+ topology: 'default',
44
+ dmzMachines: [],
45
+ appMachines: [],
46
+ secureMachines: [],
47
+ internalMachines: [],
48
+ secureMgmtMachines: [],
49
+ domain: 'iamtheinternet.org',
50
+ ddnsPassword: 'test123',
51
+ verifyRouting: false,
52
+ managementVolumes: [],
53
+ dhcpClient: true,
54
+ signalCli: true,
55
+ signalSim: true,
56
+ signalRelease: true,
57
+ };
58
+
59
+ function publicSimulators(yaml: string): Array<{ name: string; dockerfile: string }> {
60
+ const compose = parse(yaml) as { services?: Record<string, ComposeService> };
61
+ return Object.entries(compose.services ?? {})
62
+ .filter(([name, svc]) => name !== EDGE_SERVICE && svc.networks?.['internet-external'])
63
+ .map(([name, svc]) => ({ name, dockerfile: svc.build?.dockerfile ?? '' }));
64
+ }
65
+
66
+ /**
67
+ * True if the image establishes the route — either through the shared
68
+ * entrypoint or by naming the ISP edge itself (a few sims predate the shared
69
+ * script and do it inline in their own entrypoint).
70
+ */
71
+ function establishesIspRoute(dockerfile: string): boolean {
72
+ const e2eDir = join(import.meta.dir, '..');
73
+ const text = readFileSync(join(e2eDir, dockerfile), 'utf-8');
74
+ if (text.includes('public-sim-entrypoint.sh') || text.includes(ISP_EDGE)) return true;
75
+
76
+ // Otherwise the route may live in a script the image copies in. Follow every
77
+ // COPY source that looks like a script and check those too.
78
+ for (const match of text.matchAll(/^COPY\s+(?:--from=\S+\s+)?(\S+\.sh)\s/gm)) {
79
+ try {
80
+ if (readFileSync(join(e2eDir, match[1]), 'utf-8').includes(ISP_EDGE)) return true;
81
+ } catch {
82
+ // A COPY source outside the build context is not a route carrier.
83
+ }
84
+ }
85
+
86
+ // Some sims ship their entrypoint in a simulators/<name>/ directory copied
87
+ // wholesale; check the obvious sibling.
88
+ for (const match of text.matchAll(/^COPY\s+(?:--from=\S+\s+)?(simulators\/\S+)\s/gm)) {
89
+ try {
90
+ if (readFileSync(join(e2eDir, match[1], 'entrypoint.sh'), 'utf-8').includes(ISP_EDGE)) {
91
+ return true;
92
+ }
93
+ } catch {
94
+ // Not an entrypoint-bearing directory.
95
+ }
96
+ }
97
+ return false;
98
+ }
99
+
100
+ describe('public simulators route back through the ISP edge', () => {
101
+ const simulators = [
102
+ ...publicSimulators(generateSharedInfraYaml()),
103
+ ...publicSimulators(generateTestComposeYaml(EVERYTHING)),
104
+ ];
105
+
106
+ test('the sweep actually found simulators', () => {
107
+ // Guard against the check silently passing because the filter broke.
108
+ expect(simulators.length).toBeGreaterThan(5);
109
+ });
110
+
111
+ for (const { name, dockerfile } of simulators) {
112
+ test(`${name} routes the customer prefix via ${ISP_EDGE}`, () => {
113
+ expect(dockerfile).not.toBe('');
114
+ expect(establishesIspRoute(dockerfile)).toBe(true);
115
+ });
116
+ }
117
+ });
@@ -35,6 +35,15 @@ export const SIMULATOR_IPS = {
35
35
  APT_REPO: '100.64.0.60',
36
36
  /** MinIO S3-compatible object storage — backup/restore target (replaces AWS S3 in tests). Reachable at http://minio.lab. */
37
37
  MINIO: '100.64.0.61',
38
+ /**
39
+ * OFF-FLEET cPanel/SSH web host simulator (`tangohost.com`) — a third-party
40
+ * host celilo publishes to via `external_web` but does NOT govern. A public
41
+ * peer of namecheap-dns/pebble, NOT behind the customer firewall, so public
42
+ * DNS pointing the domain here is a genuine public endpoint.
43
+ */
44
+ CPANEL_HOST: '100.64.0.63',
45
+ /** signal-cli release host — serves the tarball the signal module downloads at deploy time. */
46
+ SIGNAL_RELEASE: '100.64.0.62',
38
47
  /** Pebble ACME server (replaces production Let's Encrypt). */
39
48
  PEBBLE: '100.64.0.100',
40
49
  } as const;
package/src/types.ts CHANGED
@@ -1,4 +1,13 @@
1
- export type Zone = 'dmz' | 'app' | 'secure' | 'internal';
1
+ /**
2
+ * A placement zone a machine can be brought up in.
3
+ *
4
+ * `secure-mgmt` is celilo's own control plane, not a data-plane tier. It was
5
+ * expressible for the management box alone until a module declared
6
+ * `zone: secure-mgmt` (#436, the signal transport) and infrastructure
7
+ * selection had nowhere to put it — so a module that named the zone correctly
8
+ * could not be deployed by any test.
9
+ */
10
+ export type Zone = 'dmz' | 'app' | 'secure' | 'internal' | 'secure-mgmt';
2
11
 
3
12
  /**
4
13
  * Where a network assertion is made FROM — named by the box's network location
@@ -38,6 +47,11 @@ export interface NetworkConfig {
38
47
  appMachines: MachineSpec[];
39
48
  secureMachines: MachineSpec[];
40
49
  internalMachines: MachineSpec[];
50
+ /**
51
+ * Machines on celilo's control-plane network. Bringing one up implies the
52
+ * `secure-mgmt` network exists, whether or not celilo-mgr itself sits there.
53
+ */
54
+ secureMgmtMachines: MachineSpec[];
41
55
  domain: string;
42
56
  ddnsPassword: string;
43
57
  verifyRouting: boolean;
@@ -45,6 +59,40 @@ export interface NetworkConfig {
45
59
  managementVolumes: string[];
46
60
  /** Include a DHCP client container on the internal network */
47
61
  dhcpClient: boolean;
62
+ /**
63
+ * Include a REAL signal-cli daemon on the internal network.
64
+ *
65
+ * Never linked to a Signal account — the JSON-RPC interface is served
66
+ * locally and needs none. It exists so the contract celilo's client depends
67
+ * on is verified against the actual binary rather than against a simulator
68
+ * that encodes the same assumptions. See design.md D16.
69
+ */
70
+ signalCli: boolean;
71
+ /**
72
+ * Include the signal-cli SIMULATOR on the internal network.
73
+ *
74
+ * The drivable counterpart to `signalCli`: it can queue an inbound reply,
75
+ * fail a recipient inside a JSON-RPC success, and revoke the device link on
76
+ * command, which is what a test of the delivery/ack loop needs and what an
77
+ * unlinked real daemon cannot be made to do.
78
+ */
79
+ signalSim: boolean;
80
+ /**
81
+ * Serve the signal-cli release tarball from the simulated internet, so the
82
+ * signal module's deploy-time download resolves inside the sealed network.
83
+ */
84
+ signalRelease: boolean;
85
+ /**
86
+ * Which zone the celilo management container sits in. Defaults to `internal`
87
+ * — the single-network topology every existing test uses.
88
+ *
89
+ * `secure-mgmt` places celilo-mgr on its OWN control-plane network, which is
90
+ * the production topology and the one the suite could not express before. That
91
+ * gap is why the control-plane-network bug reached production: with management
92
+ * always on `internal`, the hardcoded "trusted subnet == network.internal.subnet"
93
+ * assumption was accidentally true in every test.
94
+ */
95
+ managementZone?: 'internal' | 'secure-mgmt';
48
96
  /**
49
97
  * Observer vantages to inject into the topology. Each becomes an `observer-*`
50
98
  * container — a passive spy carrying the probe tooling, placed at the named
@@ -364,6 +412,7 @@ export const ZONE_GATEWAYS: Record<Zone, string> = {
364
412
  app: '10.0.20.1',
365
413
  secure: '10.0.30.1',
366
414
  internal: '192.168.0.254',
415
+ 'secure-mgmt': '10.0.120.1',
367
416
  };
368
417
 
369
418
  export const ZONE_SUBNETS: Record<Zone, string> = {
@@ -371,6 +420,7 @@ export const ZONE_SUBNETS: Record<Zone, string> = {
371
420
  app: '10.0.20.0/24',
372
421
  secure: '10.0.30.0/24',
373
422
  internal: '192.168.0.0/24',
423
+ 'secure-mgmt': '10.0.120.0/24',
374
424
  };
375
425
 
376
426
  /**
@@ -394,6 +444,26 @@ export function greenwaveRouterIp(): string {
394
444
  return '192.168.0.1';
395
445
  }
396
446
 
447
+ /**
448
+ * The customer firewall's EXTERNAL (WAN) address — what the public internet
449
+ * sees, and the only address a public A record for a fronted service may hold
450
+ * (CLAUDE.md inviolable rule #1). Also the source address a DDNS update
451
+ * acquires on its way out, which is what makes source-IP registration correct
452
+ * by construction. Reference this instead of hardcoding it.
453
+ */
454
+ export function externalWanIp(): string {
455
+ return '100.100.0.100';
456
+ }
457
+
458
+ /**
459
+ * The customer's public /24 on `isp-external`. The ISP ROUTES this prefix; it
460
+ * never re-NATs it, so a packet leaving the fleet still carries the firewall's
461
+ * WAN address when it reaches a public service.
462
+ */
463
+ export function externalWanSubnet(): string {
464
+ return '100.100.0.0/24';
465
+ }
466
+
397
467
  /** The internal split-horizon DNS resolver (dns-int) in the e2e topology. */
398
468
  export function internalResolverIp(): string {
399
469
  return '192.168.0.10';