@celilo/e2e 0.10.0 → 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.
@@ -13,7 +13,22 @@ ip route add 10.0.30.0/24 via 100.100.0.100 # secure via fw-isp
13
13
 
14
14
  # IP forwarding is set via sysctls in docker-compose
15
15
 
16
- # NAT/forward between isp-external and internet-external
16
+ # NAT/forward between isp-external and internet-external.
17
+ #
18
+ # The customer's public /24 is ROUTED, never re-NAT'd. An ISP does not NAT a
19
+ # subscriber that already holds a public address — the whole point of
20
+ # 100.100.0.100 is that it IS the customer's address on the internet. fw-ext
21
+ # used to MASQUERADE it anyway, so every packet leaving the fleet reached the
22
+ # public simulators bearing fw-ext's own 100.64.0.1.
23
+ #
24
+ # That is not cosmetic. Namecheap-style DDNS registers the SOURCE address of
25
+ # the update when the caller omits `ip=` (which is now how celilo registers
26
+ # public names — #464/#466), so the double NAT published 100.64.0.1 for every
27
+ # public hostname: an address that DNATs to nothing, breaking ACME validation
28
+ # and any inbound reach. It also silently clobbered seeded apex records.
29
+ # Anything that reasons about "the address the internet sees us as" was
30
+ # measuring the simulator's own artefact.
31
+ iptables -t nat -A POSTROUTING -s 100.100.0.0/24 -j RETURN
17
32
  iptables -t nat -A POSTROUTING -j MASQUERADE
18
33
  iptables -P FORWARD ACCEPT
19
34
 
@@ -32,6 +32,11 @@ ip route add default via "$GATEWAY"
32
32
  ip route add 10.0.10.0/24 via "$FW_MAIN" # dmz via fw-main
33
33
  ip route add 10.0.20.0/24 via "$FW_MAIN" # app via fw-main
34
34
  ip route add 10.0.30.0/24 via "$FW_MAIN" # secure via fw-main
35
+ # Control plane. Only reachable when a secure-mgmt network exists — that is,
36
+ # when celilo-mgr lives there itself, or when a module declaring
37
+ # `zone: secure-mgmt` needs a system to land on (#436). Tolerated silently
38
+ # otherwise so the default topology is unchanged.
39
+ ip route add 10.0.120.0/24 via "$FW_MAIN" 2>/dev/null || true
35
40
 
36
41
  # DNS — Technitium internal resolver (split-horizon) with public fallback.
37
42
  # Technitium at 192.168.0.10 is authoritative for iamtheinternet.org internally,
@@ -7,6 +7,15 @@
7
7
  # `mc mb --ignore-existing`.
8
8
  set -e
9
9
 
10
+ # Route the customer's public prefix via the ISP edge (fw-ext), like every
11
+ # other host on internet-external. Docker's default bridge gateway has no path
12
+ # across networks, so without this a reply to the customer's WAN address is
13
+ # dropped and `backup --storage` hangs. fw-ext is per-test, so this is
14
+ # expected to fail while shared infra runs alone — hence the tolerance.
15
+ # (`busybox ip` because the MinIO image ships no iproute2.)
16
+ busybox ip route del default 2>/dev/null || true
17
+ busybox ip route add default via 100.64.0.1 2>/dev/null || true
18
+
10
19
  # Serve the S3 API on :80 so the management box reaches it over the SAME
11
20
  # proven HTTP egress path as the other public sims (apt-repo, npm-registry):
12
21
  # fw-ext's Squid-bypass exception is scoped to dport 80/443 → 100.64.0.0/24, so
