@celilo/e2e 0.9.2 → 0.10.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 (46) 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/proxy/squid.conf +7 -2
  11. package/config/resolver/unbound.conf +39 -3
  12. package/config/routing/management-routes.sh +9 -4
  13. package/config/routing/resolver-internal-routes.sh +10 -0
  14. package/docker/Dockerfile.cpanel-host-sim +70 -0
  15. package/docker/Dockerfile.firewall +12 -0
  16. package/docker/Dockerfile.management +6 -0
  17. package/docker/Dockerfile.minio +1 -1
  18. package/docker/Dockerfile.observer +11 -0
  19. package/docker/Dockerfile.signal-cli +60 -0
  20. package/docker/Dockerfile.signal-release +36 -0
  21. package/docker/Dockerfile.signal-sim +17 -0
  22. package/docker/Dockerfile.target-machine +10 -1
  23. package/docker/Dockerfile.target-machine-docker +1 -1
  24. package/docker/Dockerfile.target-technitium +1 -1
  25. package/package.json +1 -1
  26. package/registry-server/src/auth.ts +2 -2
  27. package/registry-server/src/introspection.test.ts +247 -0
  28. package/registry-server/src/introspection.ts +204 -0
  29. package/registry-server/src/module-owner-store.test.ts +85 -0
  30. package/registry-server/src/module-owner-store.ts +108 -0
  31. package/registry-server/src/server.test.ts +262 -0
  32. package/registry-server/src/server.ts +139 -10
  33. package/scripts/pack-celilo-packages.ts +79 -51
  34. package/scripts/stage-libsignal.ts +116 -0
  35. package/simulators/signal-cli/server.ts +272 -0
  36. package/src/cli/build.ts +35 -9
  37. package/src/cli/command-registry.ts +34 -6
  38. package/src/cli/index.ts +59 -21
  39. package/src/container-manager.ts +6 -2
  40. package/src/docker-compose-generator.ts +108 -4
  41. package/src/network-builder.ts +52 -0
  42. package/src/runner.ts +128 -57
  43. package/src/simulator-ips.ts +9 -0
  44. package/src/types.ts +34 -0
  45. package/config/dns/example.net.zone +0 -16
  46. package/config/dns/iamtheinternet.org.zone +0 -16
@@ -25,16 +25,31 @@ export const COMMANDS: CommandDef[] = [
25
25
  name: 'run',
26
26
  description: 'Run e2e tests — pass a module path or pattern, or --all / --all-modules',
27
27
  flags: [
28
- { name: '--all', description: 'Run the full regression: every module suite + top-level e2e/tests/' },
29
- { name: '--complete', description: 'Alias of --all: run every test, including ci-unsafe (quarantined) ones' },
30
- { name: '--ci-safe', description: 'Full regression EXCEPT tests marked cele2e-ci-unsafe (used by the nightly)' },
28
+ {
29
+ name: '--all',
30
+ description:
31
+ 'Full regression: every module suite + top-level e2e/tests/ (quarantined cele2e-ci-unsafe tests skipped)',
32
+ },
33
+ {
34
+ name: '--complete',
35
+ description:
36
+ 'Run EVERYTHING including quarantined (cele2e-ci-unsafe) tests — the only mode that opts into them',
37
+ },
38
+ {
39
+ name: '--ci-safe',
40
+ description:
41
+ 'Alias for the default full regression (quarantined tests skipped); kept for the nightly',
42
+ },
31
43
  { name: '--all-modules', description: 'Run e2e tests for all modules with e2e/ directories' },
32
44
  { name: '--keep', description: 'Keep network running after tests' },
33
45
  { name: '--reuse', description: 'Reuse existing network if running' },
34
46
  { name: '--live', description: 'Use live (non-simulated) internet' },
35
47
  { name: '--published', description: 'Use published .netapp packages' },
36
48
  { name: '--notify', description: 'Desktop notification when the run finishes' },
37
- { name: '--shuffle', description: 'Randomize test order to surface order-dependent bugs (seed is logged)' },
49
+ {
50
+ name: '--shuffle',
51
+ description: 'Randomize test order to surface order-dependent bugs (seed is logged)',
52
+ },
38
53
  { name: '--seed=', description: 'Replay a specific --shuffle order (e.g. --seed=12345)' },
39
54
  ],
40
55
  },
