@celilo/e2e 0.11.3 → 0.13.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 (51) hide show
  1. package/README.md +5 -5
  2. package/bin/e2e-status +1 -1
  3. package/bin/e2e-up +2 -2
  4. package/config/dns/tangohost.com.zone +1 -1
  5. package/config/dns/templates/example.net.zone +3 -3
  6. package/config/dns/templates/iamtheinternet.org.zone +2 -2
  7. package/config/resolver/unbound.conf +4 -0
  8. package/config/routing/fw-ext-routes.sh +8 -8
  9. package/config/routing/fw-isp-routes.sh +2 -2
  10. package/config/routing/fw-main-routes.sh +5 -5
  11. package/config/routing/management-routes.sh +1 -1
  12. package/config/routing/observer-setup.sh +1 -1
  13. package/config/routing/public-resolver-routes.sh +33 -0
  14. package/config/routing/public-sim-entrypoint.sh +1 -1
  15. package/config/routing/resolver-internal-routes.sh +7 -2
  16. package/config/routing/resolver-routes.sh +1 -1
  17. package/config/routing/target-routes.sh +1 -1
  18. package/config/routing/target-setup.sh +1 -1
  19. package/config/socks/startup.sh +2 -2
  20. package/docker/Dockerfile.ip-echo +20 -0
  21. package/docker/Dockerfile.resolver +5 -1
  22. package/package.json +5 -4
  23. package/simulators/greenwave/server.ts +35 -0
  24. package/simulators/greenwave/state.ts +79 -7
  25. package/simulators/ip-echo/server.ts +76 -0
  26. package/src/address-plan.test.ts +4 -4
  27. package/src/cli/build.ts +30 -0
  28. package/src/cli/command-registry.ts +19 -0
  29. package/src/cli/completion.ts +16 -1
  30. package/src/cli/index.ts +85 -4
  31. package/src/container-manager.ts +75 -12
  32. package/src/docker-compose-generator.ts +97 -29
  33. package/src/doctor.test.ts +279 -0
  34. package/src/doctor.ts +421 -0
  35. package/src/extract-failure.ts +41 -0
  36. package/src/index.ts +12 -0
  37. package/src/last-run.test.ts +62 -0
  38. package/src/last-run.ts +54 -0
  39. package/src/network-builder.ts +11 -0
  40. package/src/observer.test.ts +2 -2
  41. package/src/observer.ts +1 -1
  42. package/src/router-swap.ts +121 -0
  43. package/src/run-lock.test.ts +73 -2
  44. package/src/run-lock.ts +110 -14
  45. package/src/runner.ts +99 -5
  46. package/src/simulator-ips.ts +21 -0
  47. package/src/socks-proxy.ts +2 -2
  48. package/src/types.ts +34 -4
  49. package/src/vantage.test.ts +1 -1
  50. package/src/zone-classifier.test.ts +11 -16
  51. package/src/zone-classifier.ts +16 -31