@@ -0,0 +1,24 @@
1
+ #!/bin/sh
2
+ # Entrypoint wrapper for a simulator on `internet-external` — a host out on
3
+ # the simulated public internet.
4
+ #
5
+ # Such a host must reach the customer's public prefix (100.100.0.0/24) through
6
+ # the ISP edge, fw-ext. Docker's default bridge gateway has no path across
7
+ # networks, so without this route a reply to the customer's WAN address is
8
+ # simply dropped and the connection hangs.
9
+ #
10
+ # This used to be invisible: fw-ext MASQUERADEd the customer to its own
11
+ # 100.64.0.1, which is on-link here, so replies never needed routing at all.
12
+ # Once fw-ext stopped re-NATing a subscriber that already holds a public
13
+ # address (the ISP routes it; it does not NAT it), the omission surfaced —
14
+ # every sim that had this route already kept working, and the four that did
15
+ # not started timing out. celilo-website-sim, isitup, pebble, namecheap-dns
16
+ # and the DNS hierarchy have carried it all along; this is the same two lines.
17
+ #
18
+ # fw-ext is per-test, so the route is unreachable while shared infra runs
19
+ # alone. That is harmless — nothing dials the customer before a test brings
20
+ # fw-ext up — hence the tolerant `|| true`.
21
+ ip route del default 2>/dev/null || true
22
+ ip route add default via 100.64.0.1 2>/dev/null || true
23
+
24
+ exec "$@"
@@ -49,8 +49,16 @@ RUN set -eux; \
49
49
  -o APT::FTPArchive::Release::Architectures="amd64 arm64" \
50
50
  release dists/stable > dists/stable/Release
51
51
 
