@celilo/e2e 0.13.0 → 0.13.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.
- package/docker/Dockerfile.firewall +12 -1
- package/package.json +3 -3
- package/src/container-manager.ts +126 -12
- package/src/types.ts +28 -9
|
@@ -12,10 +12,21 @@ RUN apt-get update && apt-get install -y \
|
|
|
12
12
|
dnsutils \
|
|
13
13
|
procps \
|
|
14
14
|
python3 \
|
|
15
|
-
wireguard-tools \
|
|
16
15
|
wireguard-go \
|
|
17
16
|
&& rm -rf /var/lib/apt/lists/*
|
|
18
17
|
|
|
18
|
+
# `wireguard-tools` is DELIBERATELY not installed here. The wireguard module's
|
|
19
|
+
# job includes putting it on the host, and baking it in meant the rig proved the
|
|
20
|
+
# tunnel worked on the one kind of host that could never exercise that: the
|
|
21
|
+
# module only ever ran `wg --version`, found it, and moved on. On a real firewall
|
|
22
|
+
# the deploy failed and told the operator to install wireguard by hand — over the
|
|
23
|
+
# access the VPN was being deployed to provide.
|
|
24
|
+
#
|
|
25
|
+
# `wireguard-go` stays, and is a different kind of thing. A container cannot load
|
|
26
|
+
# a kernel module, so wg-quick needs a userspace implementation to fall back to;
|
|
27
|
+
# that is the simulator standing in for hardware it does not have, not a step
|
|
28
|
+
# celilo is supposed to perform.
|
|
29
|
+
|
|
19
30
|
# Containers have no wireguard kernel module to load, and a test must not depend
|
|
20
31
|
# on whether the Docker host happens to have one. wg-quick falls back to this
|
|
21
32
|
# userspace implementation when `ip link add type wireguard` fails, so the tunnel
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/e2e",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.1",
|
|
4
4
|
"description": "E2E test infrastructure for Celilo-deployed applications. Provides a simulated internet with DNS hierarchy, ACME server, firewalls, and target machines in Docker.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -37,9 +37,9 @@
|
|
|
37
37
|
"README.md"
|
|
38
38
|
],
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@celilo/capabilities": "^1.
|
|
40
|
+
"@celilo/capabilities": "^1.2.0",
|
|
41
41
|
"@celilo/cli-display": "^0.2.0",
|
|
42
|
-
"@celilo/event-bus": "^0.
|
|
42
|
+
"@celilo/event-bus": "^0.5.0",
|
|
43
43
|
"yaml": "^2.8.0",
|
|
44
44
|
"zod": "^3.24.1"
|
|
45
45
|
},
|
package/src/container-manager.ts
CHANGED
|
@@ -535,6 +535,9 @@ function buildNetworkHandle(
|
|
|
535
535
|
// Playwright browsers). stop() tears these down before the docker
|
|
536
536
|
// network itself.
|
|
537
537
|
const activeProxies: SocksProxyHandle[] = [];
|
|
538
|
+
/** Services plugged into the undeclared segment, and the subnet it uses. */
|
|
539
|
+
const attachedToAlienSegment = new Set<string>();
|
|
540
|
+
let alienSegmentSubnet = '';
|
|
538
541
|
const activeBrowsers: BrowserHandle[] = [];
|
|
539
542
|
|
|
540
543
|
const handle: NetworkHandle = {
|
|
@@ -612,19 +615,32 @@ function buildNetworkHandle(
|
|
|
612
615
|
await new Promise((r) => setTimeout(r, 500));
|
|
613
616
|
},
|
|
614
617
|
|
|
615
|
-
async deployFirewall(opts = {}): Promise<
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
//
|
|
618
|
+
async deployFirewall(opts = {}): Promise<ExecResult> {
|
|
619
|
+
// Everything the firewall declares is derived from the topology it is
|
|
620
|
+
// wired for. `opts.zones` used to select which zones got
|
|
621
|
+
// `provided_networks`, and that cannot survive interface classification:
|
|
622
|
+
// `provided_networks` IS what writes `network.<zone>.subnet`, so a leg
|
|
623
|
+
// left out of it has no declared subnet, classifies `alien`, and the
|
|
624
|
+
// converge refuses on a firewall celilo has never understood.
|
|
621
625
|
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
|
|
626
|
+
// That is the real rule, not a harness quirk. A firewall holding an
|
|
627
|
+
// address on a segment celilo has no subnet for is one celilo cannot
|
|
628
|
+
// describe. So the wired legs and the declared zones are ONE list (D8),
|
|
629
|
+
// and a test wanting fewer legs changes the topology.
|
|
630
|
+
//
|
|
631
|
+
// The old `opts.zones` never said which NICs the box has — the compose
|
|
632
|
+
// topology decides that, and it wires fw-main to every segmented zone
|
|
633
|
+
// regardless of what a test passed. Roughly twenty call sites passed
|
|
634
|
+
// `['dmz']` against a four-leg firewall, so every one of them
|
|
635
|
+
// under-declared the hardware. Deriving from the topology makes that
|
|
636
|
+
// structurally impossible rather than something each test author has to
|
|
637
|
+
// remember, and removes a hand-maintained copy of a list the compose
|
|
638
|
+
// generator already owns.
|
|
639
|
+
//
|
|
640
|
+
// `external` is excluded from provided_networks: it is the residual, has
|
|
641
|
+
// no subnet, and is not a zone modules are placed in.
|
|
627
642
|
const declaredZones = firewallZoneLegs(topology);
|
|
643
|
+
const providedZones = declaredZones.filter((zone) => zone !== 'external');
|
|
628
644
|
const firewallIp = ZONE_GATEWAYS.internal; // fw-main on internal
|
|
629
645
|
const natIp = opts.natIp ?? internalNatIp();
|
|
630
646
|
const providedNetworks = providedZones.map((zone) => ({
|
|
@@ -667,7 +683,105 @@ function buildNetworkHandle(
|
|
|
667
683
|
await handle.celilo(
|
|
668
684
|
`module config set iptables provided_networks '${JSON.stringify(providedNetworks)}'`,
|
|
669
685
|
);
|
|
670
|
-
|
|
686
|
+
// `check: false` returns the failed deploy instead of throwing, so a test
|
|
687
|
+
// can assert on WHAT the refusal said. Interface classification refuses
|
|
688
|
+
// by design (D12 onboarding), and a refusal is only useful if it names
|
|
689
|
+
// the interface — which is an assertion about output, not about an
|
|
690
|
+
// exception having been raised.
|
|
691
|
+
return opts.check === false
|
|
692
|
+
? await handle.celilo('module deploy iptables', { check: false, timeoutMs: 180_000 })
|
|
693
|
+
: await handle.celilo('module deploy iptables', 180_000);
|
|
694
|
+
},
|
|
695
|
+
|
|
696
|
+
async attachAlienSegment(opts: {
|
|
697
|
+
subnet: string;
|
|
698
|
+
containers: string[];
|
|
699
|
+
}): Promise<Record<string, string>> {
|
|
700
|
+
// A REAL cable into a REAL port. Interface classification is about what
|
|
701
|
+
// celilo finds on the box, so a test that fakes the finding — a dummy
|
|
702
|
+
// link, a stubbed interface table — is testing its own fixture. This
|
|
703
|
+
// creates an actual docker network celilo has no subnet for and plugs
|
|
704
|
+
// real containers into it, which is what an operator hanging an
|
|
705
|
+
// unmanaged switch off a spare port produces: a new interface, a global
|
|
706
|
+
// address, and something live on the other side to prove reachability
|
|
707
|
+
// with.
|
|
708
|
+
//
|
|
709
|
+
// The subnet is the caller's, and it must not be one of ZONE_SUBNETS —
|
|
710
|
+
// the whole point is that celilo cannot attribute it.
|
|
711
|
+
const netName = `${projectName}_alien-seg`;
|
|
712
|
+
alienSegmentSubnet = opts.subnet;
|
|
713
|
+
run(`docker network create --driver bridge --subnet ${opts.subnet} ${netName}`, {
|
|
714
|
+
timeout: 20_000,
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
const assigned: Record<string, string> = {};
|
|
718
|
+
opts.containers.forEach((service, index) => {
|
|
719
|
+
// .2 upward: .1 is the bridge itself.
|
|
720
|
+
const ip = opts.subnet.replace(/\.0\/\d+$/, `.${index + 2}`);
|
|
721
|
+
const id = run(`docker compose -p ${projectName} -f ${COMPOSE_FILE} ps -q ${service}`, {
|
|
722
|
+
cwd: composeDir,
|
|
723
|
+
timeout: 20_000,
|
|
724
|
+
});
|
|
725
|
+
if (!id) throw new Error(`attachAlienSegment: no container for service '${service}'`);
|
|
726
|
+
|
|
727
|
+
// `docker network connect` EXITS NONZERO on a container that already has
|
|
728
|
+
// a default route — "failed to set gateway while updating gateway: file
|
|
729
|
+
// exists" — while attaching the interface perfectly well: the address
|
|
730
|
+
// lands, the veth is up, and traffic flows both ways. Every container in
|
|
731
|
+
// this topology routes through the firewall, so every attach hits it.
|
|
732
|
+
// `docker network inspect` is no help either; it lists no containers for
|
|
733
|
+
// this network whether the attach worked or not.
|
|
734
|
+
//
|
|
735
|
+
// So neither the exit code nor docker's own view can be believed, and
|
|
736
|
+
// the address on the box is the only trustworthy signal. Verified below
|
|
737
|
+
// rather than assumed — a half-completed attach really does leave an
|
|
738
|
+
// address behind with no working wire, which is precisely the state that
|
|
739
|
+
// would make a classification test pass for the wrong reason.
|
|
740
|
+
try {
|
|
741
|
+
run(`docker network connect --ip ${ip} ${netName} ${id}`, { timeout: 20_000 });
|
|
742
|
+
} catch {}
|
|
743
|
+
|
|
744
|
+
const check = dockerExec(projectName, composeDir, service, 'ip -o addr show scope global');
|
|
745
|
+
if (!check.stdout.includes(ip)) {
|
|
746
|
+
throw new Error(
|
|
747
|
+
`attachAlienSegment: '${service}' has no ${ip} after connecting to ${netName}:\n${check.stdout}`,
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
assigned[service] = ip;
|
|
751
|
+
attachedToAlienSegment.add(service);
|
|
752
|
+
});
|
|
753
|
+
return assigned;
|
|
754
|
+
},
|
|
755
|
+
|
|
756
|
+
async detachAlienSegment(): Promise<void> {
|
|
757
|
+
const netName = `${projectName}_alien-seg`;
|
|
758
|
+
for (const service of attachedToAlienSegment) {
|
|
759
|
+
const id = run(`docker compose -p ${projectName} -f ${COMPOSE_FILE} ps -q ${service}`, {
|
|
760
|
+
cwd: composeDir,
|
|
761
|
+
timeout: 20_000,
|
|
762
|
+
});
|
|
763
|
+
if (id) {
|
|
764
|
+
try {
|
|
765
|
+
run(`docker network disconnect -f ${netName} ${id}`, { timeout: 20_000 });
|
|
766
|
+
} catch {}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
try {
|
|
770
|
+
run(`docker network rm ${netName}`, { timeout: 20_000 });
|
|
771
|
+
} catch {}
|
|
772
|
+
|
|
773
|
+
// VERIFY, for the same reason attach does. A lingering address on a dead
|
|
774
|
+
// wire is still an interface celilo cannot attribute, so a test that goes
|
|
775
|
+
// on to expect a clean converge would fail somewhere far from the cause.
|
|
776
|
+
for (const service of attachedToAlienSegment) {
|
|
777
|
+
const check = dockerExec(projectName, composeDir, service, 'ip -o addr show scope global');
|
|
778
|
+
const prefix = alienSegmentSubnet.replace(/\.\d+\/\d+$/, '').replace(/\./g, '\\.');
|
|
779
|
+
const orphan = new RegExp(`(\\S+)\\s+inet\\s+${prefix}\\.\\d+`).exec(check.stdout);
|
|
780
|
+
if (orphan) {
|
|
781
|
+
dockerExec(projectName, composeDir, service, `ip link del ${orphan[1]}`);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
attachedToAlienSegment.clear();
|
|
671
785
|
},
|
|
672
786
|
|
|
673
787
|
async deployGreenwave(): Promise<void> {
|
package/src/types.ts
CHANGED
|
@@ -363,18 +363,16 @@ export interface NetworkHandle {
|
|
|
363
363
|
* Targets fw-main (the customer firewall) using the standard topology
|
|
364
364
|
* subnets from ZONE_SUBNETS / ZONE_GATEWAYS.
|
|
365
365
|
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
366
|
+
* Both the declared zone list AND `provided_networks` are derived from the
|
|
367
|
+
* topology this network was built with. There is no `zones` option: a firewall
|
|
368
|
+
* holding an address on a segment celilo has no subnet for classifies `alien`
|
|
369
|
+
* and the converge refuses, so the wired legs and the declared zones must be
|
|
370
|
+
* the same list. A test that wants a firewall with fewer legs changes the
|
|
371
|
+
* topology, which is the honest way to say it (design D8).
|
|
370
372
|
*
|
|
371
|
-
* @param opts.zones - zones to PROVIDE (default: dmz, app, secure)
|
|
372
373
|
* @param opts.natIp - NAT IP for port-forwarded traffic (default 10.226.1.253)
|
|
373
374
|
*/
|
|
374
|
-
deployFirewall(opts?: {
|
|
375
|
-
zones?: Array<'dmz' | 'app' | 'secure'>;
|
|
376
|
-
natIp?: string;
|
|
377
|
-
}): Promise<void>;
|
|
375
|
+
deployFirewall(opts?: { natIp?: string; check?: boolean }): Promise<ExecResult>;
|
|
378
376
|
|
|
379
377
|
/**
|
|
380
378
|
* Deploy the `greenwave` module against the ISP router (`fw-isp`) — the box
|
|
@@ -390,6 +388,27 @@ export interface NetworkHandle {
|
|
|
390
388
|
*
|
|
391
389
|
* Not needed under `direct-internet`, where fw-main owns the edge itself.
|
|
392
390
|
*/
|
|
391
|
+
/**
|
|
392
|
+
* Plug the named containers into a NEW network celilo has no subnet for, and
|
|
393
|
+
* return the address each was given.
|
|
394
|
+
*
|
|
395
|
+
* For interface-classification tests: the firewall gains a real interface
|
|
396
|
+
* with a real global address that matches no declared zone, and a peer on the
|
|
397
|
+
* same segment gives the isolation assertion a real signal to measure — can
|
|
398
|
+
* that segment still reach a service on the firewall — rather than a rule
|
|
399
|
+
* string in `iptables-save`.
|
|
400
|
+
*
|
|
401
|
+
* `subnet` must NOT be one of `ZONE_SUBNETS`; an attributable segment defeats
|
|
402
|
+
* the purpose.
|
|
403
|
+
*/
|
|
404
|
+
attachAlienSegment(opts: {
|
|
405
|
+
subnet: string;
|
|
406
|
+
containers: string[];
|
|
407
|
+
}): Promise<Record<string, string>>;
|
|
408
|
+
|
|
409
|
+
/** Unplug and remove the segment `attachAlienSegment` created. */
|
|
410
|
+
detachAlienSegment(): Promise<void>;
|
|
411
|
+
|
|
393
412
|
deployGreenwave(): Promise<void>;
|
|
394
413
|
|
|
395
414
|
/** Tear down the entire network */
|