@@ -65,7 +80,10 @@ export const COMMANDS: CommandDef[] = [
65
80
  description: 'Rebuild Docker images and package all modules as .netapps',
66
81
  flags: [
67
82
  { name: '--save', description: 'Save Docker images to tarball after build' },
68
- { name: '--skip-modules', description: 'Skip .netapp packaging (only rebuild Docker images)' },
83
+ {
84
+ name: '--skip-modules',
85
+ description: 'Skip .netapp packaging (only rebuild Docker images)',
86
+ },
69
87
  ],
70
88
  },
71
89
  {
@@ -109,7 +127,17 @@ export const COMMANDS: CommandDef[] = [
109
127
 
110
128
  export const UP_PRESETS = ['--caddy', '--full-stack', '--infrastructure'];
111
129
 
112
- export const RUN_FLAGS = ['--all', '--complete', '--ci-safe', '--all-modules', '--keep', '--reuse', '--live', '--published', '--notify'];
130
+ export const RUN_FLAGS = [
131
+ '--all',
132
+ '--complete',
133
+ '--ci-safe',
134
+ '--all-modules',
135
+ '--keep',
136
+ '--reuse',
137
+ '--live',
138
+ '--published',
139
+ '--notify',
140
+ ];
113
141
 
114
142
  export const DOWN_FLAGS = ['--keep', '--all'];
115
143
 
package/src/cli/index.ts CHANGED
@@ -19,19 +19,27 @@ import { spawnSync } from 'node:child_process';
19
19
  import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
20
20
  import { basename, dirname, join, resolve } from 'node:path';
21
21
  import { parse as parseYaml } from 'yaml';
22
+ import {
23
+ E2eBusyError,
24
+ acquireRunLock,
25
+ clearLock,
26
+ formatBusy,
27
+ lockStatus,
28
+ markKept,
29
+ } from '../run-lock';
22
30
  import { runBuild } from './build';
23
- import { runScaffold } from './scaffold';
24
31
  import { generateBashCompletion, generateZshCompletion, getCompletions } from './completion';
25
32
  import { findAllModules, findModulesWithMatchingTest } from './module-discovery';
26
- import { acquireRunLock, clearLock, E2eBusyError, formatBusy, lockStatus, markKept } from '../run-lock';
33
+ import { runScaffold } from './scaffold';
27
34
 
28
35
  const PKG_DIR = resolve(import.meta.dir, '../..');
29
36
  const BIN_DIR = join(PKG_DIR, 'bin');
30
37
 
31
-
32
38
  function readManifestTestsDir(manifestPath: string): string | undefined {
33
39
  try {
34
- const parsed = parseYaml(readFileSync(manifestPath, 'utf-8')) as { e2e?: { tests_dir?: string } };
40
+ const parsed = parseYaml(readFileSync(manifestPath, 'utf-8')) as {
41
+ e2e?: { tests_dir?: string };
42
+ };
35
43
  return parsed?.e2e?.tests_dir;
36
44
  } catch {
37
45
  return undefined;
@@ -53,6 +61,12 @@ function detectTestDir(): { stateDir: string; testsPath: string } {
53
61
  }
54
62
  }
55
63
  if (existsSync(join(cwd, 'tests'))) return { stateDir: cwd, testsPath: join(cwd, 'tests') };
64
+ // The repo's own top-level suite lives at e2e/tests/, which is what `list`
65
+ // enumerates. Without this, `cele2e list` shows a test that `cele2e run
66
+ // <name>` then cannot find — the run/list disagreement in #412.
67
+ if (existsSync(join(cwd, 'e2e', 'tests'))) {
68
+ return { stateDir: join(cwd, 'e2e'), testsPath: join(cwd, 'e2e', 'tests') };
69
+ }
56
70
  return { stateDir: cwd, testsPath: join(cwd, 'tests') };
57
71
  }
58
72
 
@@ -90,14 +104,26 @@ function discoverSuiteTests(suiteRoot: string): { name: string; file: string; gr
90
104
  ? resolve(moduleDir, readManifestTestsDir(join(moduleDir, 'manifest.yml')) ?? 'e2e')
91
105
  : tp;
92
106
  if (!existsSync(testsDir)) continue;
93
- for (const f of readdirSync(testsDir).filter((f) => f.endsWith('.test.ts')).sort()) {
94
- out.push({ name: f.replace(/\.test\.ts$/, ''), file: join(testsDir, f), group: basename(moduleDir) });
107
+ for (const f of readdirSync(testsDir)
108
+ .filter((f) => f.endsWith('.test.ts'))
109
+ .sort()) {
110
+ out.push({
111
+ name: f.replace(/\.test\.ts$/, ''),
112
+ file: join(testsDir, f),
113
+ group: basename(moduleDir),
114
+ });
95
115
  }
96
116
  }
97
117
  const topLevel = join(suiteRoot, 'e2e', 'tests');
98
118
  if (existsSync(topLevel)) {
99
- for (const f of readdirSync(topLevel).filter((f) => f.endsWith('.test.ts')).sort()) {
100
- out.push({ name: f.replace(/\.test\.ts$/, ''), file: join(topLevel, f), group: '(top-level)' });
119
+ for (const f of readdirSync(topLevel)
120
+ .filter((f) => f.endsWith('.test.ts'))
121
+ .sort()) {
122
+ out.push({
123
+ name: f.replace(/\.test\.ts$/, ''),
124
+ file: join(topLevel, f),
125
+ group: '(top-level)',
126
+ });
101
127
  }
102
128
  }
103
129
  return out;
@@ -200,9 +226,10 @@ switch (command) {
200
226
  args.includes('--complete') ||
201
227
  args.includes('--ci-safe')
202
228
  ) {
203
- // --complete and --ci-safe both run the full discovery (every module +
204
- // the repo's top-level e2e/tests/). --ci-safe additionally has the runner
205
- // skip tests marked `cele2e-ci-unsafe`. --all-modules stays modules-only.
229
+ // --all / --complete / --ci-safe all run the full discovery (every module +
230
+ // the repo's top-level e2e/tests/). Quarantined (`cele2e-ci-unsafe`) tests
231
+ // are skipped by default; only --complete opts back into them (the runner
232
+ // sees the flag — it's kept below). --all-modules stays modules-only.
206
233
  const wantTopLevel =
207
234
  args.includes('--all') || args.includes('--complete') || args.includes('--ci-safe');
208
235
  const suiteRoot = resolveSuiteRoot();
@@ -222,9 +249,9 @@ switch (command) {
222
249
  }
223
250
  runScript(
224
251
  'e2e-run',
225
- // Strip the discovery aliases; KEEP --ci-safe so the runner applies the
226
- // `cele2e-ci-unsafe` quarantine exclusion.
227
- args.filter((a) => a !== '--all' && a !== '--all-modules' && a !== '--complete'),
252
+ // Strip the discovery-only aliases; KEEP --complete (the runner uses it to
253
+ // opt back into quarantined tests) and --ci-safe (harmless/no-op now).
254
+ args.filter((a) => a !== '--all' && a !== '--all-modules'),
228
255
  env,
229
256
  );
230
257
  }
@@ -236,10 +263,14 @@ switch (command) {
236
263
 
237
264
  if (modulePath) {
238
265
  const absModulePath = resolve(modulePath);
239
- runScript('e2e-run', args.filter((a) => a !== modulePath), {
240
- E2E_TEST_DIR: stateDir,
241
- E2E_MODULE_DIR: absModulePath,
242
- });
266
+ runScript(
267
+ 'e2e-run',
268
+ args.filter((a) => a !== modulePath),
269
+ {
270
+ E2E_TEST_DIR: stateDir,
271
+ E2E_MODULE_DIR: absModulePath,
272
+ },
273
+ );
243
274
  }
244
275
 
245
276
  // If cwd is not a module, walk sibling modules so the user can invoke
@@ -261,7 +292,9 @@ switch (command) {
261
292
  for (const m of matches) {
262
293
  console.error(` ${basename(m.moduleDir)}: ${m.matchedTests.join(', ')}`);
263
294
  }
264
- console.error('\nDisambiguate by running from inside the module directory or passing its path.');
295
+ console.error(
296
+ '\nDisambiguate by running from inside the module directory or passing its path.',
297
+ );
265
298
  process.exit(1);
266
299
  }
267
300
  } else if (positionals.length === 0) {
@@ -364,7 +397,9 @@ switch (command) {
364
397
  console.log(` Reuse: cele2e run --reuse`);
365
398
  console.log(` Tear down: cele2e down`);
366
399
  console.log('');
367
- } catch { /* ignore parse errors */ }
400
+ } catch {
401
+ /* ignore parse errors */
402
+ }
368
403
  }
369
404
  runScript('e2e-status', args, projectName ? { CELILO_E2E_PROJECT: projectName } : undefined);
370
405
  }
@@ -380,7 +415,10 @@ switch (command) {
380
415
  // build-infra mutates the shared Docker images — serialize against runs.
381
416
  // Released via the lock's exit handler (runBuild may process.exit on failure).
382
417
  try {
383
- acquireRunLock({ test: 'build-infra', runId: process.env.CELE2E_RUN_ID ?? crypto.randomUUID() });
418
+ acquireRunLock({
419
+ test: 'build-infra',
420
+ runId: process.env.CELE2E_RUN_ID ?? crypto.randomUUID(),
421
+ });
384
422
  } catch (err) {
385
423
  if (err instanceof E2eBusyError) {
386
424
  console.error(err.message);
@@ -922,7 +922,7 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
922
922
  if (config.managementVariant !== 'vanilla') {
923
923
  assertCliVersion(projectName, composeDir);
924
924
  console.log('[progress:start] initializing celilo | celilo initialized');
925
- // Honest harness (v2/NETWORK_CONFIG_TO_FIREWALL.md): seed ONLY the
925
+ // Honest harness (openspec/specs/progressive-zone-disclosure/spec.md): seed ONLY the
926
926
  // `internal` zone — the network the management box is genuinely on —
927
927
  // plus DNS. dmz/app/secure are NOT pre-seeded; they come into being
928
928
  // when a test deploys the firewall (net.deployFirewall()), exactly as
@@ -935,7 +935,11 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
935
935
  `celilo system init --accept-defaults \
936
936
  network.internal.subnet=${ZONE_SUBNETS.internal} \
937
937
  network.internal.gateway=${ZONE_GATEWAYS.internal} \
938
- dns.primary=100.100.0.1 \
938
+ ${
939
+ config.managementZone === 'secure-mgmt'
940
+ ? 'network.secure-mgmt.subnet=10.0.120.0/24 network.secure-mgmt.gateway=10.0.120.1 '
941
+ : ''
942
+ }dns.primary=100.100.0.1 \
939
943
  dns.fallback=1.0.0.1,8.8.8.8`,
940
944
  );
941
945
  if (initResult.exitCode !== 0) {
@@ -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,17 @@ 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';
340
377
  const networks: Record<string, unknown> = {
341
378
  internal: networkDef('192.168.0.0/24', '192.168.0.250'),
342
379
  dmz: networkDef('10.0.10.0/24', '10.0.10.250'),
343
380
  app: networkDef('10.0.20.0/24', '10.0.20.250'),
344
381
  secure: networkDef('10.0.30.0/24', '10.0.30.250'),
382
+ // Only when celilo-mgr lives off the internal LAN — keeps the default
383
+ // topology's generated compose byte-identical for every existing test.
384
+ ...(mgmtOnOwnNetwork
385
+ ? { 'secure-mgmt': networkDef(SECURE_MGMT_SUBNET, '10.0.120.250') }
386
+ : {}),
345
387
  'isp-external': networkDef('100.100.0.0/24', '100.100.0.250'),
346
388
  // internet-external is owned by shared infra; real-internet is per-test
347
389
  // (it just bridges fw-ext ↔ comcast-resolver, no shared service uses it).
@@ -392,11 +434,15 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
392
434
  // first. container-manager's celilo-init + routing-verify target the
393
435
  // `management` container by name, so `celilo-mgr-2` comes up fresh and
394
436
  // un-init'd — the empty target restore needs.
395
- const emitManagement = (variant: 'default' | 'vanilla' | undefined, ip: string) =>
437
+ const emitManagement = (
438
+ variant: 'default' | 'vanilla' | undefined,
439
+ ip: string,
440
+ zone: 'internal' | 'secure-mgmt' = 'internal',
441
+ ) =>
396
442
  baseService({
397
443
  image:
398
444
  variant === 'vanilla' ? 'celilo-e2e/management:vanilla' : 'celilo-e2e/management:latest',
399
- networks: { internal: { ipv4_address: ip } },
445
+ networks: { [zone]: { ipv4_address: ip } },
400
446
  cap_add: ['NET_ADMIN'],
401
447
  // ISS-0157: `module import`'s `bun install` hard-links packages from the
402
448
  // bun cache into node_modules, which on overlayfs forces a copy-up + fsync
@@ -416,12 +462,17 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
416
462
  ],
417
463
  environment: {
418
464
  DDNS_ENDPOINT: `http://${SIMULATOR_IPS.NAMECHEAP_DNS}:8080/update`,
419
- DEFAULT_GATEWAY: managementDefaultGw,
465
+ // On its own control-plane network the only router in reach is fw-main's
466
+ // leg there — both for the default route and for the segmented zones.
467
+ DEFAULT_GATEWAY: zone === 'secure-mgmt' ? SECURE_MGMT_GATEWAY : managementDefaultGw,
468
+ ...(zone === 'secure-mgmt' ? { FW_MAIN_HOP: SECURE_MGMT_GATEWAY } : {}),
420
469
  CELILO_REGISTRY_URL: 'http://e2e-registry.lab',
421
470
  },
422
471
  });
423
472
 
424
- services.management = emitManagement(config.managementVariant, '192.168.0.100');
473
+ services.management = mgmtOnOwnNetwork
474
+ ? emitManagement(config.managementVariant, SECURE_MGMT_MANAGEMENT_IP, 'secure-mgmt')
475
+ : emitManagement(config.managementVariant, '192.168.0.100');
425
476
 
426
477
  if (config.secondaryManagementVariant) {
427
478
  services['celilo-mgr-2'] = emitManagement(config.secondaryManagementVariant, '192.168.0.101');
@@ -451,6 +502,17 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
451
502
  const topologyFn = TOPOLOGY_SERVICES[config.topology] || TOPOLOGY_SERVICES.default;
452
503
  Object.assign(services, topologyFn());
453
504
 
505
+ // Give fw-main a leg on the control-plane network when celilo-mgr lives there,
506
+ // so the management box is routed like any other segmented network rather than
507
+ // being stranded. Applied as a post-step so the default topology's generated
508
+ // compose is untouched.
509
+ if (mgmtOnOwnNetwork) {
510
+ const fwMain = services['fw-main'] as { networks?: Record<string, unknown> } | undefined;
511
+ if (fwMain?.networks) {
512
+ fwMain.networks['secure-mgmt'] = { ipv4_address: SECURE_MGMT_GATEWAY };
513
+ }
514
+ }
515
+
454
516
  // --- Routing infrastructure (bridges per-test to shared infra) ---
455
517
  services['fw-ext'] = baseService({
456
518
  build: { context: '.', dockerfile: 'docker/Dockerfile.router' },
@@ -518,6 +580,44 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
518
580
  });
519
581
  }
520
582
 
583
+ // --- Optional real signal-cli daemon ---
584
+ // On `internal`, matching the signal module's declared zone — it is a local
585
+ // service celilo talks to, not an internet-facing one, so it does not belong
586
+ // on internet-external. Unlinked: the JSON-RPC surface needs no account.
587
+ if (config.signalCli) {
588
+ services['signal-cli'] = baseService({
589
+ build: { context: '.', dockerfile: 'docker/Dockerfile.signal-cli' },
590
+ networks: { internal: { ipv4_address: '192.168.0.90' } },
591
+ cap_add: ['NET_ADMIN'],
592
+ });
593
+ }
594
+
595
+ // --- Optional signal-cli release host ---
596
+ // On internet-external because that is what it models: an artifact the
597
+ // module downloads FROM the internet at deploy time.
598
+ if (config.signalRelease) {
599
+ services['signal-release'] = baseService({
600
+ build: { context: '.', dockerfile: 'docker/Dockerfile.signal-release' },
601
+ networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.SIGNAL_RELEASE } },
602
+ cap_add: ['NET_ADMIN'],
603
+ });
604
+ }
605
+
606
+ // --- Optional signal-cli simulator ---
607
+ // Same network placement as the real daemon so a test can swap one for the
608
+ // other without touching the module's configured endpoint.
609
+ if (config.signalSim) {
610
+ services['signal-sim'] = baseService({
611
+ build: { context: '.', dockerfile: 'docker/Dockerfile.signal-sim' },
612
+ networks: { internal: { ipv4_address: '192.168.0.91' } },
613
+ cap_add: ['NET_ADMIN'],
614
+ environment: {
615
+ SIGNAL_ACCOUNT: '+15551234567',
616
+ SIGNAL_KNOWN_RECIPIENTS: '+15550001,+15550002',
617
+ },
618
+ });
619
+ }
620
+
521
621
  // --- Observer vantages (passive spies; ISS-0117) ---
522
622
  // Each is the SAME image at a different network location with a routing profile that
523
623
  // mirrors a real device there — that routing profile is what makes it a faithful seat
@@ -528,6 +628,9 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
528
628
  build: { context: '.', dockerfile: 'docker/Dockerfile.observer' },
529
629
  networks: { [p.network]: { ipv4_address: p.ip } },
530
630
  cap_add: ['NET_ADMIN'],
631
+ // A vantage may dial a VPN and probe from INSIDE the tunnel — the only way
632
+ // to assert VPN reach by real signal instead of by reading a rule string.
633
+ devices: ['/dev/net/tun'],
531
634
  environment: observerEnv(p),
532
635
  });
533
636
  }
@@ -660,6 +763,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
660
763
  './config/dns/celilo.computer.zone:/seed/celilo.computer.zone:ro',
661
764
  './config/dns/example.net.zone:/seed/example.net.zone:ro',
662
765
  './config/dns/park-your-domain.com.zone:/seed/park-your-domain.com.zone:ro',
766
+ './config/dns/tangohost.com.zone:/seed/tangohost.com.zone:ro',
663
767
  ],
