@celilo/e2e 0.9.3 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/config/cpanel-host/cpanel-host-ca.crt +20 -0
  2. package/config/cpanel-host/docroot/.htaccess +14 -0
  3. package/config/cpanel-host/docroot/cms.html +4 -0
  4. package/config/cpanel-host/site-tls.crt +20 -0
  5. package/config/cpanel-host/site-tls.key +28 -0
  6. package/config/cpanel-host/site.conf +27 -0
  7. package/config/dns/com.zone +7 -0
  8. package/config/dns/knot-namecheap.conf +6 -0
  9. package/config/dns/tangohost.com.zone +14 -0
  10. package/config/resolver/unbound.conf +26 -0
  11. package/config/routing/management-routes.sh +9 -4
  12. package/config/routing/resolver-internal-routes.sh +10 -0
  13. package/docker/Dockerfile.cpanel-host-sim +70 -0
  14. package/docker/Dockerfile.firewall +12 -0
  15. package/docker/Dockerfile.management +6 -0
  16. package/docker/Dockerfile.observer +11 -0
  17. package/docker/Dockerfile.signal-cli +60 -0
  18. package/docker/Dockerfile.signal-release +36 -0
  19. package/docker/Dockerfile.signal-sim +17 -0
  20. package/docker/Dockerfile.target-machine +9 -0
  21. package/package.json +3 -3
  22. package/registry-server/package.json +17 -0
  23. package/registry-server/src/auth.test.ts +76 -0
  24. package/registry-server/src/auth.ts +105 -0
  25. package/registry-server/src/bootstrap-packaging.test.ts +71 -0
  26. package/registry-server/src/bootstrap.ts +246 -0
  27. package/registry-server/src/index.ts +16 -0
  28. package/registry-server/src/introspection.test.ts +247 -0
  29. package/registry-server/src/introspection.ts +204 -0
  30. package/registry-server/src/landing.ts +90 -0
  31. package/registry-server/src/module-owner-store.test.ts +85 -0
  32. package/registry-server/src/module-owner-store.ts +108 -0
  33. package/registry-server/src/rate-limit.test.ts +62 -0
  34. package/registry-server/src/rate-limit.ts +83 -0
  35. package/registry-server/src/scoped-token-store.test.ts +93 -0
  36. package/registry-server/src/scoped-token-store.ts +110 -0
  37. package/registry-server/src/server.test.ts +771 -0
  38. package/registry-server/src/server.ts +701 -0
  39. package/registry-server/src/storage.test.ts +148 -0
  40. package/registry-server/src/storage.ts +150 -0
  41. package/registry-server/src/validation.test.ts +86 -0
  42. package/registry-server/src/validation.ts +60 -0
  43. package/registry-server/tsconfig.json +15 -0
  44. package/scripts/stage-libsignal.ts +116 -0
  45. package/simulators/signal-cli/server.ts +272 -0
  46. package/src/cli/build.ts +7 -0
  47. package/src/cli/index.ts +6 -0
  48. package/src/container-manager.ts +5 -1
  49. package/src/docker-compose-generator.ts +108 -4
  50. package/src/network-builder.ts +52 -0
  51. package/src/simulator-ips.ts +9 -0
  52. package/src/types.ts +34 -0
