@celilo/e2e 0.10.4 → 0.11.1

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 (68) hide show
  1. package/README.md +37 -28
  2. package/bin/e2e-status +1 -1
  3. package/bin/e2e-up +14 -14
  4. package/config/dhcp/dnsmasq.conf +3 -3
  5. package/config/resolver/unbound.conf +2 -2
  6. package/config/routing/fw-ext-routes.sh +4 -4
  7. package/config/routing/fw-isp-routes.sh +3 -3
  8. package/config/routing/fw-main-routes.sh +15 -4
  9. package/config/routing/management-routes.sh +11 -11
  10. package/config/routing/observer-setup.sh +3 -3
  11. package/config/routing/resolver-internal-routes.sh +5 -5
  12. package/config/routing/target-routes.sh +1 -1
  13. package/config/routing/target-setup.sh +18 -16
  14. package/config/socks/startup.sh +3 -3
  15. package/docker/Dockerfile.target-machine +15 -1
  16. package/docker/Dockerfile.target-machine-docker +6 -0
  17. package/docker/Dockerfile.target-technitium +6 -0
  18. package/npm-registry-server/src/packument.test.ts +2 -5
  19. package/npm-registry-server/src/packument.ts +3 -3
  20. package/npm-registry-server/src/server.ts +1 -1
  21. package/package.json +3 -3
  22. package/registry-server/src/bootstrap.ts +1 -1
  23. package/registry-server/src/introspection.test.ts +8 -12
  24. package/registry-server/src/server.test.ts +2 -2
  25. package/registry-server/src/server.ts +1 -1
  26. package/registry-server/src/storage.test.ts +8 -4
  27. package/registry-server/src/storage.ts +1 -1
  28. package/registry-server/src/validation.ts +2 -4
  29. package/scripts/pack-celilo-packages.ts +1 -4
  30. package/scripts/stage-apt-repo.ts +1 -1
  31. package/scripts/stage-libsignal.ts +1 -3
  32. package/simulators/greenwave/server.ts +4 -2
  33. package/simulators/greenwave/state.ts +24 -17
  34. package/simulators/isitup/server.ts +1 -5
  35. package/simulators/namecheap-ddns/server.ts +2 -4
  36. package/simulators/namecheap-ddns/zone-updater.test.ts +3 -1
  37. package/simulators/namecheap-ddns/zone-updater.ts +6 -10
  38. package/simulators/signal-cli/server.ts +3 -1
  39. package/src/address-plan.test.ts +95 -0
  40. package/src/browser.ts +3 -3
  41. package/src/bus-events.ts +2 -2
  42. package/src/cli/command-tree-parser.ts +8 -2
  43. package/src/cli/completion.ts +5 -8
  44. package/src/cli/index.ts +9 -2
  45. package/src/cli/module-discovery.ts +3 -1
  46. package/src/cli/scaffold.ts +2 -2
  47. package/src/cli-version-contract.ts +5 -6
  48. package/src/container-manager.ts +25 -8
  49. package/src/docker-compose-generator.ts +67 -39
  50. package/src/extract-failure.ts +92 -0
  51. package/src/fixtures.ts +10 -17
  52. package/src/index.ts +2 -2
  53. package/src/network-builder.ts +14 -9
  54. package/src/observer.test.ts +50 -19
  55. package/src/observer.ts +15 -8
  56. package/src/registry-bundle.ts +1 -3
  57. package/src/run-lock.test.ts +2 -2
  58. package/src/run-lock.ts +19 -5
  59. package/src/runner.ts +9 -67
  60. package/src/shared-infra.ts +50 -22
  61. package/src/socks-proxy.ts +16 -19
  62. package/src/types.ts +67 -17
  63. package/src/vantage-helpers.ts +6 -2
  64. package/src/vantage.test.ts +94 -40
  65. package/src/vantage.ts +24 -17
  66. package/src/wait-for-run.ts +1 -1
  67. package/src/zone-classifier.test.ts +32 -21
  68. package/src/zone-classifier.ts +7 -6