package/src/types.ts CHANGED
@@ -55,6 +55,15 @@ export interface NetworkConfig {
55
55
  domain: string;
56
56
  ddnsPassword: string;
57
57
  verifyRouting: boolean;
58
+ /**
59
+ * Which real ISP router the `fw-isp` simulator stands in for, expressed as
60
+ * its TR-181 vendor extension prefix. `X_GWS_` (the default) is the GreenWave
61
+ * C4000XG that `modules/greenwave` drives; `X_AXON_` is the Axon Networks
62
+ * Q1000K that `modules/axon` drives. The CGI protocol is identical across the
63
+ * two — this prefix IS the entire difference, which is why one simulator
64
+ * serves both rather than a near-duplicate second one.
65
+ */
66
+ routerVendorPrefix?: string;
58
67
  /** Extra volume mounts for the management container (host:container format) */
59
68
  managementVolumes: string[];
60
69
  /** Include a DHCP client container on the internal network */
@@ -354,7 +363,12 @@ export interface NetworkHandle {
354
363
  * Targets fw-main (the customer firewall) using the standard topology
355
364
  * subnets from ZONE_SUBNETS / ZONE_GATEWAYS.
356
365
  *
357
- * @param opts.zones - zones to provide (default: dmz, app, secure)
366
+ * `opts.zones` says which zones get `provided_networks` written to system
367
+ * config. It does NOT declare which NICs the firewall has — the topology
368
+ * decides that, and `deployFirewall` derives the declared list from it, so a
369
+ * test cannot under-declare the box's hardware by omission.
370
+ *
371
+ * @param opts.zones - zones to PROVIDE (default: dmz, app, secure)
358
372
  * @param opts.natIp - NAT IP for port-forwarded traffic (default 10.226.1.253)
359
373
  */
360
374
  deployFirewall(opts?: {
@@ -362,6 +376,22 @@ export interface NetworkHandle {
362
376
  natIp?: string;
363
377
  }): Promise<void>;
364
378
 
379
+ /**
380
+ * Deploy the `greenwave` module against the ISP router (`fw-isp`) — the box
381
+ * that actually owns the WAN in the `default` topology.
382
+ *
383
+ * This is what makes the production shape work end to end. celilo's own
384
+ * firewall is DOWNSTREAM: it holds only RFC1918 legs, reports no external
385
+ * edge, and `exposeService` therefore has to delegate up the chain to a
386
+ * provider whose firewall capability declares `has_external: true`. greenwave
387
+ * is that provider. Without it a `default`-topology test that exposes
388
+ * anything publicly fails at the delegation step, which is correct — there is
389
+ * genuinely nothing that knows the fleet's public address.
390
+ *
391
+ * Not needed under `direct-internet`, where fw-main owns the edge itself.
392
+ */
393
+ deployGreenwave(): Promise<void>;
394
+
365
395
  /** Tear down the entire network */
366
396
  stop(): Promise<void>;
367
397
 
@@ -502,7 +532,7 @@ export function greenwaveRouterIp(): string {
502
532
  * by construction. Reference this instead of hardcoding it.
503
533
  */
504
534
  export function externalWanIp(): string {
505
- return '100.100.0.100';
535
+ return '203.0.113.100';
506
536
  }
507
537
 
508
538
  /**
@@ -511,7 +541,7 @@ export function externalWanIp(): string {
511
541
  * WAN address when it reaches a public service.
512
542
  */
513
543
  export function externalWanSubnet(): string {
514
- return '100.100.0.0/24';
544
+ return '203.0.113.0/24';
515
545
  }
516
546
 
517
547
  /** The internal split-horizon DNS resolver (dns-int) in the e2e topology. */
@@ -521,5 +551,5 @@ export function internalResolverIp(): string {
521
551
 
522
552
  /** The public upstream recursive resolver (the comcast-resolver sim). */
523
553
  export function publicResolverIp(): string {
524
- return '100.100.0.1';
554
+ return '203.0.113.1';
525
555
  }
@@ -86,7 +86,7 @@ describe('assertResolvesInZone — the killer verb', () => {
86
86
 
87
87
  test('public vantage expecting a public answer passes; a leaked zone IP fails', async () => {
88
88
  const ok = new VantageProbe(
89
- fakeTransport({ resolve: async () => ({ ips: ['100.100.0.100'] }) }),
89
+ fakeTransport({ resolve: async () => ({ ips: ['203.0.113.100'] }) }),
90
90
  );
91
91
  await ok.assertResolvesInZone({
92
92
  from: 'publicInternet',
@@ -1,11 +1,6 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import {
3
- classifyIp,
4
- inCidr,
5
- isPublicLeakSafe,
6
- isSegmentedZoneIp,
7
- parseIpv4,
8
- } from './zone-classifier';
2
+ import { parseIpv4, subnetContains } from '@celilo/capabilities';
3
+ import { classifyIp, isPublicLeakSafe, isSegmentedZoneIp } from './zone-classifier';
9
4
 
10
5
  describe('parseIpv4', () => {
11
6
  test('parses dotted-quad to unsigned 32-bit', () => {
@@ -25,17 +20,17 @@ describe('parseIpv4', () => {
25
20
  });
26
21
  });
27
22
 
28
- describe('inCidr', () => {
23
+ describe('subnetContains (shared, @celilo/capabilities)', () => {
29
24
  test('membership within /24', () => {
30
- expect(inCidr('10.226.10.5', '10.226.10.0/24')).toBe(true);
31
- expect(inCidr('10.226.10.255', '10.226.10.0/24')).toBe(true);
32
- expect(inCidr('10.226.11.1', '10.226.10.0/24')).toBe(false);
25
+ expect(subnetContains('10.226.10.0/24', '10.226.10.5')).toBe(true);
26
+ expect(subnetContains('10.226.10.0/24', '10.226.10.255')).toBe(true);
27
+ expect(subnetContains('10.226.10.0/24', '10.226.11.1')).toBe(false);
33
28
  });
34
29
  test('high-octet subnet — no sign-bit bug', () => {
35
30
  // Arithmetic fixture, not a sim address: a first octet >= 128 is what trips
36
31
  // the sign bit, and no address in the sim plan has one any more.
37
- expect(inCidr('200.100.50.253', '200.100.50.0/24')).toBe(true);
38
- expect(inCidr('200.100.51.1', '200.100.50.0/24')).toBe(false);
32
+ expect(subnetContains('200.100.50.0/24', '200.100.50.253')).toBe(true);
33
+ expect(subnetContains('200.100.50.0/24', '200.100.51.1')).toBe(false);
39
34
  });
40
35
  });
41
36
 
@@ -49,7 +44,7 @@ describe('classifyIp', () => {
49
44
  expect(classifyIp('10.226.1.253')).toEqual({ kind: 'zone', zone: 'internal' });
50
45
  });
51
46
  test('public: the CGNAT stand-in and real public space', () => {
52
- expect(classifyIp('100.100.0.100')).toEqual({ kind: 'public' }); // sim firewall external
47
+ expect(classifyIp('203.0.113.100')).toEqual({ kind: 'public' }); // sim firewall external
53
48
  expect(classifyIp('8.8.8.8')).toEqual({ kind: 'public' });
54
49
  expect(classifyIp('71.36.115.155')).toEqual({ kind: 'public' }); // the prod DDNS target
55
50
  });
@@ -70,13 +65,13 @@ describe('isSegmentedZoneIp', () => {
70
65
  expect(isSegmentedZoneIp('10.226.10.10')).toBe(true); // caddy DMZ — the ISS-0101/0111 bug value
71
66
  expect(isSegmentedZoneIp('10.226.20.30')).toBe(true);
72
67
  expect(isSegmentedZoneIp('10.226.1.253')).toBe(false); // internal IS the LAN
73
- expect(isSegmentedZoneIp('100.100.0.100')).toBe(false); // public
68
+ expect(isSegmentedZoneIp('203.0.113.100')).toBe(false); // public
74
69
  });
75
70
  });
76
71
 
77
72
  describe('isPublicLeakSafe (CLAUDE.md inviolable rule #1)', () => {
78
73
  test('only public addresses are leak-safe', () => {
79
- expect(isPublicLeakSafe('100.100.0.100')).toBe(true);
74
+ expect(isPublicLeakSafe('203.0.113.100')).toBe(true);
80
75
  expect(isPublicLeakSafe('8.8.8.8')).toBe(true);
81
76
  expect(isPublicLeakSafe('10.226.10.10')).toBe(false); // managed segmented zone
82
77
  expect(isPublicLeakSafe('10.226.1.253')).toBe(false); // internal/RFC-1918 must not leak
@@ -8,14 +8,15 @@
8
8
  * e.g. "www.<domain> resolved from internalDevice must NOT be a segmented-zone
9
9
  * container IP" — the ISS-0101 / ISS-0111 bug class.
10
10
  *
11
- * Classification uses `ZONE_SUBNETS` (exact) + a reserved-range check — deliberately
12
- * NOT a blanket RFC-1918/CGNAT ban: the sim's "public" IP is a CGNAT stand-in
13
- * (100.100.0.100), so in this topology the RFC-1918 zones under
14
- * `SIM_PRIVATE_SUPERNET` are *zones* and 100.x is *public*. (A real deployment
15
- * would additionally treat RFC-1918 + RFC-6598 as non-public; that's the
16
- * caller's policy, layered on top of this primitive.)
11
+ * Classification uses `ZONE_SUBNETS` (exact) + a reserved-range check. RFC-1918 is
12
+ * deliberately absent from the reserved list: in this topology those ranges are the
13
+ * managed zones. RFC-6598 (CGNAT) used to be absent too, because the sim's "public"
14
+ * edge was a CGNAT stand-in — that exemption is gone with the renumber to TEST-NET-3
15
+ * (RFC 5737), and a carrier address is now correctly non-public here as it is in
16
+ * `@celilo/capabilities`.
17
17
  */
18
18
 
19
+ import { parseIpv4, subnetContains } from '@celilo/capabilities';
19
20
  import { ZONE_SUBNETS, type Zone } from './types';
20
21
 
21
22
  export type ZoneClass =
@@ -33,46 +34,30 @@ export const SEGMENTED_ZONES: readonly Zone[] = ['dmz', 'app', 'secure'];
33
34
 
34
35
  /**
35
36
  * Special-use IPv4 ranges that must never be a real service address (nor appear in
36
- * any DNS answer). RFC-1918 and RFC-6598 (CGNAT) are intentionally absent — in this
37
- * topology the former are the managed zones and the latter is the public stand-in.
37
+ * any DNS answer). RFC-1918 is intentionally absent — in this topology those ranges
38
+ * are the managed zones.
39
+ *
40
+ * The `internet-external` network still numbers its public simulators out of
41
+ * `100.64.0.0/24`; nothing classifies those addresses, and renumbering that network
42
+ * is not part of this change.
38
43
  */
39
44
  const RESERVED: ReadonlyArray<{ cidr: string; label: string }> = [
40
45
  { cidr: '0.0.0.0/8', label: 'unspecified/this-host (incl. the 0.0.0.0 sentinel)' },
46
+ { cidr: '100.64.0.0/10', label: 'carrier-grade NAT (RFC 6598)' },
41
47
  { cidr: '127.0.0.0/8', label: 'loopback' },
42
48
  { cidr: '169.254.0.0/16', label: 'link-local (RFC 3927)' },
43
49
  { cidr: '224.0.0.0/4', label: 'multicast' },
44
50
  { cidr: '240.0.0.0/4', label: 'reserved (RFC 1112)' },
45
51
  ];
46
52
 
47
- /** Parse a dotted-quad IPv4 to an unsigned 32-bit int, or null if malformed. */
48
- export function parseIpv4(ip: string): number | null {
49
- const m = ip.trim().match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
50
- if (!m) return null;
51
- const octets = m.slice(1, 5).map(Number);
52
- if (octets.some((o) => o > 255)) return null;
53
- return ((octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]) >>> 0;
54
- }
55
-
56
- /** True if `ip` falls within `cidr` (e.g. "10.226.10.0/24"). */
57
- export function inCidr(ip: string, cidr: string): boolean {
58
- const [base, bitsStr] = cidr.split('/');
59
- const bits = Number(bitsStr);
60
- const ipN = parseIpv4(ip);
61
- const baseN = parseIpv4(base);
62
- if (ipN === null || baseN === null || Number.isNaN(bits) || bits < 0 || bits > 32) return false;
63
- if (bits === 0) return true;
64
- const mask = (0xffffffff << (32 - bits)) >>> 0;
65
- return (ipN & mask) >>> 0 === (baseN & mask) >>> 0;
66
- }
67
-
68
53
  /** Classify a resolved IPv4 against the e2e topology. */
69
54
  export function classifyIp(ip: string): ZoneClass {
70
55
  if (parseIpv4(ip) === null) return { kind: 'invalid' };
71
56
  for (const r of RESERVED) {
72
- if (inCidr(ip, r.cidr)) return { kind: 'reserved', label: r.label };
57
+ if (subnetContains(r.cidr, ip)) return { kind: 'reserved', label: r.label };
73
58
  }
74
59
  for (const zone of Object.keys(ZONE_SUBNETS) as Zone[]) {
75
- if (inCidr(ip, ZONE_SUBNETS[zone])) return { kind: 'zone', zone };
60
+ if (subnetContains(ZONE_SUBNETS[zone], ip)) return { kind: 'zone', zone };
76
61
  }
77
62
  return { kind: 'public' };
78
63
  }