@@ -0,0 +1,272 @@
1
+ /**
2
+ * signal-cli JSON-RPC daemon simulator.
3
+ *
4
+ * Mimics `signal-cli daemon --http <addr>`: JSON-RPC 2.0 over POST, with the
5
+ * behaviours that make the real thing awkward, because those are the ones
6
+ * celilo has to survive:
7
+ *
8
+ * - errors arrive as a JSON-RPC `error` member with HTTP **200**, so code
9
+ * that checks `response.ok` believes a rejected send succeeded;
10
+ * - an unlinked/revoked account answers HTTP perfectly and can send nothing;
11
+ * - `receive` returns receipts and typing indicators alongside real
12
+ * messages, and those are not replies.
13
+ *
14
+ * It pushes back rather than saying yes. Sending to a number that has not been
15
+ * registered with the simulator fails the way Signal fails.
16
+ *
17
+ * ── FIDELITY ───────────────────────────────────────────────────────────────
18
+ * Every shape below is verified against signal-cli 0.14.6 running in a
19
+ * container, plus its published JSON schemas — not against recollection. The
20
+ * contract test in e2e re-checks the real daemon so this file cannot silently
21
+ * drift from it. See design.md D16 for the verified table.
22
+ *
23
+ * Still true: this cannot prove that a LINKED account delivers to a phone.
24
+ * That needs Signal's real network, and CDSI/SVR2 attestation makes a local
25
+ * substitute impossible. Everything short of delivery is verified.
26
+ * ───────────────────────────────────────────────────────────────────────────
27
+ *
28
+ * Control surface (simulator-only, not part of signal-cli):
29
+ * POST /_control/inbound {from, body} queue a reply as if a human sent it
30
+ * POST /_control/soft-fail {recipient} make a recipient fail INSIDE a success
31
+ * POST /_control/unlink revoke the device link
32
+ * POST /_control/relink restore it
33
+ * GET /_control/sent every message sent, for assertions
34
+ */
35
+
36
+ const ACCOUNT = process.env.SIGNAL_ACCOUNT ?? '+15551234567';
37
+ const PORT = Number(process.env.SIGNAL_RPC_PORT ?? 8080);
38
+
39
+ /** Numbers the simulator will accept as recipients. Anything else is rejected. */
40
+ const KNOWN_RECIPIENTS = new Set(
41
+ (process.env.SIGNAL_KNOWN_RECIPIENTS ?? '')
42
+ .split(',')
43
+ .map((n) => n.trim())
44
+ .filter(Boolean),
45
+ );
46
+
47
+ interface SentMessage {
48
+ recipient: string;
49
+ message: string;
50
+ timestamp: number;
51
+ }
52
+
53
+ interface QueuedInbound {
54
+ from: string;
55
+ body: string;
56
+ timestamp: number;
57
+ }
58
+
59
+ const sent: SentMessage[] = [];
60
+ /**
61
+ * Recipients that fail INSIDE a successful response, with a non-SUCCESS
62
+ * `type`. signal-cli only raises a JSON-RPC error when nothing succeeded, so
63
+ * this is how a partial failure looks — and how a client that checks only
64
+ * `error` ends up recording an undelivered page as sent.
65
+ */
66
+ const unregisteredRecipients = new Set<string>();
67
+ const inboundQueue: QueuedInbound[] = [];
68
+ let linked = true;
69
+ let clock = 1_722_200_000_000;
70
+
71
+ const nextTimestamp = () => ++clock;
72
+
73
+ function rpcResult(id: unknown, result: unknown): Response {
74
+ return Response.json({ jsonrpc: '2.0', id, result });
75
+ }
76
+
77
+ /**
78
+ * A JSON-RPC error, returned with HTTP 200 exactly as signal-cli does. This is
79
+ * the single most important fidelity detail in the simulator.
80
+ */
81
+ function rpcError(id: unknown, code: number, message: string): Response {
82
+ return Response.json({ jsonrpc: '2.0', id, error: { code, message } }, { status: 200 });
83
+ }
84
+
85
+ function handleSend(id: unknown, params: Record<string, unknown>): Response {
86
+ if (!linked) {
87
+ return rpcError(id, -32000, 'Account is not registered or the device link was removed');
88
+ }
89
+
90
+ const recipients = Array.isArray(params.recipient) ? (params.recipient as string[]) : [];
91
+ const message = typeof params.message === 'string' ? params.message : '';
92
+
93
+ if (recipients.length === 0) return rpcError(id, -32602, 'No recipient given');
94
+
95
+ for (const recipient of recipients) {
96
+ // Push back like Signal: an unregistered number is a hard failure, not a
97
+ // silent no-op.
98
+ if (KNOWN_RECIPIENTS.size > 0 && !KNOWN_RECIPIENTS.has(recipient)) {
99
+ return rpcError(id, -32000, `Unregistered user: ${recipient}`);
100
+ }
101
+ }
102
+
103
+ const timestamp = nextTimestamp();
104
+ for (const recipient of recipients) {
105
+ sent.push({ recipient, message, timestamp });
106
+ console.log(`[signal-sim] -> ${recipient}: ${message.split('\n')[0]}`);
107
+ }
108
+
109
+ // Verified shape: { timestamp, results: [ { type, recipientAddress } ] }.
110
+ // The per-recipient `type` is the THIRD failure mode — a call can succeed
111
+ // at the JSON-RPC level and still not have delivered.
112
+ return rpcResult(id, {
113
+ timestamp,
114
+ results: recipients.map((recipient) => ({
115
+ type: unregisteredRecipients.has(recipient) ? 'UNREGISTERED_FAILURE' : 'SUCCESS',
116
+ recipientAddress: { number: recipient },
117
+ })),
118
+ });
119
+ }
120
+
121
+ /**
122
+ * Drain the inbound queue.
123
+ *
124
+ * Interleaves a delivery receipt and a typing indicator with real messages —
125
+ * the real daemon does, and a consumer that treats every envelope as a reply
126
+ * will misbehave on them.
127
+ */
128
+ function handleReceive(id: unknown): Response {
129
+ if (!linked) {
130
+ return rpcError(id, -32000, 'Account is not registered or the device link was removed');
131
+ }
132
+
133
+ const envelopes: unknown[] = [
134
+ {
135
+ envelope: {
136
+ sourceNumber: ACCOUNT,
137
+ timestamp: nextTimestamp(),
138
+ receiptMessage: { isDelivery: true },
139
+ },
140
+ },
141
+ ];
142
+
143
+ while (inboundQueue.length > 0) {
144
+ 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
+ });
152
+ console.log(`[signal-sim] <- ${item.from}: ${item.body}`);
153
+ }
154
+
155
+ envelopes.push({
156
+ envelope: {
157
+ sourceNumber: ACCOUNT,
158
+ timestamp: nextTimestamp(),
159
+ typingMessage: { action: 'STARTED' },
160
+ },
161
+ });
162
+
163
+ return rpcResult(id, envelopes);
164
+ }
165
+
166
+ function handleListAccounts(id: unknown): Response {
167
+ // A revoked link answers happily with an empty account list — reachability
168
+ // is not health.
169
+ return rpcResult(id, linked ? [{ number: ACCOUNT }] : []);
170
+ }
171
+
172
+ async function handleControl(url: URL, req: Request): Promise<Response> {
173
+ switch (url.pathname) {
174
+ case '/_control/inbound': {
175
+ const body = (await req.json()) as { from?: string; body?: string };
176
+ if (!body.from || !body.body) {
177
+ return Response.json({ error: 'from and body required' }, { status: 400 });
178
+ }
179
+ inboundQueue.push({ from: body.from, body: body.body, timestamp: nextTimestamp() });
180
+ return Response.json({ queued: inboundQueue.length });
181
+ }
182
+ case '/_control/soft-fail': {
183
+ const body = (await req.json()) as { recipient?: string };
184
+ if (!body.recipient) {
185
+ return Response.json({ error: 'recipient required' }, { status: 400 });
186
+ }
187
+ unregisteredRecipients.add(body.recipient);
188
+ return Response.json({ softFailing: [...unregisteredRecipients] });
189
+ }
190
+ case '/_control/unlink':
191
+ linked = false;
192
+ console.log('[signal-sim] device link revoked');
193
+ return Response.json({ linked });
194
+ case '/_control/relink':
195
+ linked = true;
196
+ console.log('[signal-sim] device link restored');
197
+ return Response.json({ linked });
198
+ case '/_control/sent':
199
+ return Response.json({ sent });
200
+ case '/_control/reset':
201
+ sent.length = 0;
202
+ inboundQueue.length = 0;
203
+ unregisteredRecipients.clear();
204
+ linked = true;
205
+ return Response.json({ ok: true });
206
+ default:
207
+ return new Response('Not found', { status: 404 });
208
+ }
209
+ }
210
+
211
+ Bun.serve({
212
+ port: PORT,
213
+ hostname: '0.0.0.0',
214
+ async fetch(req) {
215
+ const url = new URL(req.url);
216
+
217
+ if (url.pathname.startsWith('/_control/')) return handleControl(url, req);
218
+
219
+ // Verified endpoints on the real daemon.
220
+ if (url.pathname === '/api/v1/check') {
221
+ return req.method === 'GET' ? new Response(null, { status: 200 }) : new Response(null, { status: 405 });
222
+ }
223
+ if (url.pathname === '/api/v1/events') {
224
+ return new Response('', { status: 200, headers: { 'content-type': 'text/event-stream' } });
225
+ }
226
+ if (url.pathname !== '/api/v1/rpc') return new Response('Not found', { status: 404 });
227
+
228
+ // Verified: the real daemon returns 415 when Content-Type is not JSON.
229
+ const contentType = req.headers.get('content-type');
230
+ if (!contentType?.startsWith('application/json')) {
231
+ return new Response(null, { status: 415 });
232
+ }
233
+
234
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
235
+
236
+ let payload: { id?: unknown; method?: string; params?: Record<string, unknown> };
237
+ try {
238
+ payload = (await req.json()) as typeof payload;
239
+ } catch {
240
+ return Response.json(
241
+ { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } },
242
+ { status: 200 },
243
+ );
244
+ }
245
+
246
+ const { id = null, method, params = {} } = payload;
247
+
248
+ switch (method) {
249
+ case 'send':
250
+ return handleSend(id, params);
251
+ case 'receive':
252
+ return handleReceive(id);
253
+ case 'listAccounts':
254
+ return handleListAccounts(id);
255
+ case 'version':
256
+ // Verified: { "version": "0.14.6" }
257
+ return rpcResult(id, { version: '0.14.6' });
258
+ default:
259
+ return rpcError(id, -32601, `Method not found: ${method}`);
260
+ }
261
+ },
262
+ });
263
+
264
+ console.log(`[signal-sim] signal-cli JSON-RPC simulator on :${PORT} as ${ACCOUNT}`);
265
+ if (KNOWN_RECIPIENTS.size > 0) {
266
+ console.log(`[signal-sim] known recipients: ${[...KNOWN_RECIPIENTS].join(', ')}`);
267
+ }
268
+
269
+ // Marks this file a module so its top-level constants get their own scope.
270
+ // Without it, `PORT` here collides with the same name in the isitup simulator,
271
+ // which shares a tsconfig.
272
+ export {};
package/src/cli/build.ts CHANGED
@@ -23,6 +23,7 @@ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } fro
23
23
  import { basename, join, resolve } from 'node:path';