package/src/observer.ts CHANGED
@@ -45,8 +45,8 @@ export interface ObserverPlacement {
45
45
  // — the firewall to the segmented zones — NOT the LAN's default route, which is the home
46
46
  // router below. A faithful internalDevice routes its default via the home router and has
47
47
  // NO route to the segmented zones, so a dmz/app/secure container IP is unreachable.
48
- const HOME_ROUTER = '192.168.0.1';
49
- const INTERNAL_RESOLVER = '192.168.0.10';
48
+ const HOME_ROUTER = '10.226.1.1';
49
+ const INTERNAL_RESOLVER = '10.226.1.10';
50
50
  const PUBLIC_RESOLVER = '100.100.0.1';
51
51
  const INTERNET_GATEWAY = '100.64.0.1'; // fw-ext, on internet-external
52
52
 
@@ -64,7 +64,7 @@ export const OBSERVER_PLACEMENTS: Record<Exclude<Vantage, 'management'>, Observe
64
64
  internalDevice: {
65
65
  service: 'observer-internal',
66
66
  network: 'internal',
67
- ip: '192.168.0.211', // a LAN host (dhcp range is .200-.220; dhcp-client is .210)
67
+ ip: '10.226.1.211', // a LAN host (dhcp range is .200-.220; dhcp-client is .210)
68
68
  gateway: HOME_ROUTER,
69
69
  interZoneRoutes: false,
70
70
  // Identical DNS to the management box on purpose: the only difference vs management
@@ -109,7 +109,9 @@ export const OBSERVER_PLACEMENTS: Record<Exclude<Vantage, 'management'>, Observe
109
109
  /** The placement for an injectable vantage; throws for `management` (not injectable). */
110
110
  export function observerPlacement(vantage: Vantage): ObserverPlacement {
111
111
  if (vantage === 'management') {
112
- throw new Error('management has no observer placement — it reuses the real management container.');
112
+ throw new Error(
113
+ 'management has no observer placement — it reuses the real management container.',
114
+ );
113
115
  }
114
116
  return OBSERVER_PLACEMENTS[vantage];
115
117
  }
@@ -209,7 +211,10 @@ export function createObserverTransport(handle: NetworkHandle): ProbeTransport {
209
211
  `timeout 4 bash -c 'exec 3<>/dev/tcp/${opts.host}/${opts.port}'`,
210
212
  8_000,
211
213
  );
212
- return { connected: res.exitCode === 0, detail: res.exitCode === 0 ? undefined : 'connect failed/timed out' };
214
+ return {
215
+ connected: res.exitCode === 0,
216
+ detail: res.exitCode === 0 ? undefined : 'connect failed/timed out',
217
+ };
213
218
  },
214
219
 
215
220
  async ping(opts) {
@@ -223,8 +228,7 @@ export function createObserverTransport(handle: NetworkHandle): ProbeTransport {
223
228
  const { host, port } = hostPort(opts.url);
224
229
  const certRes = await handle.exec(
225
230
  c,
226
- `echo | openssl s_client -connect ${host}:${port} -servername ${host} 2>/dev/null | ` +
227
- 'openssl x509 -noout -subject -issuer -ext subjectAltName',
231
+ `echo | openssl s_client -connect ${host}:${port} -servername ${host} 2>/dev/null | openssl x509 -noout -subject -issuer -ext subjectAltName`,
228
232
  10_000,
229
233
  );
230
234
  // -k gathers the status even for an untrusted chain; the cert is reported truthfully
@@ -249,7 +253,10 @@ export function createObserverTransport(handle: NetworkHandle): ProbeTransport {
249
253
  const hosts = await handle.exec(c, `grep -F ${opts.name} /etc/hosts`, 5_000);
250
254
  return {
251
255
  resolvConf: resolv.stdout.trim(),
252
- hostsLines: hosts.stdout.split('\n').map((l) => l.trim()).filter(Boolean),
256
+ hostsLines: hosts.stdout
257
+ .split('\n')
258
+ .map((l) => l.trim())
259
+ .filter(Boolean),
253
260
  };
254
261
  },
255
262
  };
@@ -39,9 +39,7 @@ export function ensureRegistryServerBundle(pkgDir: string): void {
39
39
  if (!existsSync(upstream)) {
40
40
  if (!existsSync(bundle)) {
41
41
  throw new Error(
42
- `cele2e: ${bundle} is missing and ../registry-server doesn't exist either.\n` +
43
- 'The @celilo/e2e package was published without its registry-server bundle. ' +
44
- 'Re-publish from infra/scripts/publish.ts so the bundle is recreated.',
42
+ `cele2e: ${bundle} is missing and ../registry-server doesn't exist either.\nThe @celilo/e2e package was published without its registry-server bundle. Re-publish from infra/scripts/publish.ts so the bundle is recreated.`,
45
43
  );
46
44
  }
47
45
  return; // npm-installed; bundle ships in tarball
@@ -3,10 +3,10 @@ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { hostname, tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import {
6
- acquireRunLock,
7
- clearLock,
8
6
  E2eBusyError,
9
7
  type LockHolder,
8
+ acquireRunLock,
9
+ clearLock,
10
10
  lockStatus,
11
11
  markKept,
12
12
  readHolder,
package/src/run-lock.ts CHANGED
@@ -24,7 +24,7 @@
24
24
  */
25
25
 
26
26
  import { execFileSync } from 'node:child_process';
27
- import { mkdirSync, openSync, closeSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
27
+ import { closeSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
28
28
  import { homedir, hostname } from 'node:os';
29
29
  import { basename, dirname, join } from 'node:path';
30
30
 
@@ -71,7 +71,10 @@ export class E2eBusyError extends Error {
71
71
  export function formatBusy(h: LockHolder): string {
72
72
  const age = ageString(Date.parse(h.startedAt));
73
73
  const verb = h.state === 'kept' ? 'holds a kept stack from' : 'running';
74
- const what = h.state === 'kept' ? `(run \`cele2e release\` to free it)` : `${h.test}, started ${age} ago (pid ${h.pid})`;
74
+ const what =
75
+ h.state === 'kept'
76
+ ? '(run `cele2e release` to free it)'
77
+ : `${h.test}, started ${age} ago (pid ${h.pid})`;
75
78
  return `e2e busy: ${h.session} ${verb} ${what}`;
76
79
  }
77
80
 
@@ -88,7 +91,11 @@ function deriveSession(): string {
88
91
  const cwd = process.cwd();
89
92
  const git = (args: string[]): string => {
90
93
  try {
91
- return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
94
+ return execFileSync('git', args, {
95
+ cwd,
96
+ encoding: 'utf-8',
97
+ stdio: ['ignore', 'pipe', 'ignore'],
98
+ }).trim();
92
99
  } catch {
93
100
  return '';
94
101
  }
@@ -164,7 +171,9 @@ export function acquireRunLock(opts: { test: string; runId: string; allowKept?:
164
171
  // Reclaim when: corrupt/unreadable, the holder is stale, OR this is a
165
172
  // `--reuse` run taking over a `kept` stack it's deliberately reusing.
166
173
  if (!existing || isStale(existing) || (opts.allowKept && existing.state === 'kept')) {
167
- try { unlinkSync(lockPath()); } catch {}
174
+ try {
175
+ unlinkSync(lockPath());
176
+ } catch {}
168
177
  continue;
169
178
  }
170
179
  throw new E2eBusyError(existing);
@@ -224,7 +233,12 @@ export function releaseRunLock(): void {
224
233
  if (held.keepOnRelease) {
225
234
  const cur = readHolder();
226
235
  if (cur && cur.pid === process.pid) {
227
- try { writeFileSync(lockPath(), JSON.stringify({ ...cur, state: 'kept', beatAt: Date.now() }, null, 2)); } catch {}
236
+ try {
237
+ writeFileSync(
238
+ lockPath(),
239
+ JSON.stringify({ ...cur, state: 'kept', beatAt: Date.now() }, null, 2),
240
+ );
241
+ } catch {}
228
242
  }
229
243
  return;
230
244
  }
package/src/runner.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  emitTestCompleted,
30
30
  emitTestStarted,
31
31
  } from './bus-events';
32
+ import { extractFailureMessage, stripAnsi } from './extract-failure';
32
33
  import { parseLine } from './parse-line';
33
34
  import { E2eBusyError, acquireRunLock, markKept } from './run-lock';
34
35
  import { SIMULATOR_IPS } from './simulator-ips';
@@ -176,7 +177,7 @@ function loadTiming(): TimingHistory {
176
177
  }
177
178
 
178
179
  function saveTiming(history: TimingHistory): void {
179
- writeFileSync(timingFile, JSON.stringify(history, Object.keys(history).sort(), 2) + '\n');
180
+ writeFileSync(timingFile, `${JSON.stringify(history, Object.keys(history).sort(), 2)}\n`);
180
181
  }
181
182
 
182
183
  function formatDuration(secs: number): string {
@@ -185,67 +186,8 @@ function formatDuration(secs: number): string {
185
186
  }
186
187
 
187
188
  // ─── Error Extraction ────────────────────────────────────────────────
188
-
189
- // Strips ANSI escape sequences so pattern matching works on bun's coloured output
190
- const ANSI_RE = /\x1b\[[0-9;]*m/g;
191
- function stripAnsi(s: string): string {
192
- return s.replace(ANSI_RE, '');
193
- }
194
-
195
- function extractFailureMessage(lines: string[], exitCode: number): string {
196
- const plain = lines.map(stripAnsi);
197
-
198
- // bun's own test failure: "✗ test name [Xms]" — extract the failing test name
199
- const bunFailIdx = plain.findIndex((l) => /^\s*✗\s+\S/.test(l) && !l.includes('failed ('));
200
- if (bunFailIdx >= 0) {
201
- const testName = plain[bunFailIdx]
202
- .replace(/^\s*✗\s+/, '')
203
- .replace(/\s*\[\d+ms\].*$/, '')
204
- .trim();
205
- // Grab the error detail on the next few lines (bun indents it under the test)
206
- const detail = plain
207
- .slice(bunFailIdx + 1, bunFailIdx + 6)
208
- .map((l) => l.trim())
209
- .filter((l) => l.length > 0 && !l.startsWith('at ') && !l.startsWith('│') && l !== '└─')
210
- .slice(0, 2)
211
- .join(' — ');
212
- return detail ? `${testName}: ${detail}` : testName;
213
- }
214
-
215
- // "error: ..." lines (may be indented in bun output)
216
- const errorIdx = plain.findIndex(
217
- (l) => l.trimStart().startsWith('error:') && !l.includes('bun test'),
218
- );
219
- if (errorIdx >= 0) {
220
- const context = plain
221
- .slice(errorIdx, errorIdx + 5)
222
- .map((l) => l.trim())
223
- .filter((l) => l.length > 0 && !l.startsWith('at '));
224
- return context.join('\n ');
225
- }
226
-
227
- const celiloError = plain.find(
228
- (l) => l.includes('CeliloCommandError') || (l.includes('celilo') && l.includes('failed')),
229
- );
230
- if (celiloError) return celiloError.slice(0, 120);
231
-
232
- const timeout = plain.find((l) => l.includes('Timeout') || l.includes('timed out'));
233
- if (timeout) return timeout.slice(0, 120);
234
-
235
- // Last resort: print the last few non-empty, non-progress lines from the output
236
- const tail = plain
237
- .filter(
238
- (l) =>
239
- l.length > 0 &&
240
- !l.startsWith('[progress') &&
241
- !l.startsWith('[e2e:') &&
242
- !l.startsWith('[ansible:'),
243
- )
244
- .slice(-3)
245
- .map((l) => l.trim())
246
- .join(' | ');
247
- return tail || `exit code ${exitCode}`;
248
- }
189
+ // extractFailureMessage / stripAnsi live in extract-failure.ts: this file
190
+ // calls main() at import time, so nothing declared here is testable.
249
191
 
250
192
  // ─── Test Execution ──────────────────────────────────────────────────
251
193
 
@@ -409,7 +351,7 @@ async function runTest(
409
351
  display.abandonFailed();
410
352
  }
411
353
 
412
- writeFileSync(logFile, stdout + '\n---STDERR---\n' + stderr);
354
+ writeFileSync(logFile, `${stdout}\n---STDERR---\n${stderr}`);
413
355
 
414
356
  const expectMatch = stdout.match(/(\d+) expect\(\) calls/);
415
357
  const expectCount = expectMatch ? Number.parseInt(expectMatch[1], 10) : undefined;
@@ -816,7 +758,7 @@ async function main() {
816
758
  if (flagKeep && result.projectName) {
817
759
  writeFileSync(
818
760
  persistentFile,
819
- JSON.stringify(
761
+ `${JSON.stringify(
820
762
  {
821
763
  projectName: result.projectName,
822
764
  testFile: file,
@@ -824,7 +766,7 @@ async function main() {
824
766
  },
825
767
  null,
826
768
  2,
827
- ) + '\n',
769
+ )}\n`,
828
770
  );
829
771
  console.log(`${dim} Persistent network: ${result.projectName}${reset}`);
830
772
  console.log(`${dim} Reuse: ${basename(process.argv[1])} --reuse${reset}`);
@@ -886,7 +828,7 @@ async function main() {
886
828
  const icon = r.status === 'pass' ? '✓' : '✗';
887
829
  const color = r.status === 'pass' ? green : red;
888
830
  console.log(
889
- `${bold}║${reset} ${color}${icon}${reset} ${r.name.padEnd(28)} ${dim}${(r.duration + 's').padStart(6)}${reset}`,
831
+ `${bold}║${reset} ${color}${icon}${reset} ${r.name.padEnd(28)} ${dim}${(`${r.duration}s`).padStart(6)}${reset}`,
890
832
  );
891
833
  if (r.error) {
892
834
  console.log(`${bold}║${reset} ${red}└─ ${r.error}${reset}`);
@@ -911,7 +853,7 @@ async function main() {
911
853
  const adds: string[] = [];
912
854
  if (!content.includes('results/')) adds.push('results/');
913
855
  if (!content.includes('.e2e-timing.json')) adds.push('.e2e-timing.json');
914
- if (adds.length) writeFileSync(gitignore, content.trimEnd() + '\n' + adds.join('\n') + '\n');
856
+ if (adds.length) writeFileSync(gitignore, `${content.trimEnd()}\n${adds.join('\n')}\n`);
915
857
 
916
858
  emitRunCompleted({
917
859
  runId,
@@ -11,9 +11,13 @@
11
11
  */
12
12
 
13
13
  import { execSync } from 'node:child_process';
14
- import { join } from 'node:path';
15
14
  import { copyFileSync, existsSync, rmSync, statSync, writeFileSync } from 'node:fs';
16
- import { generateSharedInfraYaml, SHARED_PROJECT_NAME, SHARED_NETWORKS } from './docker-compose-generator';
15
+ import { join } from 'node:path';
16
+ import {
17
+ SHARED_NETWORKS,
18
+ SHARED_PROJECT_NAME,
19
+ generateSharedInfraYaml,
20
+ } from './docker-compose-generator';
17
21
  import { ensureRegistryServerBundle } from './registry-bundle';
18
22
 
19
23
  const SHARED_COMPOSE_FILE = 'docker-compose.shared.yml';
@@ -37,8 +41,10 @@ function run(cmd: string, opts?: { cwd?: string; timeout?: number }): string {
37
41
  */
38
42
  function nukeE2eResources(e2eDir: string): void {
39
43
  try {
40
- run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} down --volumes --remove-orphans`,
41
- { cwd: e2eDir, timeout: 30_000 });
44
+ run(
45
+ `docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} down --volumes --remove-orphans`,
46
+ { cwd: e2eDir, timeout: 30_000 },
47
+ );
42
48
  } catch {}
43
49
  try {
44
50
  const ids = run('docker ps -aq --filter name=celilo-e2e', { timeout: 10_000 });
@@ -49,11 +55,17 @@ function nukeE2eResources(e2eDir: string): void {
49
55
  .split('\n')
50
56
  .filter((n) => n.startsWith('celilo-e2e'));
51
57
  for (const net of nets) {
52
- try { run(`docker network rm ${net}`, { timeout: 5_000 }); } catch {}
58
+ try {
59
+ run(`docker network rm ${net}`, { timeout: 5_000 });
60
+ } catch {}
53
61
  }
54
62
  } catch {}
55
- try { run('docker network prune -f', { timeout: 10_000 }); } catch {}
56
- try { run('docker volume prune -f', { timeout: 10_000 }); } catch {}
63
+ try {
64
+ run('docker network prune -f', { timeout: 10_000 });
65
+ } catch {}
66
+ try {
67
+ run('docker volume prune -f', { timeout: 10_000 });
68
+ } catch {}
57
69
  }
58
70
 
59
71
  /**
@@ -117,7 +129,9 @@ export async function ensureSharedInfra(): Promise<void> {
117
129
  }
118
130
  } catch {
119
131
  // DNS check failed — restart shared infra
120
- console.log('[progress:start] shared infra DNS check failed, restarting | shared infra restarted');
132
+ console.log(
133
+ '[progress:start] shared infra DNS check failed, restarting | shared infra restarted',
134
+ );
121
135
  await stopSharedInfra();
122
136
  }
123
137
  }
@@ -130,7 +144,9 @@ export async function ensureSharedInfra(): Promise<void> {
130
144
  // This self-heals orphans left by a crashed / cross-path / cross-checkout
131
145
  // prior run (e.g. a botched-mount container that wedges the next `up`),
132
146
  // which the old compose-project-scoped `down` couldn't see (#212).
133
- console.log('[progress:start] cleaning up stale shared infrastructure | shared infra cleanup complete');
147
+ console.log(
148
+ '[progress:start] cleaning up stale shared infrastructure | shared infra cleanup complete',
149
+ );
134
150
  nukeE2eResources(e2eDir);
135
151
 
136
152
  // Refresh the bundled registry-server source so Dockerfile.registry's
@@ -161,12 +177,16 @@ export async function ensureSharedInfra(): Promise<void> {
161
177
 
162
178
  // Build and start
163
179
  console.log('[progress:start] building shared infrastructure images | shared images built');
164
- run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} build`,
165
- { cwd: e2eDir, timeout: 300_000 });
180
+ run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} build`, {
181
+ cwd: e2eDir,
182
+ timeout: 300_000,
183
+ });
166
184
 
167
185
  console.log('[progress:start] starting shared infrastructure | shared infra started');
168
- run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} up -d`,
169
- { cwd: e2eDir, timeout: 120_000 });
186
+ run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} up -d`, {
187
+ cwd: e2eDir,
188
+ timeout: 120_000,
189
+ });
170
190
 
171
191
  // Wait for DNS convergence — robustly. The old single 60s window with no
172
192
  // recovery was an intermittent build-infra failure on the builder (#319:
@@ -196,23 +216,29 @@ export async function ensureSharedInfra(): Promise<void> {
196
216
  if (Date.now() - lastRestart > 45_000) {
197
217
  console.log('[progress:sub] DNS not converged yet — restarting namecheap-dns');
198
218
  try {
199
- run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} restart namecheap-dns`,
200
- { cwd: e2eDir, timeout: 30_000 });
219
+ run(
220
+ `docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} restart namecheap-dns`,
221
+ { cwd: e2eDir, timeout: 30_000 },
222
+ );
201
223
  } catch {}
202
224
  lastRestart = Date.now();
203
225
  }
204
- await new Promise(r => setTimeout(r, 2000));
226
+ await new Promise((r) => setTimeout(r, 2000));
205
227
  }
206
228
  // Diagnostics before failing, so the log shows WHY it didn't converge.
207
229
  let psOut = '';
208
230
  try {
209
- psOut = run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} ps`,
210
- { cwd: e2eDir, timeout: 10_000 });
231
+ psOut = run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} ps`, {
232
+ cwd: e2eDir,
233
+ timeout: 10_000,
234
+ });
211
235
  } catch {}
212
236
  let dnsLog = '';
213
237
  try {
214
- dnsLog = run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} logs --tail 40 namecheap-dns`,
215
- { cwd: e2eDir, timeout: 10_000 });
238
+ dnsLog = run(
239
+ `docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} logs --tail 40 namecheap-dns`,
240
+ { cwd: e2eDir, timeout: 10_000 },
241
+ );
216
242
  } catch {}
217
243
  throw new Error(
218
244
  `Timeout waiting for shared infrastructure DNS convergence (${dnsTimeout / 1000}s)\n` +
@@ -227,8 +253,10 @@ export async function stopSharedInfra(): Promise<void> {
227
253
  console.log('[progress:start] stopping shared infrastructure | shared infra stopped');
228
254
  const e2eDir = getE2eDir();
229
255
  try {
230
- run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} down --volumes --remove-orphans`,
231
- { cwd: e2eDir, timeout: 60_000 });
256
+ run(
257
+ `docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} down --volumes --remove-orphans`,
258
+ { cwd: e2eDir, timeout: 60_000 },
259
+ );
232
260
  } catch {
233
261
  // Best effort
234
262
  }
@@ -17,7 +17,7 @@ export interface SocksProxyOptions {
17
17
  * ACME, Caddy host routing). Recommended for tests that need to
18
18
  * simulate what a real user sees.
19
19
  * - 'internal': operator-on-LAN view, attaches to the internal zone,
20
- * uses split-horizon DNS (192.168.0.10) so iamtheinternet.org resolves
20
+ * uses split-horizon DNS (10.226.1.10) so iamtheinternet.org resolves
21
21
  * to caddy's internal IP directly. Useful for debugging — reaches all
22
22
  * zones via the firewall.
23
23
  */
@@ -65,7 +65,7 @@ const VANTAGE_CONFIGS: Record<SocksProxyVantage, VantageConfig> = {
65
65
  },
66
66
  internal: {
67
67
  network: 'internal',
68
- ip: '192.168.0.150',
68
+ ip: '10.226.1.150',
69
69
  },
70
70
  };
71
71
 
@@ -165,8 +165,7 @@ async function waitForTcpAccept(port: number, timeoutMs = 10_000): Promise<void>
165
165
  await new Promise((r) => setTimeout(r, 100));
166
166
  }
167
167
  throw new Error(
168
- `SOCKS proxy on 127.0.0.1:${port} never accepted connections within ${timeoutMs}ms` +
169
- (lastErr ? ` (last error: ${lastErr})` : ''),
168
+ `SOCKS proxy on 127.0.0.1:${port} never accepted connections within ${timeoutMs}ms${lastErr ? ` (last error: ${lastErr})` : ''}`,
170
169
  );
171
170
  }
172
171
 
@@ -233,8 +232,7 @@ function ensureProxyImage(): void {
233
232
  const dockerfilePath = join(PACKAGE_ROOT, PROXY_DOCKERFILE);
234
233
  if (!existsSync(dockerfilePath)) {
235
234
  throw new Error(
236
- `SOCKS proxy Dockerfile not found at ${dockerfilePath}. ` +
237
- `Expected it to ship with the @celilo/e2e package.`,
235
+ `SOCKS proxy Dockerfile not found at ${dockerfilePath}. Expected it to ship with the @celilo/e2e package.`,
238
236
  );
239
237
  }
240
238
 
@@ -247,18 +245,17 @@ function ensureProxyImage(): void {
247
245
  });
248
246
  } catch (err) {
249
247
  const msg = err instanceof Error ? err.message : String(err);
250
- throw new Error(
251
- `Failed to build ${PROXY_IMAGE} from ${PROXY_DOCKERFILE}: ${msg}`,
252
- );
248
+ throw new Error(`Failed to build ${PROXY_IMAGE} from ${PROXY_DOCKERFILE}: ${msg}`);
253
249
  }
254
250
  }
255
251
 
256
252
  function inspectContainer(name: string): boolean {
257
253
  try {
258
- const result = execSync(
259
- `docker inspect -f '{{.State.Running}}' ${name} 2>/dev/null`,
260
- { timeout: 5_000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] },
261
- );
254
+ const result = execSync(`docker inspect -f '{{.State.Running}}' ${name} 2>/dev/null`, {
255
+ timeout: 5_000,
256
+ encoding: 'utf-8',
257
+ stdio: ['pipe', 'pipe', 'pipe'],
258
+ });
262
259
  return result.trim() === 'true';
263
260
  } catch {
264
261
  return false;
@@ -276,15 +273,15 @@ async function waitForContainerListening(name: string): Promise<void> {
276
273
  let lastInspect = '';
277
274
  while (Date.now() < deadline) {
278
275
  try {
279
- lastInspect = execSync(
280
- `docker inspect -f '{{.State.Status}} {{.State.ExitCode}}' ${name}`,
281
- { timeout: 3_000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] },
282
- ).trim();
276
+ lastInspect = execSync(`docker inspect -f '{{.State.Status}} {{.State.ExitCode}}' ${name}`, {
277
+ timeout: 3_000,
278
+ encoding: 'utf-8',
279
+ stdio: ['pipe', 'pipe', 'pipe'],
280
+ }).trim();
283
281
  } catch {
284
282
  // Container may have already self-removed (--rm) after exiting
285
283
  throw new Error(
286
- `SOCKS proxy container '${name}' disappeared during startup. ` +
287
- `It likely exited immediately. Check the image's required env vars.`,
284
+ `SOCKS proxy container '${name}' disappeared during startup. It likely exited immediately. Check the image's required env vars.`,
288
285
  );
289
286
  }
290
287
  const [status] = lastInspect.split(/\s+/);
package/src/types.ts CHANGED
@@ -123,7 +123,7 @@ export interface NetworkConfig {
123
123
 
124
124
  /**
125
125
  * When set, brings up a SECOND management-class box named `celilo-mgr-2`
126
- * (at 192.168.0.101 on `internal`) using the given image variant. Used by
126
+ * (at 10.226.1.101 on `internal`) using the given image variant. Used by
127
127
  * the migration restore e2e: the primary box is the backup source, the
128
128
  * second box is a fresh target that `apt install celilo` + `celilo restore`s
129
129
  * into. The second box is NOT auto-init'd by container-manager (which keys
@@ -355,7 +355,7 @@ export interface NetworkHandle {
355
355
  * subnets from ZONE_SUBNETS / ZONE_GATEWAYS.
356
356
  *
357
357
  * @param opts.zones - zones to provide (default: dmz, app, secure)
358
- * @param opts.natIp - NAT IP for port-forwarded traffic (default 192.168.0.253)
358
+ * @param opts.natIp - NAT IP for port-forwarded traffic (default 10.226.1.253)
359
359
  */
360
360
  deployFirewall(opts?: {
361
361
  zones?: Array<'dmz' | 'app' | 'secure'>;
@@ -407,22 +407,72 @@ export interface ResponderValues {
407
407
  aspects?: Record<string, boolean>;
408
408
  }
409
409
 
410
- export const ZONE_GATEWAYS: Record<Zone, string> = {
411
- dmz: '10.0.10.1',
412
- app: '10.0.20.1',
413
- secure: '10.0.30.1',
414
- internal: '192.168.0.254',
415
- 'secure-mgmt': '10.0.120.1',
410
+ /**
411
+ * The parent block every simulated PRIVATE zone lives inside (#539).
412
+ *
413
+ * The sim used to number its zones exactly like a real celilo fleet: its dmz,
414
+ * app and secure /24s WERE the fleet's, and its internal LAN was the operator's.
415
+ * That is not a cosmetic overlap — celilo's own forgejo-builder lives in the
416
+ * REAL dmz, so a leaked stack put a second interface on the builder's own /24
417
+ * and blackholed every containerized CI job's route to the forge for ~135s.
418
+ * Twice in one afternoon. Teardown can't be made reliable enough to fix that (a
419
+ * SIGKILL runs no handler), so the fix is that a leak stops MATTERING.
420
+ *
421
+ * `10.226.0.0/16` — mnemonic `0xE2` = "e2", the e2e supernet. It must be
422
+ * RFC-1918 (the private zones model private networks — CLAUDE.md inviolable
423
+ * rules #1/#3), and this block collides with nothing plausible:
424
+ * - the fleet numbers its zones out of the bottom of 10/8 → far away
425
+ * - the operator's VPN sits at the very top of 10/8 → far away
426
+ * - the operator's LAN is in 192.168/16 → different block
427
+ * - Docker's default address pools are 172.16/12 and
428
+ * 192.168/16 → different block
429
+ *
430
+ * The second reason matters more than the collision: there must be nothing
431
+ * magic about the octets production happens to use. A suite that only passes on
432
+ * those exact numbers is asserting one site's address plan rather than celilo's
433
+ * behaviour. Renumbering proves address-independence.
434
+ *
435
+ * Do not move a zone out of this block. `address-plan.test.ts` fails if you do,
436
+ * and fails if the retired fleet-colliding prefixes reappear anywhere in the
437
+ * suite.
438
+ */
439
+ export const SIM_PRIVATE_SUPERNET = '10.226.0.0/16';
440
+
441
+ /**
442
+ * Third-octet plan inside {@link SIM_PRIVATE_SUPERNET}. Host octets are
443
+ * unchanged from the pre-#539 numbering, so `caddy` is still `…10.10` and
444
+ * fw-main is still `…1.254` — only the network part moved.
445
+ */
446
+ const ZONE_PREFIXES: Record<Zone, string> = {
447
+ internal: '10.226.1',
448
+ dmz: '10.226.10',
449
+ app: '10.226.20',
450
+ secure: '10.226.30',
451
+ 'secure-mgmt': '10.226.120',
416
452
  };
417
453
 
418
- export const ZONE_SUBNETS: Record<Zone, string> = {
419
- dmz: '10.0.10.0/24',
420
- app: '10.0.20.0/24',
421
- secure: '10.0.30.0/24',
422
- internal: '192.168.0.0/24',
423
- 'secure-mgmt': '10.0.120.0/24',
454
+ /**
455
+ * An address in a zone, by host octet. Reference this instead of writing an
456
+ * octet: it is what makes the whole plan a one-line change, and what makes a
457
+ * stray hardcoded address stand out as the anomaly it is.
458
+ */
459
+ export function zoneIp(zone: Zone, host: number): string {
460
+ return `${ZONE_PREFIXES[zone]}.${host}`;
461
+ }
462
+
463
+ /** The router/firewall leg in each zone. `internal`'s is fw-main's LAN side. */
464
+ export const ZONE_GATEWAYS: Record<Zone, string> = {
465
+ dmz: zoneIp('dmz', 1),
466
+ app: zoneIp('app', 1),
467
+ secure: zoneIp('secure', 1),
468
+ internal: zoneIp('internal', 254),
469
+ 'secure-mgmt': zoneIp('secure-mgmt', 1),
424
470
  };
425
471
 
472
+ export const ZONE_SUBNETS: Record<Zone, string> = Object.fromEntries(
473
+ (Object.keys(ZONE_PREFIXES) as Zone[]).map((zone) => [zone, `${ZONE_PREFIXES[zone]}.0/24`]),
474
+ ) as Record<Zone, string>;
475
+
426
476
  /**
427
477
  * The firewall's internal-side DNAT ingress (the natIp) in the e2e topology — the role by
428
478
  * which an internal device reaches a service that lives in a segmented zone. Single source
@@ -431,7 +481,7 @@ export const ZONE_SUBNETS: Record<Zone, string> = {
431
481
  * hardcoding the address in a test.
432
482
  */
433
483
  export function internalNatIp(): string {
434
- return '192.168.0.253';
484
+ return zoneIp('internal', 253);
435
485
  }
436
486
 
437
487
  /**
@@ -441,7 +491,7 @@ export function internalNatIp(): string {
441
491
  * this instead of hardcoding the address in a test.
442
492
  */
443
493
  export function greenwaveRouterIp(): string {
444
- return '192.168.0.1';
494
+ return zoneIp('internal', 1);
445
495
  }
446
496
 
447
497
  /**
@@ -466,7 +516,7 @@ export function externalWanSubnet(): string {
466
516
 
467
517
  /** The internal split-horizon DNS resolver (dns-int) in the e2e topology. */
468
518
  export function internalResolverIp(): string {
469
- return '192.168.0.10';
519
+ return zoneIp('internal', 10);
470
520
  }
471
521
 
472
522
  /** The public upstream recursive resolver (the comcast-resolver sim). */
@@ -8,7 +8,7 @@
8
8
  * here — they wait on real firewall isolation (ISS-0118).
9
9
  *
10
10
  * Topology prerequisite: inject the observers the assertion needs, e.g.
11
- * network().dmz({ caddy: '10.0.10.10' }).observe('internalDevice', 'publicInternet')
11
+ * network().dmz({ caddy: '10.226.10.10' }).observe('internalDevice', 'publicInternet')
12
12
  */
13
13
 
14
14
  import { createObserverTransport } from './observer';
@@ -52,7 +52,11 @@ export async function assertFrontedServicePositives(
52
52
  const url = `https://${expectation.fqdn}${expectation.path ?? ''}`;
53
53
 
54
54
  // The internet sees the service at a public IP and a valid HTTPS endpoint.
55
- await probe.assertResolvesInZone({ from: 'publicInternet', name: expectation.fqdn, zone: 'public' });
55
+ await probe.assertResolvesInZone({
56
+ from: 'publicInternet',
57
+ name: expectation.fqdn,
58
+ zone: 'public',
59
+ });
56
60
  await probe.assertHttps({
57
61
  from: 'publicInternet',
58
62
  url,