@celilo/e2e 0.9.3 → 0.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/cpanel-host/cpanel-host-ca.crt +20 -0
- package/config/cpanel-host/docroot/.htaccess +14 -0
- package/config/cpanel-host/docroot/cms.html +4 -0
- package/config/cpanel-host/site-tls.crt +20 -0
- package/config/cpanel-host/site-tls.key +28 -0
- package/config/cpanel-host/site.conf +27 -0
- package/config/dns/com.zone +7 -0
- package/config/dns/knot-namecheap.conf +6 -0
- package/config/dns/tangohost.com.zone +14 -0
- package/config/resolver/unbound.conf +26 -0
- package/config/routing/fw-ext-routes.sh +16 -1
- package/config/routing/management-routes.sh +14 -4
- package/config/routing/minio-startup.sh +9 -0
- package/config/routing/public-sim-entrypoint.sh +24 -0
- package/config/routing/resolver-internal-routes.sh +10 -0
- package/docker/Dockerfile.apt-repo-sim +8 -0
- package/docker/Dockerfile.cpanel-host-sim +75 -0
- package/docker/Dockerfile.firewall +12 -0
- package/docker/Dockerfile.management +6 -0
- package/docker/Dockerfile.minio +7 -0
- package/docker/Dockerfile.npm-registry-sim +8 -0
- package/docker/Dockerfile.observer +11 -0
- package/docker/Dockerfile.registry +8 -0
- package/docker/Dockerfile.signal-cli +60 -0
- package/docker/Dockerfile.signal-release +44 -0
- package/docker/Dockerfile.signal-sim +17 -0
- package/docker/Dockerfile.target-machine +9 -0
- package/package.json +3 -3
- package/registry-server/package.json +17 -0
- package/registry-server/src/auth.test.ts +76 -0
- package/registry-server/src/auth.ts +105 -0
- package/registry-server/src/bootstrap-packaging.test.ts +71 -0
- package/registry-server/src/bootstrap.ts +250 -0
- package/registry-server/src/index.ts +16 -0
- package/registry-server/src/introspection.test.ts +247 -0
- package/registry-server/src/introspection.ts +204 -0
- package/registry-server/src/landing.ts +90 -0
- package/registry-server/src/module-owner-store.test.ts +85 -0
- package/registry-server/src/module-owner-store.ts +108 -0
- package/registry-server/src/rate-limit.test.ts +62 -0
- package/registry-server/src/rate-limit.ts +83 -0
- package/registry-server/src/scoped-token-store.test.ts +93 -0
- package/registry-server/src/scoped-token-store.ts +110 -0
- package/registry-server/src/server.test.ts +771 -0
- package/registry-server/src/server.ts +701 -0
- package/registry-server/src/storage.test.ts +148 -0
- package/registry-server/src/storage.ts +150 -0
- package/registry-server/src/validation.test.ts +86 -0
- package/registry-server/src/validation.ts +60 -0
- package/registry-server/tsconfig.json +15 -0
- package/scripts/stage-libsignal.ts +116 -0
- package/simulators/signal-cli/server.ts +328 -0
- package/src/cli/build.ts +7 -0
- package/src/cli/index.ts +6 -0
- package/src/container-manager.ts +47 -10
- package/src/docker-compose-generator.ts +120 -16
- package/src/exit-cleanup.test.ts +56 -0
- package/src/index.ts +7 -0
- package/src/network-builder.ts +66 -0
- package/src/public-sim-routes.test.ts +117 -0
- package/src/simulator-ips.ts +9 -0
- package/src/types.ts +71 -1
|
@@ -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,21 @@ 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';
|
|
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;
|
|
340
381
|
const networks: Record<string, unknown> = {
|
|
341
382
|
internal: networkDef('192.168.0.0/24', '192.168.0.250'),
|
|
342
383
|
dmz: networkDef('10.0.10.0/24', '10.0.10.250'),
|
|
343
384
|
app: networkDef('10.0.20.0/24', '10.0.20.250'),
|
|
344
385
|
secure: networkDef('10.0.30.0/24', '10.0.30.250'),
|
|
386
|
+
// Only when celilo-mgr lives off the internal LAN — keeps the default
|
|
387
|
+
// topology's generated compose byte-identical for every existing test.
|
|
388
|
+
...(needsSecureMgmt
|
|
389
|
+
? { 'secure-mgmt': networkDef(SECURE_MGMT_SUBNET, '10.0.120.250') }
|
|
390
|
+
: {}),
|
|
345
391
|
'isp-external': networkDef('100.100.0.0/24', '100.100.0.250'),
|
|
346
392
|
// internet-external is owned by shared infra; real-internet is per-test
|
|
347
393
|
// (it just bridges fw-ext ↔ comcast-resolver, no shared service uses it).
|
|
@@ -392,11 +438,15 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
|
|
|
392
438
|
// first. container-manager's celilo-init + routing-verify target the
|
|
393
439
|
// `management` container by name, so `celilo-mgr-2` comes up fresh and
|
|
394
440
|
// un-init'd — the empty target restore needs.
|
|
395
|
-
const emitManagement = (
|
|
441
|
+
const emitManagement = (
|
|
442
|
+
variant: 'default' | 'vanilla' | undefined,
|
|
443
|
+
ip: string,
|
|
444
|
+
zone: 'internal' | 'secure-mgmt' = 'internal',
|
|
445
|
+
) =>
|
|
396
446
|
baseService({
|
|
397
447
|
image:
|
|
398
448
|
variant === 'vanilla' ? 'celilo-e2e/management:vanilla' : 'celilo-e2e/management:latest',
|
|
399
|
-
networks: {
|
|
449
|
+
networks: { [zone]: { ipv4_address: ip } },
|
|
400
450
|
cap_add: ['NET_ADMIN'],
|
|
401
451
|
// ISS-0157: `module import`'s `bun install` hard-links packages from the
|
|
402
452
|
// bun cache into node_modules, which on overlayfs forces a copy-up + fsync
|
|
@@ -416,12 +466,17 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
|
|
|
416
466
|
],
|
|
417
467
|
environment: {
|
|
418
468
|
DDNS_ENDPOINT: `http://${SIMULATOR_IPS.NAMECHEAP_DNS}:8080/update`,
|
|
419
|
-
|
|
469
|
+
// On its own control-plane network the only router in reach is fw-main's
|
|
470
|
+
// leg there — both for the default route and for the segmented zones.
|
|
471
|
+
DEFAULT_GATEWAY: zone === 'secure-mgmt' ? SECURE_MGMT_GATEWAY : managementDefaultGw,
|
|
472
|
+
...(zone === 'secure-mgmt' ? { FW_MAIN_HOP: SECURE_MGMT_GATEWAY } : {}),
|
|
420
473
|
CELILO_REGISTRY_URL: 'http://e2e-registry.lab',
|
|
421
474
|
},
|
|
422
475
|
});
|
|
423
476
|
|
|
424
|
-
services.management =
|
|
477
|
+
services.management = mgmtOnOwnNetwork
|
|
478
|
+
? emitManagement(config.managementVariant, SECURE_MGMT_MANAGEMENT_IP, 'secure-mgmt')
|
|
479
|
+
: emitManagement(config.managementVariant, '192.168.0.100');
|
|
425
480
|
|
|
426
481
|
if (config.secondaryManagementVariant) {
|
|
427
482
|
services['celilo-mgr-2'] = emitManagement(config.secondaryManagementVariant, '192.168.0.101');
|
|
@@ -451,6 +506,17 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
|
|
|
451
506
|
const topologyFn = TOPOLOGY_SERVICES[config.topology] || TOPOLOGY_SERVICES.default;
|
|
452
507
|
Object.assign(services, topologyFn());
|
|
453
508
|
|
|
509
|
+
// Give fw-main a leg on the control-plane network when celilo-mgr lives there,
|
|
510
|
+
// so the management box is routed like any other segmented network rather than
|
|
511
|
+
// being stranded. Applied as a post-step so the default topology's generated
|
|
512
|
+
// compose is untouched.
|
|
513
|
+
if (needsSecureMgmt) {
|
|
514
|
+
const fwMain = services['fw-main'] as { networks?: Record<string, unknown> } | undefined;
|
|
515
|
+
if (fwMain?.networks) {
|
|
516
|
+
fwMain.networks['secure-mgmt'] = { ipv4_address: SECURE_MGMT_GATEWAY };
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
454
520
|
// --- Routing infrastructure (bridges per-test to shared infra) ---
|
|
455
521
|
services['fw-ext'] = baseService({
|
|
456
522
|
build: { context: '.', dockerfile: 'docker/Dockerfile.router' },
|
|
@@ -473,12 +539,7 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
|
|
|
473
539
|
});
|
|
474
540
|
|
|
475
541
|
// --- Dynamic test machines ---
|
|
476
|
-
const allMachines =
|
|
477
|
-
...config.dmzMachines,
|
|
478
|
-
...config.appMachines,
|
|
479
|
-
...config.secureMachines,
|
|
480
|
-
...config.internalMachines,
|
|
481
|
-
];
|
|
542
|
+
const allMachines = getAllMachines(config);
|
|
482
543
|
|
|
483
544
|
for (const machine of allMachines) {
|
|
484
545
|
const zoneNetwork = machine.zone;
|
|
@@ -518,6 +579,49 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
|
|
|
518
579
|
});
|
|
519
580
|
}
|
|
520
581
|
|
|
582
|
+
// --- Optional real signal-cli daemon ---
|
|
583
|
+
// On `internal`, matching the signal module's declared zone — it is a local
|
|
584
|
+
// service celilo talks to, not an internet-facing one, so it does not belong
|
|
585
|
+
// on internet-external. Unlinked: the JSON-RPC surface needs no account.
|
|
586
|
+
if (config.signalCli) {
|
|
587
|
+
services['signal-cli'] = baseService({
|
|
588
|
+
build: { context: '.', dockerfile: 'docker/Dockerfile.signal-cli' },
|
|
589
|
+
networks: { internal: { ipv4_address: '192.168.0.90' } },
|
|
590
|
+
cap_add: ['NET_ADMIN'],
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// --- Optional signal-cli release host ---
|
|
595
|
+
// On internet-external because that is what it models: an artifact the
|
|
596
|
+
// module downloads FROM the internet at deploy time.
|
|
597
|
+
if (config.signalRelease) {
|
|
598
|
+
services['signal-release'] = baseService({
|
|
599
|
+
build: { context: '.', dockerfile: 'docker/Dockerfile.signal-release' },
|
|
600
|
+
networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.SIGNAL_RELEASE } },
|
|
601
|
+
cap_add: ['NET_ADMIN'],
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// --- Optional signal-cli simulator ---
|
|
606
|
+
// Same network placement as the real daemon so a test can swap one for the
|
|
607
|
+
// other without touching the module's configured endpoint.
|
|
608
|
+
if (config.signalSim) {
|
|
609
|
+
services['signal-sim'] = baseService({
|
|
610
|
+
build: { context: '.', dockerfile: 'docker/Dockerfile.signal-sim' },
|
|
611
|
+
networks: { internal: { ipv4_address: '192.168.0.91' } },
|
|
612
|
+
cap_add: ['NET_ADMIN'],
|
|
613
|
+
environment: {
|
|
614
|
+
SIGNAL_ACCOUNT: '+15551234567',
|
|
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',
|
|
621
|
+
},
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
521
625
|
// --- Observer vantages (passive spies; ISS-0117) ---
|
|
522
626
|
// Each is the SAME image at a different network location with a routing profile that
|
|
523
627
|
// mirrors a real device there — that routing profile is what makes it a faithful seat
|
|
@@ -528,6 +632,9 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
|
|
|
528
632
|
build: { context: '.', dockerfile: 'docker/Dockerfile.observer' },
|
|
529
633
|
networks: { [p.network]: { ipv4_address: p.ip } },
|
|
530
634
|
cap_add: ['NET_ADMIN'],
|
|
635
|
+
// A vantage may dial a VPN and probe from INSIDE the tunnel — the only way
|
|
636
|
+
// to assert VPN reach by real signal instead of by reading a rule string.
|
|
637
|
+
devices: ['/dev/net/tun'],
|
|
531
638
|
environment: observerEnv(p),
|
|
532
639
|
});
|
|
533
640
|
}
|
|
@@ -660,6 +767,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
|
|
|
660
767
|
'./config/dns/celilo.computer.zone:/seed/celilo.computer.zone:ro',
|
|
661
768
|
'./config/dns/example.net.zone:/seed/example.net.zone:ro',
|
|
662
769
|
'./config/dns/park-your-domain.com.zone:/seed/park-your-domain.com.zone:ro',
|
|
770
|
+
'./config/dns/tangohost.com.zone:/seed/tangohost.com.zone:ro',
|
|
663
771
|
],
|
|
664
772
|
});
|
|
665
773
|
|
|
@@ -691,12 +799,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
|
|
|
691
799
|
volumes: getRegistryVolumes(),
|
|
692
800
|
});
|
|
693
801
|
|
|
694
|
-
const allMachines =
|
|
695
|
-
...config.dmzMachines,
|
|
696
|
-
...config.appMachines,
|
|
697
|
-
...config.secureMachines,
|
|
698
|
-
...config.internalMachines,
|
|
699
|
-
];
|
|
802
|
+
const allMachines = getAllMachines(config);
|
|
700
803
|
|
|
701
804
|
for (const machine of allMachines) {
|
|
702
805
|
const zoneNetwork = machine.zone;
|
|
@@ -743,5 +846,6 @@ export function getAllMachines(config: NetworkConfig): MachineSpec[] {
|
|
|
743
846
|
...config.appMachines,
|
|
744
847
|
...config.secureMachines,
|
|
745
848
|
...config.internalMachines,
|
|
849
|
+
...(config.secureMgmtMachines ?? []),
|
|
746
850
|
];
|
|
747
851
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recurrence gate for #508: a failed e2e run must not leak its Docker networks.
|
|
3
|
+
*
|
|
4
|
+
* The sim's networks use the same subnets as a real celilo fleet, so on a host
|
|
5
|
+
* that lives in those subnets (celilo's forgejo-builder does) a stranded bridge
|
|
6
|
+
* leaves a duplicate route for a production prefix and blackholes later
|
|
7
|
+
* containers' traffic to the real address. Three consecutive release runs died
|
|
8
|
+
* that way — in `git fetch`, ~132s connect timeout — well after the e2e run
|
|
9
|
+
* that stranded the network had finished.
|
|
10
|
+
*
|
|
11
|
+
* The leak's mechanism was a wiring bug, so the gate is on the wiring.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { expect, test } from 'bun:test';
|
|
18
|
+
|
|
19
|
+
const SRC = readFileSync(
|
|
20
|
+
join(dirname(fileURLToPath(import.meta.url)), 'container-manager.ts'),
|
|
21
|
+
'utf-8',
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
test("teardown is wired to 'exit', which is the event an explicit process.exit() fires", () => {
|
|
25
|
+
// `beforeExit` alone was the bug: the runner ends with
|
|
26
|
+
// `process.exit(failed > 0 ? 1 : 0)`, so on the failure path — where the test
|
|
27
|
+
// never reaches handle.stop() — nothing ever tore the network down.
|
|
28
|
+
expect(SRC).toContain("process.on('exit'");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('cleanup honors --keep / --reuse so a failed run stays debuggable', () => {
|
|
32
|
+
// Without this guard, adding the `exit` hook would destroy exactly the stack
|
|
33
|
+
// an operator asked to keep, since --keep matters most after a failure.
|
|
34
|
+
const body = SRC.slice(
|
|
35
|
+
SRC.indexOf('function cleanupOnExit'),
|
|
36
|
+
SRC.indexOf('process.on(\'SIGTERM\''),
|
|
37
|
+
);
|
|
38
|
+
expect(body).toContain('CELILO_E2E_KEEP');
|
|
39
|
+
expect(body).toContain('CELILO_E2E_REUSE');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("the premise holds: process.exit() fires 'exit' but NOT 'beforeExit'", async () => {
|
|
43
|
+
// The whole fix rests on this runtime behavior. If it ever changes, the
|
|
44
|
+
// reasoning above is void and this should fail loudly rather than silently
|
|
45
|
+
// leaving the leak unfixed.
|
|
46
|
+
const proc = Bun.spawn([
|
|
47
|
+
'bun',
|
|
48
|
+
'-e',
|
|
49
|
+
"process.on('beforeExit', () => console.log('BEFORE_EXIT')); process.on('exit', () => console.log('EXIT')); process.exit(0);",
|
|
50
|
+
]);
|
|
51
|
+
const out = await new Response(proc.stdout).text();
|
|
52
|
+
await proc.exited;
|
|
53
|
+
|
|
54
|
+
expect(out).toContain('EXIT');
|
|
55
|
+
expect(out).not.toContain('BEFORE_EXIT');
|
|
56
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -48,6 +48,8 @@ export type {
|
|
|
48
48
|
} from './types';
|
|
49
49
|
export {
|
|
50
50
|
CeliloCommandError,
|
|
51
|
+
externalWanIp,
|
|
52
|
+
externalWanSubnet,
|
|
51
53
|
greenwaveRouterIp,
|
|
52
54
|
internalNatIp,
|
|
53
55
|
internalResolverIp,
|
|
@@ -84,6 +86,11 @@ export {
|
|
|
84
86
|
TECHNITIUM_DHCP_STACK,
|
|
85
87
|
} from './fixtures';
|
|
86
88
|
|
|
89
|
+
// Simulator addresses on `internet-external` — canonical for code, so a test
|
|
90
|
+
// asserting against the authoritative DNS server never hardcodes its IP.
|
|
91
|
+
export { SIMULATOR_IPS, SIMULATOR_IP_ENTRIES } from './simulator-ips';
|
|
92
|
+
export type { SimulatorIpName } from './simulator-ips';
|
|
93
|
+
|
|
87
94
|
// Shared infrastructure management (for test runners)
|
|
88
95
|
export { ensureSharedInfra, stopSharedInfra, isSharedInfraRunning } from './shared-infra';
|
|
89
96
|
|
package/src/network-builder.ts
CHANGED
|
@@ -38,7 +38,11 @@ export class NetworkBuilder {
|
|
|
38
38
|
appMachines: [],
|
|
39
39
|
secureMachines: [],
|
|
40
40
|
internalMachines: [],
|
|
41
|
+
secureMgmtMachines: [],
|
|
41
42
|
dhcpClient: false,
|
|
43
|
+
signalCli: false,
|
|
44
|
+
signalSim: false,
|
|
45
|
+
signalRelease: false,
|
|
42
46
|
observers: [],
|
|
43
47
|
domain: 'iamtheinternet.org',
|
|
44
48
|
ddnsPassword: 'test123',
|
|
@@ -51,6 +55,17 @@ export class NetworkBuilder {
|
|
|
51
55
|
return this;
|
|
52
56
|
}
|
|
53
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Place the celilo management container in a given zone. Defaults to
|
|
60
|
+
* `internal`; `secure-mgmt` gives celilo-mgr its own control-plane network
|
|
61
|
+
* (the production topology), so a test can exercise celilo reaching and
|
|
62
|
+
* resolving for a fleet from OFF the internal LAN.
|
|
63
|
+
*/
|
|
64
|
+
managementZone(zone: 'internal' | 'secure-mgmt'): this {
|
|
65
|
+
this.config.managementZone = zone;
|
|
66
|
+
return this;
|
|
67
|
+
}
|
|
68
|
+
|
|
54
69
|
/** Append `{name, ip, zone, ...extra}` for each entry to a zone's machine list. */
|
|
55
70
|
private addMachines(
|
|
56
71
|
target: MachineSpec[],
|
|
@@ -80,6 +95,19 @@ export class NetworkBuilder {
|
|
|
80
95
|
return this.addMachines(this.config.internalMachines, 'internal', machines);
|
|
81
96
|
}
|
|
82
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
|
+
|
|
83
111
|
/** Add internal-zone machines that need the Docker-capable image (e.g., Technitium) */
|
|
84
112
|
internalDocker(machines: Record<string, string>): this {
|
|
85
113
|
return this.addMachines(this.config.internalMachines, 'internal', machines, { docker: true });
|
|
@@ -97,6 +125,44 @@ export class NetworkBuilder {
|
|
|
97
125
|
return this;
|
|
98
126
|
}
|
|
99
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Add a real, UNLINKED signal-cli daemon on the internal network.
|
|
130
|
+
*
|
|
131
|
+
* For verifying the JSON-RPC contract against the actual binary. It cannot
|
|
132
|
+
* send to a phone (no account, and CDSI/SVR2 attestation makes a local
|
|
133
|
+
* Signal substitute impossible), but every endpoint, envelope and error
|
|
134
|
+
* shape celilo's client relies on is checkable without one.
|
|
135
|
+
*/
|
|
136
|
+
withSignalCli(): this {
|
|
137
|
+
this.config.signalCli = true;
|
|
138
|
+
return this;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Add the signal-cli SIMULATOR on the internal network.
|
|
143
|
+
*
|
|
144
|
+
* Use this for anything that drives the delivery or ack loop: unlike the
|
|
145
|
+
* real daemon it exposes a control surface (`/_control/inbound`,
|
|
146
|
+
* `/_control/sent`, `/_control/unlink`) so a test can make a human "reply"
|
|
147
|
+
* and then assert on what celilo did about it.
|
|
148
|
+
*/
|
|
149
|
+
withSignalSim(): this {
|
|
150
|
+
this.config.signalSim = true;
|
|
151
|
+
return this;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Serve the signal-cli release tarball from the simulated internet.
|
|
156
|
+
*
|
|
157
|
+
* Needed to DEPLOY the signal module: its ansible role downloads signal-cli
|
|
158
|
+
* at deploy time, and the e2e network is sealed. Point the module at it with
|
|
159
|
+
* `module config set signal release_base_url http://signal-release.lab`.
|
|
160
|
+
*/
|
|
161
|
+
withSignalRelease(): this {
|
|
162
|
+
this.config.signalRelease = true;
|
|
163
|
+
return this;
|
|
164
|
+
}
|
|
165
|
+
|
|
100
166
|
/**
|
|
101
167
|
* Inject one or more observer vantages (ISS-0117) — passive spies carrying the probe
|
|
102
168
|
* toolbox, each placed at its vantage's network location with a routing profile that
|
|
@@ -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/simulator-ips.ts
CHANGED
|
@@ -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
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
|
|
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;
|
|
@@ -45,6 +59,40 @@ export interface NetworkConfig {
|
|
|
45
59
|
managementVolumes: string[];
|
|
46
60
|
/** Include a DHCP client container on the internal network */
|
|
47
61
|
dhcpClient: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* Include a REAL signal-cli daemon on the internal network.
|
|
64
|
+
*
|
|
65
|
+
* Never linked to a Signal account — the JSON-RPC interface is served
|
|
66
|
+
* locally and needs none. It exists so the contract celilo's client depends
|
|
67
|
+
* on is verified against the actual binary rather than against a simulator
|
|
68
|
+
* that encodes the same assumptions. See design.md D16.
|
|
69
|
+
*/
|
|
70
|
+
signalCli: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Include the signal-cli SIMULATOR on the internal network.
|
|
73
|
+
*
|
|
74
|
+
* The drivable counterpart to `signalCli`: it can queue an inbound reply,
|
|
75
|
+
* fail a recipient inside a JSON-RPC success, and revoke the device link on
|
|
76
|
+
* command, which is what a test of the delivery/ack loop needs and what an
|
|
77
|
+
* unlinked real daemon cannot be made to do.
|
|
78
|
+
*/
|
|
79
|
+
signalSim: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* Serve the signal-cli release tarball from the simulated internet, so the
|
|
82
|
+
* signal module's deploy-time download resolves inside the sealed network.
|
|
83
|
+
*/
|
|
84
|
+
signalRelease: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* Which zone the celilo management container sits in. Defaults to `internal`
|
|
87
|
+
* — the single-network topology every existing test uses.
|
|
88
|
+
*
|
|
89
|
+
* `secure-mgmt` places celilo-mgr on its OWN control-plane network, which is
|
|
90
|
+
* the production topology and the one the suite could not express before. That
|
|
91
|
+
* gap is why the control-plane-network bug reached production: with management
|
|
92
|
+
* always on `internal`, the hardcoded "trusted subnet == network.internal.subnet"
|
|
93
|
+
* assumption was accidentally true in every test.
|
|
94
|
+
*/
|
|
95
|
+
managementZone?: 'internal' | 'secure-mgmt';
|
|
48
96
|
/**
|
|
49
97
|
* Observer vantages to inject into the topology. Each becomes an `observer-*`
|
|
50
98
|
* container — a passive spy carrying the probe tooling, placed at the named
|
|
@@ -364,6 +412,7 @@ export const ZONE_GATEWAYS: Record<Zone, string> = {
|
|
|
364
412
|
app: '10.0.20.1',
|
|
365
413
|
secure: '10.0.30.1',
|
|
366
414
|
internal: '192.168.0.254',
|
|
415
|
+
'secure-mgmt': '10.0.120.1',
|
|
367
416
|
};
|
|
368
417
|
|
|
369
418
|
export const ZONE_SUBNETS: Record<Zone, string> = {
|
|
@@ -371,6 +420,7 @@ export const ZONE_SUBNETS: Record<Zone, string> = {
|
|
|
371
420
|
app: '10.0.20.0/24',
|
|
372
421
|
secure: '10.0.30.0/24',
|
|
373
422
|
internal: '192.168.0.0/24',
|
|
423
|
+
'secure-mgmt': '10.0.120.0/24',
|
|
374
424
|
};
|
|
375
425
|
|
|
376
426
|
/**
|
|
@@ -394,6 +444,26 @@ export function greenwaveRouterIp(): string {
|
|
|
394
444
|
return '192.168.0.1';
|
|
395
445
|
}
|
|
396
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
|
+
|
|
397
467
|
/** The internal split-horizon DNS resolver (dns-int) in the e2e topology. */
|
|
398
468
|
export function internalResolverIp(): string {
|
|
399
469
|
return '192.168.0.10';
|