24
24
  import { gunzipSync } from 'node:zlib';
25
25
  import { stageAptRepo } from '../../scripts/stage-apt-repo';
26
+ import { stageLibsignal } from '../../scripts/stage-libsignal';
26
27
  import { ensureRegistryServerBundle } from '../registry-bundle';
27
28
 
28
29
  /**
@@ -176,6 +177,12 @@ async function stageSimulatorInputs(pkgDir: string): Promise<void> {
176
177
  // time, so this must run before buildDockerImages().
177
178
  stageAptRepo(repoRoot, pkgDir);
178
179
 
180
+ // (d) Build + stage the libsignal aarch64 native as a .deb, into the SAME
181
+ // pool stageAptRepo just populated (so it must run after it — that function
182
+ // recreates the pool empty). Compiled here on the host's own network rather
183
+ // than inside the sealed e2e network; see stage-libsignal.ts.
184
+ stageLibsignal(repoRoot, pkgDir);
185
+
179
186
  console.log('');
180
187
  }
181
188
 
package/src/cli/index.ts CHANGED
@@ -61,6 +61,12 @@ function detectTestDir(): { stateDir: string; testsPath: string } {
61
61
  }
62
62
  }
63
63
  if (existsSync(join(cwd, 'tests'))) return { stateDir: cwd, testsPath: join(cwd, 'tests') };
64
+ // The repo's own top-level suite lives at e2e/tests/, which is what `list`
65
+ // enumerates. Without this, `cele2e list` shows a test that `cele2e run
66
+ // <name>` then cannot find — the run/list disagreement in #412.
67
+ if (existsSync(join(cwd, 'e2e', 'tests'))) {
68
+ return { stateDir: join(cwd, 'e2e'), testsPath: join(cwd, 'e2e', 'tests') };
69
+ }
64
70
  return { stateDir: cwd, testsPath: join(cwd, 'tests') };
65
71
  }
66
72
 
@@ -935,7 +935,11 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
935
935
  `celilo system init --accept-defaults \
936
936
  network.internal.subnet=${ZONE_SUBNETS.internal} \
937
937
  network.internal.gateway=${ZONE_GATEWAYS.internal} \
938
- dns.primary=100.100.0.1 \
938
+ ${
939
+ config.managementZone === 'secure-mgmt'
940
+ ? 'network.secure-mgmt.subnet=10.0.120.0/24 network.secure-mgmt.gateway=10.0.120.1 '
941
+ : ''
942
+ }dns.primary=100.100.0.1 \
939
943
  dns.fallback=1.0.0.1,8.8.8.8`,
940
944
  );
941
945
  if (initResult.exitCode !== 0) {
@@ -79,6 +79,8 @@ function baseService(opts: {
79
79
  tmpfs?: string[];
80
80
  privileged?: boolean;
81
81
  dns?: string[];
82
+ /** Host devices to expose, e.g. `/dev/net/tun` for a userspace WireGuard tunnel. */
83
+ devices?: string[];
82
84
  }) {
83
85
  // Auto-assign an image tag from the Dockerfile name so Docker can
84
86
  // skip builds when pre-built images exist.
@@ -109,6 +111,16 @@ export const SHARED_NETWORKS = {
109
111
  * Default topology: fw-isp (greenwave) + fw-main (iptables)
110
112
  * Two-layer NAT: internet → fw-isp → fw-main → target
111
113
  */
114
+ /**
115
+ * celilo's own control-plane network, used only when `managementZone` is
116
+ * 'secure-mgmt'. Deliberately NOT one of the segmented data-plane tiers: it is
117
+ * where celilo-mgr itself lives, mirroring the production topology the suite
118
+ * previously could not express.
119
+ */
120
+ const SECURE_MGMT_SUBNET = '10.0.120.0/24';
121
+ const SECURE_MGMT_GATEWAY = '10.0.120.1';
122
+ const SECURE_MGMT_MANAGEMENT_IP = '10.0.120.100';
123
+
112
124
  function defaultTopologyServices(): Record<string, unknown> {
113
125
  return {
114
126
  'fw-main': baseService({
@@ -120,6 +132,10 @@ function defaultTopologyServices(): Record<string, unknown> {
120
132
  secure: { ipv4_address: '10.0.30.1' },
121
133
  },
122
134
  cap_add: ['NET_ADMIN', 'SYS_ADMIN'],
135
+ // A firewall may also terminate a VPN (the admin tunnel the `wireguard`
136
+ // module owns). wireguard-go needs the tun device; the kernel module is
137
+ // deliberately not relied on — see Dockerfile.firewall.
138
+ devices: ['/dev/net/tun'],
123
139
  sysctls: ROUTER_SYSCTLS,
124
140
  volumes: ['ssh-keys:/ssh-keys:ro'],
125
141
  }),
@@ -130,6 +146,10 @@ function defaultTopologyServices(): Record<string, unknown> {
130
146
  'isp-external': { ipv4_address: '100.100.0.100' },
131
147
  },
132
148
  cap_add: ['NET_ADMIN', 'SYS_ADMIN'],
149
+ // A firewall may also terminate a VPN (the admin tunnel the `wireguard`
150
+ // module owns). wireguard-go needs the tun device; the kernel module is
151
+ // deliberately not relied on — see Dockerfile.firewall.
152
+ devices: ['/dev/net/tun'],
133
153
  sysctls: ROUTER_SYSCTLS,
134
154
  volumes: ['ssh-keys:/ssh-keys:ro'],
135
155
  }),
@@ -153,6 +173,10 @@ function directInternetTopologyServices(): Record<string, unknown> {
153
173
  'isp-external': { ipv4_address: '100.100.0.100' },
154
174
  },
155
175
  cap_add: ['NET_ADMIN', 'SYS_ADMIN'],
176
+ // A firewall may also terminate a VPN (the admin tunnel the `wireguard`
177
+ // module owns). wireguard-go needs the tun device; the kernel module is
178
+ // deliberately not relied on — see Dockerfile.firewall.
179
+ devices: ['/dev/net/tun'],
156
180
  sysctls: ROUTER_SYSCTLS,
157
181
  volumes: ['ssh-keys:/ssh-keys:ro'],
158
182
  }),