52
+ # Route the customer's public prefix via the ISP edge, like every other host
53
+ # on internet-external. See config/routing/public-sim-entrypoint.sh.
54
+ RUN apt-get update \
55
+ && apt-get install -y --no-install-recommends iproute2 \
56
+ && rm -rf /var/lib/apt/lists/*
57
+ COPY config/routing/public-sim-entrypoint.sh /usr/local/bin/public-sim-entrypoint.sh
58
+
52
59
  EXPOSE 80
53
60
 
54
61
  # python3's http.server is enough for apt (GET + range requests over static
55
62
  # files). No special MIME/headers needed.
63
+ ENTRYPOINT ["/bin/sh", "/usr/local/bin/public-sim-entrypoint.sh"]
56
64
  CMD ["python3", "-m", "http.server", "80", "--directory", "/repo"]
@@ -67,4 +67,9 @@ EXPOSE 7822 80 443
67
67
  # which is what we want from a fixture host. The runtime/lock dirs live under
68
68
  # /var/run, which the container wipes at start, so recreate them before apache
69
69
  # reads DefaultRuntimeDir.
70
+ # Route the customer's public prefix via the ISP edge, like every other host
71
+ # on internet-external. See config/routing/public-sim-entrypoint.sh.
72
+ COPY config/routing/public-sim-entrypoint.sh /usr/local/bin/public-sim-entrypoint.sh
73
+
74
+ ENTRYPOINT ["/bin/sh", "/usr/local/bin/public-sim-entrypoint.sh"]
70
75
  CMD ["/bin/sh", "-c", "mkdir -p /var/run/apache2 /var/lock/apache2 /run/sshd && /usr/sbin/sshd && . /etc/apache2/envvars && exec apache2 -D FOREGROUND"]
@@ -19,8 +19,15 @@
19
19
 
20
20
  FROM minio/mc:latest AS mc
21
21
 
22
+ # Static busybox purely for its `ip` applet: the MinIO image has no iproute2
23
+ # and no package manager worth invoking, but it still needs the ISP route
24
+ # every host on internet-external carries (see minio-startup.sh).
25
+ FROM busybox:stable-musl AS busybox
26
+
22
27
  FROM minio/minio:latest
23
28
 
29
+ COPY --from=busybox /bin/busybox /usr/local/bin/busybox
30
+
24
31
  # Bring the MinIO client in from the mc image so the entrypoint can create the
25
32
  # bucket once the server is up.
26
33
  COPY --from=mc /usr/bin/mc /usr/bin/mc
@@ -22,5 +22,13 @@ ENV PACKUMENT_DIR=/var/lib/npm \
22
22
  PORT=80 \
23
23
  REGISTRY_URL=http://npm-registry.lab
24
24
 
25
+ # Route the customer's public prefix via the ISP edge, like every other host
26
+ # on internet-external. See config/routing/public-sim-entrypoint.sh.
27
+ RUN apt-get update \
28
+ && apt-get install -y --no-install-recommends iproute2 \
29
+ && rm -rf /var/lib/apt/lists/*
30
+ COPY config/routing/public-sim-entrypoint.sh /usr/local/bin/public-sim-entrypoint.sh
31
+
25
32
  EXPOSE 80
33
+ ENTRYPOINT ["/bin/sh", "/usr/local/bin/public-sim-entrypoint.sh"]
26
34
  CMD ["bun", "run", "src/server.ts"]
@@ -30,5 +30,13 @@ ENV DATA_DIR=/var/lib/celilo-registry \
30
30
  # somewhere to put files before the server runs.
31
31
  RUN mkdir -p /uploads
32
32
 
33
+ # Route the customer's public prefix via the ISP edge, like every other host
34
+ # on internet-external. See config/routing/public-sim-entrypoint.sh.
35
+ RUN apt-get update \
36
+ && apt-get install -y --no-install-recommends iproute2 \
37
+ && rm -rf /var/lib/apt/lists/*
38
+ COPY config/routing/public-sim-entrypoint.sh /usr/local/bin/public-sim-entrypoint.sh
39
+
33
40
  EXPOSE 80
41
+ ENTRYPOINT ["/bin/sh", "/usr/local/bin/public-sim-entrypoint.sh"]
34
42
  CMD ["bun", "run", "src/index.ts"]
@@ -33,4 +33,12 @@ RUN mkdir -p "/out/v${SIGNAL_CLI_VERSION}" \
33
33
 
34
34
  FROM nginx:alpine
35
35
  COPY --from=fetch /out /usr/share/nginx/html
36
+
37
+ # Route the customer's public prefix via the ISP edge, like every other host on
38
+ # internet-external. See config/routing/public-sim-entrypoint.sh. (Alpine's
39
+ # busybox already provides `ip`, so nothing to install.)
40
+ COPY config/routing/public-sim-entrypoint.sh /usr/local/bin/public-sim-entrypoint.sh
41
+
36
42
  EXPOSE 80
43
+ ENTRYPOINT ["/bin/sh", "/usr/local/bin/public-sim-entrypoint.sh"]
44
+ CMD ["nginx", "-g", "daemon off;"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/e2e",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "E2E test infrastructure for Celilo-deployed applications. Provides a simulated internet with DNS hierarchy, ACME server, firewalls, and target machines in Docker.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -38,7 +38,7 @@
38
38
  ],
39
39
  "dependencies": {
40
40
  "@celilo/cli-display": "^0.1.9",
41
- "@celilo/event-bus": "^0.1.8",
41
+ "@celilo/event-bus": "^0.1.9",
42
42
  "yaml": "^2.8.0",
43
43
  "zod": "^3.24.1"
44
44
  },
@@ -129,11 +129,15 @@ export function bootstrapIndexEntry(entry: BootstrapEntry): IndexEntry {
129
129
  * `walkDir`'s output conforms to the canonical rule. `walkDir` is exported for
130
130
  * that test.
131
131
  */