664
768
  });
665
769
 
@@ -39,6 +39,9 @@ export class NetworkBuilder {
39
39
  secureMachines: [],
40
40
  internalMachines: [],
41
41
  dhcpClient: false,
42
+ signalCli: false,
43
+ signalSim: false,
44
+ signalRelease: false,
42
45
  observers: [],
43
46
  domain: 'iamtheinternet.org',
44
47
  ddnsPassword: 'test123',
@@ -51,6 +54,17 @@ export class NetworkBuilder {
51
54
  return this;
52
55
  }
53
56
 
57
+ /**
58
+ * Place the celilo management container in a given zone. Defaults to
59
+ * `internal`; `secure-mgmt` gives celilo-mgr its own control-plane network
60
+ * (the production topology), so a test can exercise celilo reaching and
61
+ * resolving for a fleet from OFF the internal LAN.
62
+ */
63
+ managementZone(zone: 'internal' | 'secure-mgmt'): this {
64
+ this.config.managementZone = zone;
65
+ return this;
66
+ }
67
+
54
68
  /** Append `{name, ip, zone, ...extra}` for each entry to a zone's machine list. */
55
69
  private addMachines(
56
70
  target: MachineSpec[],
@@ -97,6 +111,44 @@ export class NetworkBuilder {
97
111
  return this;
98
112
  }
99
113
 
114
+ /**
115
+ * Add a real, UNLINKED signal-cli daemon on the internal network.
116
+ *
117
+ * For verifying the JSON-RPC contract against the actual binary. It cannot
118
+ * send to a phone (no account, and CDSI/SVR2 attestation makes a local
119
+ * Signal substitute impossible), but every endpoint, envelope and error
120
+ * shape celilo's client relies on is checkable without one.
121
+ */
122
+ withSignalCli(): this {
123
+ this.config.signalCli = true;
124
+ return this;
125
+ }
126
+
127
+ /**
128
+ * Add the signal-cli SIMULATOR on the internal network.
129
+ *
130
+ * Use this for anything that drives the delivery or ack loop: unlike the
131
+ * real daemon it exposes a control surface (`/_control/inbound`,
132
+ * `/_control/sent`, `/_control/unlink`) so a test can make a human "reply"
133
+ * and then assert on what celilo did about it.
134
+ */
135
+ withSignalSim(): this {
136
+ this.config.signalSim = true;
137
+ return this;
138
+ }
139
+
140
+ /**
141
+ * Serve the signal-cli release tarball from the simulated internet.
142
+ *
143
+ * Needed to DEPLOY the signal module: its ansible role downloads signal-cli
144
+ * at deploy time, and the e2e network is sealed. Point the module at it with
145
+ * `module config set signal release_base_url http://signal-release.lab`.
146
+ */
147
+ withSignalRelease(): this {
148
+ this.config.signalRelease = true;
149
+ return this;
150
+ }
151
+
100
152
  /**
101
153
  * Inject one or more observer vantages (ISS-0117) — passive spies carrying the probe
102
154
  * toolbox, each placed at its vantage's network location with a routing profile that