@@ -232,6 +256,7 @@ export function generateSharedInfraYaml(): string {
232
256
  './config/dns/celilo.computer.zone:/seed/celilo.computer.zone:ro',
233
257
  './config/dns/example.net.zone:/seed/example.net.zone:ro',
234
258
  './config/dns/park-your-domain.com.zone:/seed/park-your-domain.com.zone:ro',
259
+ './config/dns/tangohost.com.zone:/seed/tangohost.com.zone:ro',
235
260
  ],
236
261
  });
237
262
 
@@ -292,6 +317,17 @@ export function generateSharedInfraYaml(): string {
292
317
  // packages/e2e/scripts/pack-celilo-packages.ts (cli, capabilities,
293
318
  // cli-display, e2e, event-bus). Reachable at http://npm-registry.lab from
294
319
  // any container that uses the internal resolver.
320
+ // OFF-FLEET web host (external_web). A PUBLIC PEER on internet-external, not
321
+ // a fleet member and not behind the customer firewall: celilo reaches it by
322
+ // SSH egress the way it would reach any host on the real internet, and public
323
+ // DNS resolves tangohost.com straight to this address. It runs its own Apache
324
+ // and its own pre-installed TLS — celilo governs none of it.
325
+ services['cpanel-host'] = baseService({
326
+ build: { context: '.', dockerfile: 'docker/Dockerfile.cpanel-host-sim' },
327
+ networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.CPANEL_HOST } },
328
+ cap_add: ['NET_ADMIN'],
329
+ });
330
+
295
331
  services['npm-registry'] = baseService({
296
332
  build: { context: '.', dockerfile: 'docker/Dockerfile.npm-registry-sim' },
297
333
  networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.NPM_REGISTRY } },