132
- export function walkDir(dir: string, base = dir): string[] {
132
+ export function walkDir(dir: string, base = dir, inNodeModules = false): string[] {
133
133
  const results: string[] = [];
134
134
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
135
135
  if (entry.name.startsWith('.')) continue;
136
136
  if (entry.name.endsWith('.test.ts')) continue;
137
+ // The module's own tsconfig.json is dev-only (CI typechecks hooks with it);
138
+ // build.ts drops it too. A dependency's tsconfig.json inside node_modules is
139
+ // that package's published content and stays — build.ts keeps those as well.
140
+ if (entry.name === 'tsconfig.json' && !inNodeModules) continue;
137
141
 
138
142
  const fullPath = join(dir, entry.name);
139
143
 
@@ -152,14 +156,14 @@ export function walkDir(dir: string, base = dir): string[] {
152
156
  // Other node_modules: collect only @celilo/capabilities.
153
157
  const capDir = join(fullPath, '@celilo', 'capabilities');
154
158
  if (existsSync(capDir)) {
155
- results.push(...walkDir(capDir, base));
159
+ results.push(...walkDir(capDir, base, true));
156
160
  }
157
161
  }
158
162
  continue;
159
163
  }
160
164
 
161
165
  if (entry.isDirectory()) {
162
- results.push(...walkDir(fullPath, base));
166
+ results.push(...walkDir(fullPath, base, inNodeModules));
163
167
  } else {
164
168
  results.push(relative(base, fullPath));
165
169
  }
@@ -9,7 +9,10 @@
9
9
  * that checks `response.ok` believes a rejected send succeeded;
10
10
  * - an unlinked/revoked account answers HTTP perfectly and can send nothing;
11
11
  * - `receive` returns receipts and typing indicators alongside real
12
- * messages, and those are not replies.
12
+ * messages, and those are not replies;
13
+ * - `receive` is REFUSED unless the daemon was started with
14
+ * `--receive-mode=manual`, which is the real default and the reason the
15
+ * ack path shipped broken.
13
16
  *
14
17
  * It pushes back rather than saying yes. Sending to a number that has not been
15
18
  * registered with the simulator fails the way Signal fails.
@@ -36,6 +39,23 @@
36
39
  const ACCOUNT = process.env.SIGNAL_ACCOUNT ?? '+15551234567';
37
40
  const PORT = Number(process.env.SIGNAL_RPC_PORT ?? 8080);
38
41
 
42
+ /**
43
+ * Mirrors `signal-cli daemon --receive-mode=<mode>`, and DEFAULTS TO THE REAL
44
+ * DEFAULT (`on-start`) rather than to the convenient one.
45
+ *
46
+ * In on-start the daemon registers a strong receive handler, drains every
47
+ * inbound message into its own SSE stream, and REFUSES the `receive` JSON-RPC
48
+ * call outright. A simulator that answers `receive` regardless is a placebo
49
+ * for the whole ack path: that is precisely how the deployed module shipped
50
+ * with an unreadable transport while this suite stayed green. Only `manual`
51
+ * lets `receive` work — so a caller has to be started the way it must be
52
+ * deployed.
53
+ *
54
+ * Verified in signal-cli 0.14.6: DaemonCommand.java:78, :114;
55
+ * ManagerImpl.java:1494-1501, :1605; ReceiveCommand.java:125.
56
+ */
57
+ const RECEIVE_MODE = process.env.SIGNAL_RECEIVE_MODE ?? 'on-start';
58
+
39
59
  /** Numbers the simulator will accept as recipients. Anything else is rejected. */
40
60
  const KNOWN_RECIPIENTS = new Set(
41
61
  (process.env.SIGNAL_KNOWN_RECIPIENTS ?? '')
@@ -126,6 +146,18 @@ function handleSend(id: unknown, params: Record<string, unknown>): Response {
126
146
  * will misbehave on them.
127
147
  */
128
148
  function handleReceive(id: unknown): Response {
149
+ // The refusal comes BEFORE the link check, as it does in the real daemon:
150
+ // the receive thread is started per account at daemon start, so an
151
+ // already-receiving manager rejects the call without ever looking at what
152
+ // is queued. Verbatim message from ReceiveCommand.java:125.
153
+ if (RECEIVE_MODE !== 'manual') {
154
+ return rpcError(
155
+ id,
156
+ -32001,
157
+ 'Receive command cannot be used if messages are already being received.',
158
+ );
159
+ }
160
+
129
161
  if (!linked) {
130
162
  return rpcError(id, -32000, 'Account is not registered or the device link was removed');
131
163
  }
@@ -142,13 +174,35 @@ function handleReceive(id: unknown): Response {
142
174
 
143
175
  while (inboundQueue.length > 0) {
144
176
  const item = inboundQueue.shift() as QueuedInbound;
145
- envelopes.push({
146
- envelope: {
147
- sourceNumber: item.from,
148
- timestamp: item.timestamp,
149
- dataMessage: { message: item.body },
150
- },
151
- });
177
+ // A reply from the linked account itself — the default single-operator
178
+ // setup, where the route points at the number the transport is a
179
+ // secondary device of — is a NOTE-TO-SELF, and the real daemon delivers
180
+ // it as a sync transcript, never as a dataMessage. Emitting it as a
181
+ // dataMessage would let a client that only reads dataMessage look
182
+ // correct here while dropping every reply in production (#460).
183
+ envelopes.push(
184
+ item.from === ACCOUNT
185
+ ? {
186
+ envelope: {
187
+ sourceNumber: ACCOUNT,
188
+ timestamp: nextTimestamp(),
189
+ syncMessage: {
190
+ sentMessage: {
191
+ destinationNumber: ACCOUNT,
192
+ timestamp: item.timestamp,
193
+ message: item.body,
194
+ },
195
+ },
196
+ },
197
+ }
198
+ : {
199
+ envelope: {
200
+ sourceNumber: item.from,
201
+ timestamp: item.timestamp,
202
+ dataMessage: { message: item.body },
203
+ },
204
+ },
205
+ );
152
206
  console.log(`[signal-sim] <- ${item.from}: ${item.body}`);
153
207
  }
154
208
 
@@ -261,7 +315,9 @@ Bun.serve({
261
315
  },
262
316
  });
263
317
 
264
- console.log(`[signal-sim] signal-cli JSON-RPC simulator on :${PORT} as ${ACCOUNT}`);
318
+ console.log(
319
+ `[signal-sim] signal-cli JSON-RPC simulator on :${PORT} as ${ACCOUNT} (receive-mode=${RECEIVE_MODE})`,
320
+ );
265
321
  if (KNOWN_RECIPIENTS.size > 0) {
266
322
  console.log(`[signal-sim] known recipients: ${[...KNOWN_RECIPIENTS].join(', ')}`);
267
323
  }
@@ -4,7 +4,11 @@ import { tmpdir } from 'node:os';
4
4
  import { basename, join, resolve } from 'node:path';
5
5
  import { startBrowser } from './browser';
6
6
  import { MIN_CLI_VERSION, checkCliVersion } from './cli-version-contract';
7
- import { SHARED_PROJECT_NAME, generateTestComposeYaml } from './docker-compose-generator';
7
+ import {
8
+ SHARED_PROJECT_NAME,
9
+ generateTestComposeYaml,
10
+ getAllMachines,
11
+ } from './docker-compose-generator';
8
12
  import { ensureSharedInfra } from './shared-infra';
9
13
  import { SIMULATOR_IPS } from './simulator-ips';
10
14
  import { startSocksProxy } from './socks-proxy';
@@ -67,8 +71,16 @@ const activeProjects: Set<string> = new Set();
67
71
  *
68
72
  * NOTE: only tears down per-test projects. Shared infra is managed
69
73
  * separately (by the test runner or explicit stopSharedInfra call).
74
+ *
75
+ * MUST stay fully synchronous: it runs from an `exit` handler, where the
76
+ * event loop is already closed and any async work is silently dropped.
70
77
  */
71
78
  function cleanupOnExit() {
79
+ // `--keep` / `--reuse` mean the operator wants the stack to survive for
80
+ // debugging — and they want it MOST after a failure, which is exactly the
81
+ // path this handler covers. Tearing down here would delete the evidence.
82
+ if (process.env.CELILO_E2E_KEEP === '1' || process.env.CELILO_E2E_REUSE === '1') return;
83
+
72
84
  for (const project of activeProjects) {
73
85
  try {
74
86
  execSync(`docker compose -p ${project} down --volumes --remove-orphans`, {
@@ -77,6 +89,7 @@ function cleanupOnExit() {
77
89
  });
78
90
  } catch {}
79
91
  }
92
+ activeProjects.clear();
80
93
  try {
81
94
  execSync('docker network prune -f', { timeout: 10_000, stdio: 'pipe' });
82
95
  } catch {}
@@ -90,7 +103,25 @@ process.on('SIGINT', () => {
90
103
  cleanupOnExit();
91
104
  process.exit(1);
92
105
  });
93
- process.on('beforeExit', () => {
106
+ // `exit`, NOT `beforeExit`: the runner always terminates via `process.exit()`
107
+ // (runner.ts ends with `process.exit(failed > 0 ? 1 : 0)`), and `beforeExit`
108
+ // does not fire for an explicit exit — nor after an uncaught throw. So on the
109
+ // FAILURE path, where the test never reaches `handle.stop()`, teardown was
110
+ // never running and the per-test networks leaked.
111
+ //
112
+ // That leak is not confined to the run that caused it. The sim's networks use
113
+ // the same subnets as a real celilo fleet (dmz 10.0.10.0/24, app 10.0.20.0/24,
114
+ // …), so on a host that lives in those subnets — celilo's own forgejo-builder
115
+ // does — a leaked bridge leaves a DUPLICATE route for a production prefix.
116
+ // Traffic from any later container to the real host at that address is routed
117
+ // into the dead simulated network and blackholed. That took out three
118
+ // consecutive release runs, each dying in `git fetch` after a ~132s connect
119
+ // timeout, long after the e2e run that stranded the network had ended.
120
+ //
121
+ // The start-of-run prefix sweep stays as the crash-recovery backstop (it is
122
+ // the only thing that can clean up after SIGKILL); this makes the common case
123
+ // clean up after itself instead of poisoning the next job. See #508.
124
+ process.on('exit', () => {
94
125
  cleanupOnExit();
95
126
  });
96
127
 
@@ -829,13 +860,11 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
829
860
  );
830
861
  }
831
862
 
832
- // Wait for all target machines to complete setup (systemd boot + routing)
833
- const allMachines = [
834
- ...config.dmzMachines,
835
- ...config.appMachines,
836
- ...config.secureMachines,
837
- ...config.internalMachines,
838
- ];
863
+ // Wait for all target machines to complete setup (systemd boot + routing).
864
+ // Via getAllMachines so a newly-supported zone cannot be silently skipped —
865
+ // this list was hand-maintained and omitted `secure-mgmt`, so the first
866
+ // machine placed there raced `machine add` and failed on SSH intermittently.
867
+ const allMachines = getAllMachines(config);
839
868
  if (allMachines.length > 0) {
840
869
  console.log('[progress:start] waiting for target machines | target machines ready');
841
870
  }
@@ -936,8 +965,12 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
936
965
  network.internal.subnet=${ZONE_SUBNETS.internal} \
937
966
  network.internal.gateway=${ZONE_GATEWAYS.internal} \
938
967
  ${
939
- config.managementZone === 'secure-mgmt'
940
- ? 'network.secure-mgmt.subnet=10.0.120.0/24 network.secure-mgmt.gateway=10.0.120.1 '
968
+ // The control plane needs its subnet declared if celilo-mgr lives
969
+ // there OR if a machine does — infrastructure selection for a module
970
+ // declaring `zone: secure-mgmt` reads it either way (#436).
971
+ config.managementZone === 'secure-mgmt' ||
972
+ (config.secureMgmtMachines ?? []).length > 0
973
+ ? `network.secure-mgmt.subnet=${ZONE_SUBNETS['secure-mgmt']} network.secure-mgmt.gateway=${ZONE_GATEWAYS['secure-mgmt']} `
941
974
  : ''
942
975
  }dns.primary=100.100.0.1 \
943
976
  dns.fallback=1.0.0.1,8.8.8.8`,
@@ -374,6 +374,10 @@ export function generateSharedInfraYaml(): string {
374
374
  */
375
375
  export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: string): string {
376
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;
377
381
  const networks: Record<string, unknown> = {
378
382
  internal: networkDef('192.168.0.0/24', '192.168.0.250'),
379
383
  dmz: networkDef('10.0.10.0/24', '10.0.10.250'),
@@ -381,7 +385,7 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
381
385
  secure: networkDef('10.0.30.0/24', '10.0.30.250'),
382
386
  // Only when celilo-mgr lives off the internal LAN — keeps the default
383
387
  // topology's generated compose byte-identical for every existing test.
384
- ...(mgmtOnOwnNetwork
388
+ ...(needsSecureMgmt
385
389
  ? { 'secure-mgmt': networkDef(SECURE_MGMT_SUBNET, '10.0.120.250') }
386
390
  : {}),
387
391
  'isp-external': networkDef('100.100.0.0/24', '100.100.0.250'),
@@ -506,7 +510,7 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
506
510
  // so the management box is routed like any other segmented network rather than
507
511
  // being stranded. Applied as a post-step so the default topology's generated
508
512
  // compose is untouched.
509
- if (mgmtOnOwnNetwork) {
513
+ if (needsSecureMgmt) {
510
514
  const fwMain = services['fw-main'] as { networks?: Record<string, unknown> } | undefined;
511
515
  if (fwMain?.networks) {
512
516
  fwMain.networks['secure-mgmt'] = { ipv4_address: SECURE_MGMT_GATEWAY };
@@ -535,12 +539,7 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
535
539
  });
536
540
 
537
541
  // --- Dynamic test machines ---
538
- const allMachines = [
539
- ...config.dmzMachines,
540
- ...config.appMachines,
541
- ...config.secureMachines,
542
- ...config.internalMachines,
543
- ];
542
+ const allMachines = getAllMachines(config);
544
543
 
545
544
  for (const machine of allMachines) {
546
545
  const zoneNetwork = machine.zone;
@@ -614,6 +613,11 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
614
613
  environment: {
615
614
  SIGNAL_ACCOUNT: '+15551234567',
616
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',
617
621
  },
618
622
  });
619
623
  }
@@ -795,12 +799,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
795
799
  volumes: getRegistryVolumes(),
796
800
  });
797
801
 
798
- const allMachines = [
799
- ...config.dmzMachines,
800
- ...config.appMachines,
801
- ...config.secureMachines,
802
- ...config.internalMachines,
803
- ];
802
+ const allMachines = getAllMachines(config);
804
803
 
805
804
  for (const machine of allMachines) {
806
805
  const zoneNetwork = machine.zone;
@@ -847,5 +846,6 @@ export function getAllMachines(config: NetworkConfig): MachineSpec[] {
847
846
  ...config.appMachines,
848
847
  ...config.secureMachines,
849
848
  ...config.internalMachines,
849
+ ...(config.secureMgmtMachines ?? []),
850
850
  ];
851
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,6 +38,7 @@ export class NetworkBuilder {
38
38
  appMachines: [],
39
39
  secureMachines: [],
40
40
  internalMachines: [],
41
+ secureMgmtMachines: [],
41
42
  dhcpClient: false,
42
43
  signalCli: false,
43
44
  signalSim: false,
@@ -94,6 +95,19 @@ export class NetworkBuilder {
94
95
  return this.addMachines(this.config.internalMachines, 'internal', machines);
95
96
  }
96
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
+
97
111
  /** Add internal-zone machines that need the Docker-capable image (e.g., Technitium) */
98
112
  internalDocker(machines: Record<string, string>): this {
99
113
  return this.addMachines(this.config.internalMachines, 'internal', machines, { docker: true });
@@ -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
+ });
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;
@@ -398,6 +412,7 @@ export const ZONE_GATEWAYS: Record<Zone, string> = {
398
412
  app: '10.0.20.1',
399
413
  secure: '10.0.30.1',
400
414
  internal: '192.168.0.254',
415
+ 'secure-mgmt': '10.0.120.1',
401
416
  };
402
417
 
403
418
  export const ZONE_SUBNETS: Record<Zone, string> = {
@@ -405,6 +420,7 @@ export const ZONE_SUBNETS: Record<Zone, string> = {
405
420
  app: '10.0.20.0/24',
406
421
  secure: '10.0.30.0/24',
407
422
  internal: '192.168.0.0/24',
423
+ 'secure-mgmt': '10.0.120.0/24',
408
424
  };
409
425
 
410
426
  /**
@@ -428,6 +444,26 @@ export function greenwaveRouterIp(): string {
428
444
  return '192.168.0.1';
429
445
  }
430
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
+
431
467
  /** The internal split-horizon DNS resolver (dns-int) in the e2e topology. */
432
468
  export function internalResolverIp(): string {
433
469
  return '192.168.0.10';