@celilo/e2e 0.10.4 → 0.11.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.
package/src/types.ts CHANGED
@@ -123,7 +123,7 @@ export interface NetworkConfig {
123
123
 
124
124
  /**
125
125
  * When set, brings up a SECOND management-class box named `celilo-mgr-2`
126
- * (at 192.168.0.101 on `internal`) using the given image variant. Used by
126
+ * (at 10.226.1.101 on `internal`) using the given image variant. Used by
127
127
  * the migration restore e2e: the primary box is the backup source, the
128
128
  * second box is a fresh target that `apt install celilo` + `celilo restore`s
129
129
  * into. The second box is NOT auto-init'd by container-manager (which keys
@@ -355,7 +355,7 @@ export interface NetworkHandle {
355
355
  * subnets from ZONE_SUBNETS / ZONE_GATEWAYS.
356
356
  *
357
357
  * @param opts.zones - zones to provide (default: dmz, app, secure)
358
- * @param opts.natIp - NAT IP for port-forwarded traffic (default 192.168.0.253)
358
+ * @param opts.natIp - NAT IP for port-forwarded traffic (default 10.226.1.253)
359
359
  */
360
360
  deployFirewall(opts?: {
361
361
  zones?: Array<'dmz' | 'app' | 'secure'>;
@@ -407,22 +407,72 @@ export interface ResponderValues {
407
407
  aspects?: Record<string, boolean>;
408
408
  }
409
409
 
410
- export const ZONE_GATEWAYS: Record<Zone, string> = {
411
- dmz: '10.0.10.1',
412
- app: '10.0.20.1',
413
- secure: '10.0.30.1',
414
- internal: '192.168.0.254',
415
- 'secure-mgmt': '10.0.120.1',
410
+ /**
411
+ * The parent block every simulated PRIVATE zone lives inside (#539).
412
+ *
413
+ * The sim used to number its zones exactly like a real celilo fleet: its dmz,
414
+ * app and secure /24s WERE the fleet's, and its internal LAN was the operator's.
415
+ * That is not a cosmetic overlap — celilo's own forgejo-builder lives in the
416
+ * REAL dmz, so a leaked stack put a second interface on the builder's own /24
417
+ * and blackholed every containerized CI job's route to the forge for ~135s.
418
+ * Twice in one afternoon. Teardown can't be made reliable enough to fix that (a
419
+ * SIGKILL runs no handler), so the fix is that a leak stops MATTERING.
420
+ *
421
+ * `10.226.0.0/16` — mnemonic `0xE2` = "e2", the e2e supernet. It must be
422
+ * RFC-1918 (the private zones model private networks — CLAUDE.md inviolable
423
+ * rules #1/#3), and this block collides with nothing plausible:
424
+ * - the fleet numbers its zones out of the bottom of 10/8 → far away
425
+ * - the operator's VPN sits at the very top of 10/8 → far away
426
+ * - the operator's LAN is in 192.168/16 → different block
427
+ * - Docker's default address pools are 172.16/12 and
428
+ * 192.168/16 → different block
429
+ *
430
+ * The second reason matters more than the collision: there must be nothing
431
+ * magic about the octets production happens to use. A suite that only passes on
432
+ * those exact numbers is asserting one site's address plan rather than celilo's
433
+ * behaviour. Renumbering proves address-independence.
434
+ *
435
+ * Do not move a zone out of this block. `address-plan.test.ts` fails if you do,
436
+ * and fails if the retired fleet-colliding prefixes reappear anywhere in the
437
+ * suite.
438
+ */
439
+ export const SIM_PRIVATE_SUPERNET = '10.226.0.0/16';
440
+
441
+ /**
442
+ * Third-octet plan inside {@link SIM_PRIVATE_SUPERNET}. Host octets are
443
+ * unchanged from the pre-#539 numbering, so `caddy` is still `…10.10` and
444
+ * fw-main is still `…1.254` — only the network part moved.
445
+ */
446
+ const ZONE_PREFIXES: Record<Zone, string> = {
447
+ internal: '10.226.1',
448
+ dmz: '10.226.10',
449
+ app: '10.226.20',
450
+ secure: '10.226.30',
451
+ 'secure-mgmt': '10.226.120',
416
452
  };
417
453
 
418
- export const ZONE_SUBNETS: Record<Zone, string> = {
419
- dmz: '10.0.10.0/24',
420
- app: '10.0.20.0/24',
421
- secure: '10.0.30.0/24',
422
- internal: '192.168.0.0/24',
423
- 'secure-mgmt': '10.0.120.0/24',
454
+ /**
455
+ * An address in a zone, by host octet. Reference this instead of writing an
456
+ * octet: it is what makes the whole plan a one-line change, and what makes a
457
+ * stray hardcoded address stand out as the anomaly it is.
458
+ */
459
+ export function zoneIp(zone: Zone, host: number): string {
460
+ return `${ZONE_PREFIXES[zone]}.${host}`;
461
+ }
462
+
463
+ /** The router/firewall leg in each zone. `internal`'s is fw-main's LAN side. */
464
+ export const ZONE_GATEWAYS: Record<Zone, string> = {
465
+ dmz: zoneIp('dmz', 1),
466
+ app: zoneIp('app', 1),
467
+ secure: zoneIp('secure', 1),
468
+ internal: zoneIp('internal', 254),
469
+ 'secure-mgmt': zoneIp('secure-mgmt', 1),
424
470
  };
425
471
 
472
+ export const ZONE_SUBNETS: Record<Zone, string> = Object.fromEntries(
473
+ (Object.keys(ZONE_PREFIXES) as Zone[]).map((zone) => [zone, `${ZONE_PREFIXES[zone]}.0/24`]),
474
+ ) as Record<Zone, string>;
475
+
426
476
  /**
427
477
  * The firewall's internal-side DNAT ingress (the natIp) in the e2e topology — the role by
428
478
  * which an internal device reaches a service that lives in a segmented zone. Single source
@@ -431,7 +481,7 @@ export const ZONE_SUBNETS: Record<Zone, string> = {
431
481
  * hardcoding the address in a test.
432
482
  */
433
483
  export function internalNatIp(): string {
434
- return '192.168.0.253';
484
+ return zoneIp('internal', 253);
435
485
  }
436
486
 
437
487
  /**
@@ -441,7 +491,7 @@ export function internalNatIp(): string {
441
491
  * this instead of hardcoding the address in a test.
442
492
  */
443
493
  export function greenwaveRouterIp(): string {
444
- return '192.168.0.1';
494
+ return zoneIp('internal', 1);
445
495
  }
446
496
 
447
497
  /**
@@ -466,7 +516,7 @@ export function externalWanSubnet(): string {
466
516
 
467
517
  /** The internal split-horizon DNS resolver (dns-int) in the e2e topology. */
468
518
  export function internalResolverIp(): string {
469
- return '192.168.0.10';
519
+ return zoneIp('internal', 10);
470
520
  }
471
521
 
472
522
  /** The public upstream recursive resolver (the comcast-resolver sim). */
@@ -8,7 +8,7 @@
8
8
  * here — they wait on real firewall isolation (ISS-0118).
9
9
  *
10
10
  * Topology prerequisite: inject the observers the assertion needs, e.g.
11
- * network().dmz({ caddy: '10.0.10.10' }).observe('internalDevice', 'publicInternet')
11
+ * network().dmz({ caddy: '10.226.10.10' }).observe('internalDevice', 'publicInternet')
12
12
  */
13
13
 
14
14
  import { createObserverTransport } from './observer';
@@ -36,10 +36,10 @@ const LAN: Vantage = 'internalDevice';
36
36
 
37
37
  describe('resolveFrom', () => {
38
38
  test('defaults method to system, passes through ips', async () => {
39
- const probe = new VantageProbe(fakeTransport({ resolve: async () => ({ ips: ['192.168.0.253'] }) }));
39
+ const probe = new VantageProbe(fakeTransport({ resolve: async () => ({ ips: ['10.226.1.253'] }) }));
40
40
  const r = await probe.resolveFrom({ from: LAN, name: 'www.example.com' });
41
41
  expect(r.method).toBe('system');
42
- expect(r.ips).toEqual(['192.168.0.253']);
42
+ expect(r.ips).toEqual(['10.226.1.253']);
43
43
  });
44
44
  });
45
45
 
@@ -51,7 +51,7 @@ describe('assertResolves / assertNxdomain', () => {
51
51
  );
52
52
  });
53
53
  test('assertNxdomain throws when a public vantage CAN see an internal-only name', async () => {
54
- const probe = new VantageProbe(fakeTransport({ resolve: async () => ({ ips: ['10.0.20.5'] }) }));
54
+ const probe = new VantageProbe(fakeTransport({ resolve: async () => ({ ips: ['10.226.20.5'] }) }));
55
55
  await expect(
56
56
  probe.assertNxdomain({ from: 'publicInternet', name: 'authentik.internal' }),
57
57
  ).rejects.toThrow(/expected NXDOMAIN/);
@@ -60,23 +60,23 @@ describe('assertResolves / assertNxdomain', () => {
60
60
 
61
61
  describe('assertResolvesInZone — the killer verb', () => {
62
62
  test('ISS-0101: www at caddy DMZ container IP FAILS from internalDevice', async () => {
63
- const probe = new VantageProbe(fakeTransport({ resolve: async () => ({ ips: ['10.0.10.10'] }) }));
63
+ const probe = new VantageProbe(fakeTransport({ resolve: async () => ({ ips: ['10.226.10.10'] }) }));
64
64
  await expect(
65
65
  probe.assertResolvesInZone({ from: LAN, name: 'www.example.com', zone: 'internal' }),
66
66
  ).rejects.toThrow(/ISS-0101/);
67
67
  });
68
68
 
69
69
  test('www at the firewall natIp PASSES from internalDevice (the right answer)', async () => {
70
- const probe = new VantageProbe(fakeTransport({ resolve: async () => ({ ips: ['192.168.0.253'] }) }));
70
+ const probe = new VantageProbe(fakeTransport({ resolve: async () => ({ ips: ['10.226.1.253'] }) }));
71
71
  const r = await probe.assertResolvesInZone({ from: LAN, name: 'www.example.com', zone: 'internal' });
72
- expect(r.ips).toEqual(['192.168.0.253']);
72
+ expect(r.ips).toEqual(['10.226.1.253']);
73
73
  });
74
74
 
75
75
  test('public vantage expecting a public answer passes; a leaked zone IP fails', async () => {
76
76
  const ok = new VantageProbe(fakeTransport({ resolve: async () => ({ ips: ['100.100.0.100'] }) }));
77
77
  await ok.assertResolvesInZone({ from: 'publicInternet', name: 'www.example.com', zone: 'public' });
78
78
 
79
- const leaked = new VantageProbe(fakeTransport({ resolve: async () => ({ ips: ['192.168.0.253'] }) }));
79
+ const leaked = new VantageProbe(fakeTransport({ resolve: async () => ({ ips: ['10.226.1.253'] }) }));
80
80
  await expect(
81
81
  leaked.assertResolvesInZone({ from: 'publicInternet', name: 'www.example.com', zone: 'public' }),
82
82
  ).rejects.toThrow(VantageAssertionError);
@@ -84,38 +84,38 @@ describe('assertResolvesInZone — the killer verb', () => {
84
84
 
85
85
  test('any one leaked IP among several fails the whole answer', async () => {
86
86
  const probe = new VantageProbe(
87
- fakeTransport({ resolve: async () => ({ ips: ['192.168.0.253', '10.0.30.9'] }) }),
87
+ fakeTransport({ resolve: async () => ({ ips: ['10.226.1.253', '10.226.30.9'] }) }),
88
88
  );
89
89
  await expect(
90
90
  probe.assertResolvesInZone({ from: LAN, name: 'www.example.com', zone: 'internal' }),
91
- ).rejects.toThrow(/10\.0\.30\.9/);
91
+ ).rejects.toThrow(/10\.226\.30\.9/);
92
92
  });
93
93
  });
94
94
 
95
95
  describe('assertResolvesTo — exact-IP DNS correctness', () => {
96
96
  test('passes when the resolver returns the expected IP (via a specific server)', async () => {
97
- const probe = new VantageProbe(fakeTransport({ resolve: async () => ({ ips: ['192.168.0.53'] }) }));
97
+ const probe = new VantageProbe(fakeTransport({ resolve: async () => ({ ips: ['10.226.1.53'] }) }));
98
98
  const r = await probe.assertResolvesTo({
99
99
  from: 'internalDevice',
100
100
  name: 'host.infra.example.com',
101
101
  method: 'dns',
102
- server: '192.168.0.10',
103
- expected: '192.168.0.53',
102
+ server: '10.226.1.10',
103
+ expected: '10.226.1.53',
104
104
  });
105
- expect(r.ips).toEqual(['192.168.0.53']);
105
+ expect(r.ips).toEqual(['10.226.1.53']);
106
106
  });
107
107
 
108
108
  test('fails (naming the server) when the expected IP is absent', async () => {
109
- const probe = new VantageProbe(fakeTransport({ resolve: async () => ({ ips: ['10.0.10.99'] }) }));
109
+ const probe = new VantageProbe(fakeTransport({ resolve: async () => ({ ips: ['10.226.10.99'] }) }));
110
110
  await expect(
111
111
  probe.assertResolvesTo({
112
112
  from: 'internalDevice',
113
113
  name: 'host.infra.example.com',
114
114
  method: 'dns',
115
- server: '192.168.0.10',
116
- expected: '192.168.0.53',
115
+ server: '10.226.1.10',
116
+ expected: '10.226.1.53',
117
117
  }),
118
- ).rejects.toThrow(/via 192\.168\.0\.10.*expected to include \[192\.168\.0\.53\]/);
118
+ ).rejects.toThrow(/via 10\.226\.1\.10.*expected to include \[10\.226\.1\.53\]/);
119
119
  });
120
120
 
121
121
  test('accepts an array of expected IPs (all must be present)', async () => {
@@ -136,15 +136,15 @@ describe('reachability + isolation (§D6)', () => {
136
136
  });
137
137
  test('assertUnreachable throws when isolation is violated', async () => {
138
138
  const probe = new VantageProbe(fakeTransport({ tcpConnect: async () => ({ connected: true }) }));
139
- await expect(probe.assertUnreachable({ from: 'appSystem', host: '10.0.30.5', port: 443 })).rejects.toThrow(
139
+ await expect(probe.assertUnreachable({ from: 'appSystem', host: '10.226.30.5', port: 443 })).rejects.toThrow(
140
140
  /zone isolation/,
141
141
  );
142
142
  });
143
143
  test('the reachable/unreachable happy paths do not throw', async () => {
144
144
  const reachable = new VantageProbe(fakeTransport({ tcpConnect: async () => ({ connected: true }) }));
145
- await reachable.assertReachable({ from: LAN, host: '192.168.0.253', port: 443 });
145
+ await reachable.assertReachable({ from: LAN, host: '10.226.1.253', port: 443 });
146
146
  const blocked = new VantageProbe(fakeTransport({ tcpConnect: async () => ({ connected: false }) }));
147
- await blocked.assertUnreachable({ from: 'appSystem', host: '10.0.30.5', port: 443 });
147
+ await blocked.assertUnreachable({ from: 'appSystem', host: '10.226.30.5', port: 443 });
148
148
  });
149
149
  });
150
150
 
@@ -188,13 +188,13 @@ describe('inspectResolution', () => {
188
188
  const probe = new VantageProbe(
189
189
  fakeTransport({
190
190
  inspectResolution: async () => ({
191
- resolvConf: 'nameserver 192.168.0.253',
192
- hostsLines: ['10.0.10.10 auth.example.com'],
191
+ resolvConf: 'nameserver 10.226.1.253',
192
+ hostsLines: ['10.226.10.10 auth.example.com'],
193
193
  }),
194
194
  }),
195
195
  );
196
196
  const r = await probe.inspectResolution({ from: 'dmzSystem', name: 'auth.example.com' });
197
- expect(r.hostsLines).toContain('10.0.10.10 auth.example.com');
198
- expect(r.resolvConf).toContain('192.168.0.253');
197
+ expect(r.hostsLines).toContain('10.226.10.10 auth.example.com');
198
+ expect(r.resolvConf).toContain('10.226.1.253');
199
199
  });
200
200
  });
@@ -5,9 +5,12 @@ describe('parseIpv4', () => {
5
5
  test('parses dotted-quad to unsigned 32-bit', () => {
6
6
  expect(parseIpv4('0.0.0.0')).toBe(0);
7
7
  expect(parseIpv4('255.255.255.255')).toBe(0xffffffff);
8
- // High first octet must not go negative (sign-bit overflow guard).
9
- expect(parseIpv4('192.168.0.253')).toBe(((192 << 24) | (168 << 16) | (0 << 8) | 253) >>> 0);
10
- expect(parseIpv4('192.168.0.253')).toBeGreaterThan(0);
8
+ // High first octet must not go negative (sign-bit overflow guard). A pure
9
+ // arithmetic fixture, deliberately NOT an address from the sim plan: it used
10
+ // to be the sim's internal natIp, so the guard would have quietly
11
+ // evaporated the moment that zone was renumbered into 10/8 (#539).
12
+ expect(parseIpv4('200.100.50.253')).toBe(((200 << 24) | (100 << 16) | (50 << 8) | 253) >>> 0);
13
+ expect(parseIpv4('200.100.50.253')).toBeGreaterThan(0);
11
14
  });
12
15
  test('rejects malformed / out-of-range', () => {
13
16
  expect(parseIpv4('not.an.ip')).toBeNull();
@@ -18,24 +21,26 @@ describe('parseIpv4', () => {
18
21
 
19
22
  describe('inCidr', () => {
20
23
  test('membership within /24', () => {
21
- expect(inCidr('10.0.10.5', '10.0.10.0/24')).toBe(true);
22
- expect(inCidr('10.0.10.255', '10.0.10.0/24')).toBe(true);
23
- expect(inCidr('10.0.11.1', '10.0.10.0/24')).toBe(false);
24
+ expect(inCidr('10.226.10.5', '10.226.10.0/24')).toBe(true);
25
+ expect(inCidr('10.226.10.255', '10.226.10.0/24')).toBe(true);
26
+ expect(inCidr('10.226.11.1', '10.226.10.0/24')).toBe(false);
24
27
  });
25
- test('high-octet subnet (192.168) — no sign-bit bug', () => {
26
- expect(inCidr('192.168.0.253', '192.168.0.0/24')).toBe(true);
27
- expect(inCidr('192.168.1.1', '192.168.0.0/24')).toBe(false);
28
+ test('high-octet subnet — no sign-bit bug', () => {
29
+ // Arithmetic fixture, not a sim address: a first octet >= 128 is what trips
30
+ // the sign bit, and no address in the sim plan has one any more.
31
+ expect(inCidr('200.100.50.253', '200.100.50.0/24')).toBe(true);
32
+ expect(inCidr('200.100.51.1', '200.100.50.0/24')).toBe(false);
28
33
  });
29
34
  });
30
35
 
31
36
  describe('classifyIp', () => {
32
37
  test('managed zones (the segmented ones)', () => {
33
- expect(classifyIp('10.0.10.10')).toEqual({ kind: 'zone', zone: 'dmz' }); // caddy
34
- expect(classifyIp('10.0.20.30')).toEqual({ kind: 'zone', zone: 'app' }); // authentik
35
- expect(classifyIp('10.0.30.5')).toEqual({ kind: 'zone', zone: 'secure' });
38
+ expect(classifyIp('10.226.10.10')).toEqual({ kind: 'zone', zone: 'dmz' }); // caddy
39
+ expect(classifyIp('10.226.20.30')).toEqual({ kind: 'zone', zone: 'app' }); // authentik
40
+ expect(classifyIp('10.226.30.5')).toEqual({ kind: 'zone', zone: 'secure' });
36
41
  });
37
42
  test('internal zone (the LAN — natIp lives here)', () => {
38
- expect(classifyIp('192.168.0.253')).toEqual({ kind: 'zone', zone: 'internal' });
43
+ expect(classifyIp('10.226.1.253')).toEqual({ kind: 'zone', zone: 'internal' });
39
44
  });
40
45
  test('public: the CGNAT stand-in and real public space', () => {
41
46
  expect(classifyIp('100.100.0.100')).toEqual({ kind: 'public' }); // sim firewall external
@@ -56,9 +61,9 @@ describe('classifyIp', () => {
56
61
 
57
62
  describe('isSegmentedZoneIp', () => {
58
63
  test('dmz/app/secure are segmented; internal and public are not', () => {
59
- expect(isSegmentedZoneIp('10.0.10.10')).toBe(true); // caddy DMZ — the ISS-0101/0111 bug value
60
- expect(isSegmentedZoneIp('10.0.20.30')).toBe(true);
61
- expect(isSegmentedZoneIp('192.168.0.253')).toBe(false); // internal IS the LAN
64
+ expect(isSegmentedZoneIp('10.226.10.10')).toBe(true); // caddy DMZ — the ISS-0101/0111 bug value
65
+ expect(isSegmentedZoneIp('10.226.20.30')).toBe(true);
66
+ expect(isSegmentedZoneIp('10.226.1.253')).toBe(false); // internal IS the LAN
62
67
  expect(isSegmentedZoneIp('100.100.0.100')).toBe(false); // public
63
68
  });
64
69
  });
@@ -67,16 +72,16 @@ describe('isPublicLeakSafe (CLAUDE.md inviolable rule #1)', () => {
67
72
  test('only public addresses are leak-safe', () => {
68
73
  expect(isPublicLeakSafe('100.100.0.100')).toBe(true);
69
74
  expect(isPublicLeakSafe('8.8.8.8')).toBe(true);
70
- expect(isPublicLeakSafe('10.0.10.10')).toBe(false); // managed segmented zone
71
- expect(isPublicLeakSafe('192.168.0.253')).toBe(false); // internal/RFC-1918 must not leak
75
+ expect(isPublicLeakSafe('10.226.10.10')).toBe(false); // managed segmented zone
76
+ expect(isPublicLeakSafe('10.226.1.253')).toBe(false); // internal/RFC-1918 must not leak
72
77
  expect(isPublicLeakSafe('0.0.0.0')).toBe(false); // reserved sentinel
73
78
  });
74
79
 
75
80
  test('the ISS-0101 scenario: a fronted name resolving to caddy DMZ IP is NOT leak-safe', () => {
76
81
  // www.<domain> from a LAN device must be the natIp (internal) or public, never
77
82
  // caddy's DMZ container IP. The doctor + e2e share this judgment.
78
- const wwwResolvedWrong = '10.0.10.10';
83
+ const wwwResolvedWrong = '10.226.10.10';
79
84
  expect(isSegmentedZoneIp(wwwResolvedWrong)).toBe(true);
80
- expect(classifyIp('192.168.0.253').kind).toBe('zone'); // the RIGHT answer (natIp, internal)
85
+ expect(classifyIp('10.226.1.253').kind).toBe('zone'); // the RIGHT answer (natIp, internal)
81
86
  });
82
87
  });
@@ -10,9 +10,10 @@
10
10
  *
11
11
  * Classification uses `ZONE_SUBNETS` (exact) + a reserved-range check — deliberately
12
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 10.x / 192.168.x are *zones* and 100.x is
14
- * *public*. (A real deployment would additionally treat RFC-1918 + RFC-6598 as
15
- * non-public; that's the caller's policy, layered on top of this primitive.)
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.)
16
17
  */
17
18
 
18
19
  import { type Zone, ZONE_SUBNETS } from './types';
@@ -52,7 +53,7 @@ export function parseIpv4(ip: string): number | null {
52
53
  return ((octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]) >>> 0;
53
54
  }
54
55
 
55
- /** True if `ip` falls within `cidr` (e.g. "10.0.10.0/24"). */
56
+ /** True if `ip` falls within `cidr` (e.g. "10.226.10.0/24"). */
56
57
  export function inCidr(ip: string, cidr: string): boolean {
57
58
  const [base, bitsStr] = cidr.split('/');
58
59
  const bits = Number(bitsStr);