@@ -337,11 +373,17 @@ export function generateSharedInfraYaml(): string {
337
373
  * Creates its own internal, dmz, app, secure networks.
338
374
  */
339
375
  export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: string): string {
376
+ const mgmtOnOwnNetwork = config.managementZone === 'secure-mgmt';
340
377
  const networks: Record<string, unknown> = {
341
378
  internal: networkDef('192.168.0.0/24', '192.168.0.250'),
342
379
  dmz: networkDef('10.0.10.0/24', '10.0.10.250'),
343
380
  app: networkDef('10.0.20.0/24', '10.0.20.250'),
344
381
  secure: networkDef('10.0.30.0/24', '10.0.30.250'),
382
+ // Only when celilo-mgr lives off the internal LAN — keeps the default
383
+ // topology's generated compose byte-identical for every existing test.
384
+ ...(mgmtOnOwnNetwork
385
+ ? { 'secure-mgmt': networkDef(SECURE_MGMT_SUBNET, '10.0.120.250') }
386
+ : {}),
345
387
  'isp-external': networkDef('100.100.0.0/24', '100.100.0.250'),
346
388
  // internet-external is owned by shared infra; real-internet is per-test
347
389
  // (it just bridges fw-ext ↔ comcast-resolver, no shared service uses it).
@@ -392,11 +434,15 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
392
434
  // first. container-manager's celilo-init + routing-verify target the
393
435
  // `management` container by name, so `celilo-mgr-2` comes up fresh and
394
436
  // un-init'd — the empty target restore needs.
395
- const emitManagement = (variant: 'default' | 'vanilla' | undefined, ip: string) =>
437
+ const emitManagement = (
438
+ variant: 'default' | 'vanilla' | undefined,
439
+ ip: string,
440
+ zone: 'internal' | 'secure-mgmt' = 'internal',
441
+ ) =>
396
442
  baseService({
397
443
  image:
398
444
  variant === 'vanilla' ? 'celilo-e2e/management:vanilla' : 'celilo-e2e/management:latest',
399
- networks: { internal: { ipv4_address: ip } },
445
+ networks: { [zone]: { ipv4_address: ip } },
400
446
  cap_add: ['NET_ADMIN'],
401
447
  // ISS-0157: `module import`'s `bun install` hard-links packages from the
402
448
  // bun cache into node_modules, which on overlayfs forces a copy-up + fsync
@@ -416,12 +462,17 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
416
462
  ],
417
463
  environment: {
418
464
  DDNS_ENDPOINT: `http://${SIMULATOR_IPS.NAMECHEAP_DNS}:8080/update`,
419
- DEFAULT_GATEWAY: managementDefaultGw,
465
+ // On its own control-plane network the only router in reach is fw-main's
466
+ // leg there — both for the default route and for the segmented zones.
467
+ DEFAULT_GATEWAY: zone === 'secure-mgmt' ? SECURE_MGMT_GATEWAY : managementDefaultGw,
468
+ ...(zone === 'secure-mgmt' ? { FW_MAIN_HOP: SECURE_MGMT_GATEWAY } : {}),
420
469
  CELILO_REGISTRY_URL: 'http://e2e-registry.lab',
421
470
  },
422
471
  });
423
472
 
424
- services.management = emitManagement(config.managementVariant, '192.168.0.100');
473
+ services.management = mgmtOnOwnNetwork
474
+ ? emitManagement(config.managementVariant, SECURE_MGMT_MANAGEMENT_IP, 'secure-mgmt')
475
+ : emitManagement(config.managementVariant, '192.168.0.100');
425
476
 
426
477
  if (config.secondaryManagementVariant) {
427
478
  services['celilo-mgr-2'] = emitManagement(config.secondaryManagementVariant, '192.168.0.101');
@@ -451,6 +502,17 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
451
502
  const topologyFn = TOPOLOGY_SERVICES[config.topology] || TOPOLOGY_SERVICES.default;
452
503
  Object.assign(services, topologyFn());
453
504
 
505
+ // Give fw-main a leg on the control-plane network when celilo-mgr lives there,
506
+ // so the management box is routed like any other segmented network rather than
507
+ // being stranded. Applied as a post-step so the default topology's generated
508
+ // compose is untouched.
509
+ if (mgmtOnOwnNetwork) {
510
+ const fwMain = services['fw-main'] as { networks?: Record<string, unknown> } | undefined;
511
+ if (fwMain?.networks) {
512
+ fwMain.networks['secure-mgmt'] = { ipv4_address: SECURE_MGMT_GATEWAY };
513
+ }
514
+ }
515
+
454
516
  // --- Routing infrastructure (bridges per-test to shared infra) ---
455
517
  services['fw-ext'] = baseService({
456
518
  build: { context: '.', dockerfile: 'docker/Dockerfile.router' },
@@ -518,6 +580,44 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
518
580
  });
519
581
  }
520
582
 
583
+ // --- Optional real signal-cli daemon ---
584
+ // On `internal`, matching the signal module's declared zone — it is a local
585
+ // service celilo talks to, not an internet-facing one, so it does not belong
586
+ // on internet-external. Unlinked: the JSON-RPC surface needs no account.
587
+ if (config.signalCli) {
588
+ services['signal-cli'] = baseService({
589
+ build: { context: '.', dockerfile: 'docker/Dockerfile.signal-cli' },
590
+ networks: { internal: { ipv4_address: '192.168.0.90' } },
591
+ cap_add: ['NET_ADMIN'],
592
+ });
593
+ }
594
+
595
+ // --- Optional signal-cli release host ---
596
+ // On internet-external because that is what it models: an artifact the
597
+ // module downloads FROM the internet at deploy time.
598
+ if (config.signalRelease) {
599
+ services['signal-release'] = baseService({
600
+ build: { context: '.', dockerfile: 'docker/Dockerfile.signal-release' },
601
+ networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.SIGNAL_RELEASE } },
602
+ cap_add: ['NET_ADMIN'],
603
+ });
604
+ }
605
+
606
+ // --- Optional signal-cli simulator ---
607
+ // Same network placement as the real daemon so a test can swap one for the
608
+ // other without touching the module's configured endpoint.
609
+ if (config.signalSim) {
610
+ services['signal-sim'] = baseService({
611
+ build: { context: '.', dockerfile: 'docker/Dockerfile.signal-sim' },
612
+ networks: { internal: { ipv4_address: '192.168.0.91' } },
613
+ cap_add: ['NET_ADMIN'],
614
+ environment: {
615
+ SIGNAL_ACCOUNT: '+15551234567',
616
+ SIGNAL_KNOWN_RECIPIENTS: '+15550001,+15550002',
617
+ },
618
+ });
619
+ }
620
+
521
621
  // --- Observer vantages (passive spies; ISS-0117) ---
