@celilo/e2e 0.10.0 → 0.10.3

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.3",
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 ?? '')
@@ -93,6 +113,14 @@ function handleSend(id: unknown, params: Record<string, unknown>): Response {
93
113
  if (recipients.length === 0) return rpcError(id, -32602, 'No recipient given');
94
114
 
95
115
  for (const recipient of recipients) {
116
+ // The linked account is ALWAYS a valid recipient: messaging yourself is
117
+ // note-to-self, a first-class Signal feature and the default
118
+ // single-operator setup (design R2) — the route points at the very number
119
+ // the transport is a secondary device of. Rejecting it as "unregistered"
120
+ // is something no real daemon does, and it made the note-to-self ack path
121
+ // untestable end to end: the page could not be sent, so no token ever
122
+ // reached the operator to reply with.
123
+ if (recipient === ACCOUNT) continue;
96
124
  // Push back like Signal: an unregistered number is a hard failure, not a
97
125
  // silent no-op.
98
126
  if (KNOWN_RECIPIENTS.size > 0 && !KNOWN_RECIPIENTS.has(recipient)) {
@@ -126,6 +154,18 @@ function handleSend(id: unknown, params: Record<string, unknown>): Response {
126
154
  * will misbehave on them.
127
155
  */
128
156
  function handleReceive(id: unknown): Response {
157
+ // The refusal comes BEFORE the link check, as it does in the real daemon:
158
+ // the receive thread is started per account at daemon start, so an
159
+ // already-receiving manager rejects the call without ever looking at what
160
+ // is queued. Verbatim message from ReceiveCommand.java:125.
161
+ if (RECEIVE_MODE !== 'manual') {
162
+ return rpcError(
163
+ id,
164
+ -32001,
165
+ 'Receive command cannot be used if messages are already being received.',
166
+ );
167
+ }
168
+
129
169
  if (!linked) {
130
170
  return rpcError(id, -32000, 'Account is not registered or the device link was removed');
131
171
  }
@@ -142,13 +182,35 @@ function handleReceive(id: unknown): Response {
142
182
 
143
183
  while (inboundQueue.length > 0) {
144
184
  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
- });
185
+ // A reply from the linked account itself — the default single-operator
186
+ // setup, where the route points at the number the transport is a
187
+ // secondary device of — is a NOTE-TO-SELF, and the real daemon delivers
188
+ // it as a sync transcript, never as a dataMessage. Emitting it as a
189
+ // dataMessage would let a client that only reads dataMessage look
190
+ // correct here while dropping every reply in production (#460).
191
+ envelopes.push(
192
+ item.from === ACCOUNT
193
+ ? {
194
+ envelope: {
195
+ sourceNumber: ACCOUNT,
196
+ timestamp: nextTimestamp(),
197
+ syncMessage: {
198
+ sentMessage: {
199
+ destinationNumber: ACCOUNT,
200
+ timestamp: item.timestamp,
201
+ message: item.body,
202
+ },
203
+ },
204
+ },
205
+ }
206
+ : {
207
+ envelope: {
208
+ sourceNumber: item.from,
209
+ timestamp: item.timestamp,
210
+ dataMessage: { message: item.body },
211
+ },
212
+ },
213
+ );
152
214
  console.log(`[signal-sim] <- ${item.from}: ${item.body}`);
153
215
  }
154
216
 
@@ -261,7 +323,9 @@ Bun.serve({
261
323
  },
262
324
  });
263
325
 
264
- console.log(`[signal-sim] signal-cli JSON-RPC simulator on :${PORT} as ${ACCOUNT}`);
326
+ console.log(
327
+ `[signal-sim] signal-cli JSON-RPC simulator on :${PORT} as ${ACCOUNT} (receive-mode=${RECEIVE_MODE})`,
328
+ );
265
329
  if (KNOWN_RECIPIENTS.size > 0) {
266
330
  console.log(`[signal-sim] known recipients: ${[...KNOWN_RECIPIENTS].join(', ')}`);
267
331
  }
@@ -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,16 +71,80 @@ 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.
77
+ */
78
+ /**
79
+ * The commands that remove a per-test project WITHOUT needing its compose file.
80
+ * Pure (Rule 10.1) so the no-compose-dependency property is testable.
81
+ *
82
+ * `docker compose -p <project> down` — what this used to run — resolves the
83
+ * project from a compose file in the CURRENT DIRECTORY. An exit handler has no
84
+ * reliable cwd, so compose exited "no configuration file provided", the error
85
+ * was swallowed by the surrounding catch, and nothing was removed. The handler
86
+ * fired correctly and tore down nothing.
87
+ *
88
+ * Removing by name needs only the project name, which we already have.
89
+ * Per-test projects are `celilo-e2e-<timestamp>`; the shared project is
90
+ * `celilo-e2e-shared`, which contains no timestamp and so is never matched —
91
+ * shared infra stays up, as it must.
70
92
  */
93
+ export function projectTeardownCommands(project: string): {
94
+ listContainers: string;
95
+ listNetworks: string;
96
+ } {
97
+ return {
98
+ listContainers: `docker ps -aq --filter name=${project}`,
99
+ listNetworks: `docker network ls --format {{.Name}} --filter name=${project}`,
100
+ };
101
+ }
102
+
103
+ /** Force-remove a project's containers and networks by name. Never throws. */
104
+ function forceRemoveProject(project: string): void {
105
+ const cmds = projectTeardownCommands(project);
106
+ try {
107
+ const ids = execSync(cmds.listContainers, { timeout: 15_000, stdio: 'pipe' })
108
+ .toString()
109
+ .split('\n')
110
+ .filter(Boolean);
111
+ if (ids.length > 0) {
112
+ execSync(`docker rm -f ${ids.join(' ')}`, { timeout: 30_000, stdio: 'pipe' });
113
+ }
114
+ } catch {}
115
+ try {
116
+ const nets = execSync(cmds.listNetworks, { timeout: 15_000, stdio: 'pipe' })
117
+ .toString()
118
+ .split('\n')
119
+ .filter(Boolean);
120
+ for (const net of nets) {
121
+ try {
122
+ execSync(`docker network rm ${net}`, { timeout: 10_000, stdio: 'pipe' });
123
+ } catch {}
124
+ }
125
+ } catch {}
126
+ }
127
+
71
128
  function cleanupOnExit() {
129
+ // `--keep` / `--reuse` mean the operator wants the stack to survive for
130
+ // debugging — and they want it MOST after a failure, which is exactly the
131
+ // path this handler covers. Tearing down here would delete the evidence.
132
+ if (process.env.CELILO_E2E_KEEP === '1' || process.env.CELILO_E2E_REUSE === '1') return;
133
+
72
134
  for (const project of activeProjects) {
135
+ // Compose first when it can work — it also drops volumes and orphans — but
136
+ // it is best-effort, so never rely on it having done anything.
73
137
  try {
74
- execSync(`docker compose -p ${project} down --volumes --remove-orphans`, {
138
+ execSync(`docker compose -f ${COMPOSE_FILE} -p ${project} down --volumes --remove-orphans`, {
139
+ cwd: PACKAGE_ROOT,
75
140
  timeout: 30_000,
76
141
  stdio: 'pipe',
77
142
  });
78
143
  } catch {}
144
+ // Authoritative: needs no compose file, no cwd, no working directory state.
145
+ forceRemoveProject(project);
79
146
  }
147
+ activeProjects.clear();
80
148
  try {
81
149
  execSync('docker network prune -f', { timeout: 10_000, stdio: 'pipe' });
82
150
  } catch {}
@@ -90,7 +158,25 @@ process.on('SIGINT', () => {
90
158
  cleanupOnExit();
91
159
  process.exit(1);
92
160
  });
93
- process.on('beforeExit', () => {
161
+ // `exit`, NOT `beforeExit`: the runner always terminates via `process.exit()`
162
+ // (runner.ts ends with `process.exit(failed > 0 ? 1 : 0)`), and `beforeExit`
163
+ // does not fire for an explicit exit — nor after an uncaught throw. So on the
164
+ // FAILURE path, where the test never reaches `handle.stop()`, teardown was
165
+ // never running and the per-test networks leaked.
166
+ //
167
+ // That leak is not confined to the run that caused it. The sim's networks use
168
+ // the same subnets as a real celilo fleet (dmz 10.0.10.0/24, app 10.0.20.0/24,
169
+ // …), so on a host that lives in those subnets — celilo's own forgejo-builder
170
+ // does — a leaked bridge leaves a DUPLICATE route for a production prefix.
171
+ // Traffic from any later container to the real host at that address is routed
172
+ // into the dead simulated network and blackholed. That took out three
173
+ // consecutive release runs, each dying in `git fetch` after a ~132s connect
174
+ // timeout, long after the e2e run that stranded the network had ended.
175
+ //
176
+ // The start-of-run prefix sweep stays as the crash-recovery backstop (it is
177
+ // the only thing that can clean up after SIGKILL); this makes the common case
178
+ // clean up after itself instead of poisoning the next job. See #508.
179
+ process.on('exit', () => {
94
180
  cleanupOnExit();
95
181
  });
96
182
 
@@ -829,13 +915,11 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
829
915
  );
830
916
  }
831
917
 
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
- ];
918
+ // Wait for all target machines to complete setup (systemd boot + routing).
919
+ // Via getAllMachines so a newly-supported zone cannot be silently skipped —
920
+ // this list was hand-maintained and omitted `secure-mgmt`, so the first
921
+ // machine placed there raced `machine add` and failed on SSH intermittently.
922
+ const allMachines = getAllMachines(config);
839
923
  if (allMachines.length > 0) {
840
924
  console.log('[progress:start] waiting for target machines | target machines ready');
841
925
  }
@@ -936,8 +1020,12 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
936
1020
  network.internal.subnet=${ZONE_SUBNETS.internal} \
937
1021
  network.internal.gateway=${ZONE_GATEWAYS.internal} \
938
1022
  ${
939
- config.managementZone === 'secure-mgmt'
940
- ? 'network.secure-mgmt.subnet=10.0.120.0/24 network.secure-mgmt.gateway=10.0.120.1 '
1023
+ // The control plane needs its subnet declared if celilo-mgr lives
1024
+ // there OR if a machine does — infrastructure selection for a module
1025
+ // declaring `zone: secure-mgmt` reads it either way (#436).
1026
+ config.managementZone === 'secure-mgmt' ||
1027
+ (config.secureMgmtMachines ?? []).length > 0
1028
+ ? `network.secure-mgmt.subnet=${ZONE_SUBNETS['secure-mgmt']} network.secure-mgmt.gateway=${ZONE_GATEWAYS['secure-mgmt']} `
941
1029
  : ''
942
1030
  }dns.primary=100.100.0.1 \
943
1031
  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,82 @@
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
+ import { projectTeardownCommands } from './container-manager';
19
+
20
+ const SRC = readFileSync(
21
+ join(dirname(fileURLToPath(import.meta.url)), 'container-manager.ts'),
22
+ 'utf-8',
23
+ );
24
+
25
+ test("teardown is wired to 'exit', which is the event an explicit process.exit() fires", () => {
26
+ // `beforeExit` alone was the bug: the runner ends with
27
+ // `process.exit(failed > 0 ? 1 : 0)`, so on the failure path — where the test
28
+ // never reaches handle.stop() — nothing ever tore the network down.
29
+ expect(SRC).toContain("process.on('exit'");
30
+ });
31
+
32
+ test('cleanup honors --keep / --reuse so a failed run stays debuggable', () => {
33
+ // Without this guard, adding the `exit` hook would destroy exactly the stack
34
+ // an operator asked to keep, since --keep matters most after a failure.
35
+ const body = SRC.slice(
36
+ SRC.indexOf('function cleanupOnExit'),
37
+ SRC.indexOf('process.on(\'SIGTERM\''),
38
+ );
39
+ expect(body).toContain('CELILO_E2E_KEEP');
40
+ expect(body).toContain('CELILO_E2E_REUSE');
41
+ });
42
+
43
+ // The first version of this fix wired the handler correctly and still leaked,
44
+ // because the teardown it ran was `docker compose -p <project> down` with no
45
+ // `-f` and no cwd. Compose resolves the project from a compose file in the
46
+ // CURRENT DIRECTORY; an exit handler has no reliable cwd, so it exited "no
47
+ // configuration file provided", the surrounding catch swallowed it, and nothing
48
+ // was removed. Asserting the hook exists was not enough — assert it can work.
49
+ test('teardown does not depend on a compose file being findable', () => {
50
+ const cmds = projectTeardownCommands('celilo-e2e-1785546029645');
51
+ for (const cmd of Object.values(cmds)) {
52
+ expect(cmd).not.toContain('docker compose');
53
+ expect(cmd).toContain('celilo-e2e-1785546029645');
54
+ }
55
+ });
56
+
57
+ test('teardown cannot match the shared project', () => {
58
+ // Per-test projects carry a timestamp; `celilo-e2e-shared` does not, so a
59
+ // name filter for one can never match the other. Shared infra must survive —
60
+ // it is torn down separately, and removing it mid-suite breaks every
61
+ // subsequent test.
62
+ const cmds = projectTeardownCommands('celilo-e2e-1785546029645');
63
+ for (const cmd of Object.values(cmds)) {
64
+ expect(cmd).not.toContain('celilo-e2e-shared');
65
+ }
66
+ });
67
+
68
+ test("the premise holds: process.exit() fires 'exit' but NOT 'beforeExit'", async () => {
69
+ // The whole fix rests on this runtime behavior. If it ever changes, the
70
+ // reasoning above is void and this should fail loudly rather than silently
71
+ // leaving the leak unfixed.
72
+ const proc = Bun.spawn([
73
+ 'bun',
74
+ '-e',
75
+ "process.on('beforeExit', () => console.log('BEFORE_EXIT')); process.on('exit', () => console.log('EXIT')); process.exit(0);",
76
+ ]);
77
+ const out = await new Response(proc.stdout).text();
78
+ await proc.exited;
79
+
80
+ expect(out).toContain('EXIT');
81
+ expect(out).not.toContain('BEFORE_EXIT');
82
+ });
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';