522
622
  // Each is the SAME image at a different network location with a routing profile that
523
623
  // mirrors a real device there — that routing profile is what makes it a faithful seat
@@ -528,6 +628,9 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
528
628
  build: { context: '.', dockerfile: 'docker/Dockerfile.observer' },
529
629
  networks: { [p.network]: { ipv4_address: p.ip } },
530
630
  cap_add: ['NET_ADMIN'],
631
+ // A vantage may dial a VPN and probe from INSIDE the tunnel — the only way
632
+ // to assert VPN reach by real signal instead of by reading a rule string.
633
+ devices: ['/dev/net/tun'],
531
634
  environment: observerEnv(p),
532
635
  });
533
636
  }
@@ -660,6 +763,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
660
763
  './config/dns/celilo.computer.zone:/seed/celilo.computer.zone:ro',
661
764
  './config/dns/example.net.zone:/seed/example.net.zone:ro',
662
765
  './config/dns/park-your-domain.com.zone:/seed/park-your-domain.com.zone:ro',
766
+ './config/dns/tangohost.com.zone:/seed/tangohost.com.zone:ro',
663
767
  ],
664
768
  });
665
769
 
@@ -39,6 +39,9 @@ export class NetworkBuilder {
39
39
  secureMachines: [],
40
40
  internalMachines: [],
41
41
  dhcpClient: false,
42
+ signalCli: false,
43
+ signalSim: false,
44
+ signalRelease: false,
42
45
  observers: [],
43
46
  domain: 'iamtheinternet.org',
44
47
  ddnsPassword: 'test123',
@@ -51,6 +54,17 @@ export class NetworkBuilder {
51
54
  return this;
52
55
  }
53
56
 
57
+ /**
58
+ * Place the celilo management container in a given zone. Defaults to
59
+ * `internal`; `secure-mgmt` gives celilo-mgr its own control-plane network
60
+ * (the production topology), so a test can exercise celilo reaching and
61
+ * resolving for a fleet from OFF the internal LAN.
62
+ */
63
+ managementZone(zone: 'internal' | 'secure-mgmt'): this {
64
+ this.config.managementZone = zone;
65
+ return this;
66
+ }
67
+
54
68
  /** Append `{name, ip, zone, ...extra}` for each entry to a zone's machine list. */
55
69
  private addMachines(
56
70
  target: MachineSpec[],
@@ -97,6 +111,44 @@ export class NetworkBuilder {
97
111
  return this;
98
112
  }
99
113
 
114
+ /**
115
+ * Add a real, UNLINKED signal-cli daemon on the internal network.
116
+ *
117
+ * For verifying the JSON-RPC contract against the actual binary. It cannot
118
+ * send to a phone (no account, and CDSI/SVR2 attestation makes a local
119
+ * Signal substitute impossible), but every endpoint, envelope and error
120
+ * shape celilo's client relies on is checkable without one.
121
+ */
122
+ withSignalCli(): this {
123
+ this.config.signalCli = true;
124
+ return this;
125
+ }
126
+
127
+ /**
128
+ * Add the signal-cli SIMULATOR on the internal network.
129
+ *
130
+ * Use this for anything that drives the delivery or ack loop: unlike the
131
+ * real daemon it exposes a control surface (`/_control/inbound`,
132
+ * `/_control/sent`, `/_control/unlink`) so a test can make a human "reply"
133
+ * and then assert on what celilo did about it.
134
+ */
135
+ withSignalSim(): this {
136
+ this.config.signalSim = true;
137
+ return this;
138
+ }
139
+
140
+ /**
141
+ * Serve the signal-cli release tarball from the simulated internet.
142
+ *
143
+ * Needed to DEPLOY the signal module: its ansible role downloads signal-cli
144
+ * at deploy time, and the e2e network is sealed. Point the module at it with
145
+ * `module config set signal release_base_url http://signal-release.lab`.
146
+ */
147
+ withSignalRelease(): this {
148
+ this.config.signalRelease = true;
149
+ return this;
150
+ }
151
+
100
152
  /**
101
153
  * Inject one or more observer vantages (ISS-0117) — passive spies carrying the probe
102
154
  * toolbox, each placed at its vantage's network location with a routing profile that
@@ -35,6 +35,15 @@ export const SIMULATOR_IPS = {
35
35
  APT_REPO: '100.64.0.60',
36
36
  /** MinIO S3-compatible object storage — backup/restore target (replaces AWS S3 in tests). Reachable at http://minio.lab. */
37
37
  MINIO: '100.64.0.61',
38
+ /**
39
+ * OFF-FLEET cPanel/SSH web host simulator (`tangohost.com`) — a third-party
40
+ * host celilo publishes to via `external_web` but does NOT govern. A public
41
+ * peer of namecheap-dns/pebble, NOT behind the customer firewall, so public
42
+ * DNS pointing the domain here is a genuine public endpoint.
43
+ */
44
+ CPANEL_HOST: '100.64.0.63',
45
+ /** signal-cli release host — serves the tarball the signal module downloads at deploy time. */
46
+ SIGNAL_RELEASE: '100.64.0.62',
38
47
  /** Pebble ACME server (replaces production Let's Encrypt). */
39
48
  PEBBLE: '100.64.0.100',
40
49
  } as const;
package/src/types.ts CHANGED
@@ -45,6 +45,40 @@ export interface NetworkConfig {
45
45
  managementVolumes: string[];
46
46
  /** Include a DHCP client container on the internal network */
47
47
  dhcpClient: boolean;
48
+ /**
49
+ * Include a REAL signal-cli daemon on the internal network.
50
+ *
51
+ * Never linked to a Signal account — the JSON-RPC interface is served
52
+ * locally and needs none. It exists so the contract celilo's client depends
53
+ * on is verified against the actual binary rather than against a simulator
54
+ * that encodes the same assumptions. See design.md D16.
55
+ */
56
+ signalCli: boolean;
57
+ /**
58
+ * Include the signal-cli SIMULATOR on the internal network.
59
+ *
60
+ * The drivable counterpart to `signalCli`: it can queue an inbound reply,
61
+ * fail a recipient inside a JSON-RPC success, and revoke the device link on
62
+ * command, which is what a test of the delivery/ack loop needs and what an
63
+ * unlinked real daemon cannot be made to do.
64
+ */
65
+ signalSim: boolean;
66
+ /**
67
+ * Serve the signal-cli release tarball from the simulated internet, so the
68
+ * signal module's deploy-time download resolves inside the sealed network.
69
+ */
70
+ signalRelease: boolean;
71
+ /**
72
+ * Which zone the celilo management container sits in. Defaults to `internal`
73
+ * — the single-network topology every existing test uses.
74
+ *
75
+ * `secure-mgmt` places celilo-mgr on its OWN control-plane network, which is
76
+ * the production topology and the one the suite could not express before. That
77
+ * gap is why the control-plane-network bug reached production: with management
78
+ * always on `internal`, the hardcoded "trusted subnet == network.internal.subnet"
79
+ * assumption was accidentally true in every test.
80
+ */
81
+ managementZone?: 'internal' | 'secure-mgmt';
48
82
  /**
49
83
  * Observer vantages to inject into the topology. Each becomes an `observer-*`
50
84
  * container — a passive spy carrying the probe tooling, placed at the named