@celilo/e2e 0.12.0 → 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 (45) 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/routing/fw-ext-routes.sh +8 -8
  8. package/config/routing/fw-isp-routes.sh +2 -2
  9. package/config/routing/fw-main-routes.sh +5 -5
  10. package/config/routing/management-routes.sh +1 -1
  11. package/config/routing/observer-setup.sh +1 -1
  12. package/config/routing/public-sim-entrypoint.sh +1 -1
  13. package/config/routing/resolver-internal-routes.sh +2 -2
  14. package/config/routing/resolver-routes.sh +1 -1
  15. package/config/routing/target-routes.sh +1 -1
  16. package/config/routing/target-setup.sh +1 -1
  17. package/config/socks/startup.sh +2 -2
  18. package/package.json +4 -3
  19. package/simulators/greenwave/server.ts +35 -0
  20. package/simulators/greenwave/state.ts +64 -11
  21. package/simulators/ip-echo/server.ts +1 -1
  22. package/src/address-plan.test.ts +4 -4
  23. package/src/cli/build.ts +30 -0
  24. package/src/cli/command-registry.ts +19 -0
  25. package/src/cli/completion.ts +16 -1
  26. package/src/cli/index.ts +85 -4
  27. package/src/container-manager.ts +75 -12
  28. package/src/docker-compose-generator.ts +55 -26
  29. package/src/doctor.test.ts +279 -0
  30. package/src/doctor.ts +421 -0
  31. package/src/extract-failure.ts +41 -0
  32. package/src/index.ts +12 -0
  33. package/src/last-run.test.ts +62 -0
  34. package/src/last-run.ts +54 -0
  35. package/src/observer.test.ts +2 -2
  36. package/src/observer.ts +1 -1
  37. package/src/router-swap.ts +121 -0
  38. package/src/run-lock.test.ts +73 -2
  39. package/src/run-lock.ts +110 -14
  40. package/src/runner.ts +99 -5
  41. package/src/socks-proxy.ts +2 -2
  42. package/src/types.ts +25 -4
  43. package/src/vantage.test.ts +1 -1
  44. package/src/zone-classifier.test.ts +11 -16
  45. package/src/zone-classifier.ts +16 -31
package/src/runner.ts CHANGED
@@ -29,7 +29,9 @@ import {
29
29
  emitTestCompleted,
30
30
  emitTestStarted,
31
31
  } from './bus-events';
32
- import { extractFailureMessage, stripAnsi } from './extract-failure';
32
+ import { diagnose, formatReport } from './doctor';
33
+ import { type StageTally, extractFailureMessage, stripAnsi, tallyStages } from './extract-failure';
34
+ import { writeLastRun } from './last-run';
33
35
  import { parseLine } from './parse-line';
34
36
  import { E2eBusyError, acquireRunLock, markKept } from './run-lock';
35
37
  import { SIMULATOR_IPS } from './simulator-ips';
@@ -200,6 +202,12 @@ interface TestResult {
200
202
  rawTail?: string;
201
203
  hadDebugPause?: boolean;
202
204
  projectName?: string;
205
+ /**
206
+ * Real stage failures vs stages a failing earlier stage blocked. Without the
207
+ * split, one bad fixture line in stage 1 of a 10-stage suite reports as "9
208
+ * failed" — nine counts of a defect that does not exist.
209
+ */
210
+ stages?: StageTally;
203
211
  }
204
212
 
205
213
  async function runTest(
@@ -399,12 +407,24 @@ async function runTest(
399
407
  projectName: capturedProjectName,
400
408
  error: extractFailureMessage(lines, code ?? 1),
401
409
  rawTail,
410
+ stages: tallyStages(lines),
402
411
  });
403
412
  }
404
413
  });
405
414
  });
406
415
  }
407
416
 
417
+ /**
418
+ * " — 1 stage failed, 8 skipped (blocked by an earlier stage)". Rendered only
419
+ * when a cascade actually happened, so an ordinary single-stage failure stays
420
+ * as terse as it was.
421
+ */
422
+ function stageSuffix(stages: StageTally | undefined): string {
423
+ if (!stages || stages.skipped === 0) return '';
424
+ const failed = `${stages.failed} stage${stages.failed === 1 ? '' : 's'} failed`;
425
+ return ` ${dim}— ${failed}, ${stages.skipped} skipped (blocked by an earlier stage)${reset}`;
426
+ }
427
+
408
428
  // ─── Shared Infrastructure ───────────────────────────────────────────
409
429
 
410
430
  function dockerComposeShared(cmd: string): string {
@@ -475,11 +495,20 @@ async function main() {
475
495
  // docker mutation so a competing run can't wipe our shared infra mid-setup.
476
496
  const lockLabel = patterns.length ? patterns.join(',') : moduleDirs.length ? 'modules' : 'all';
477
497
  try {
478
- acquireRunLock({
498
+ const outcome = acquireRunLock({
479
499
  test: lockLabel,
480
500
  runId: process.env.CELE2E_RUN_ID ?? ambientRunId,
481
501
  allowKept: flagReuse,
482
502
  });
503
+ if (outcome.autoReleasedOwnKept) {
504
+ const own = outcome.autoReleasedOwnKept;
505
+ console.log(
506
+ `${dim}Auto-released your own kept stack (${own.test}, held since ${own.startedAt}) and continuing.${reset}`,
507
+ );
508
+ console.log(
509
+ `${dim}Use \`cele2e run --reuse\` instead if you meant to run against it.${reset}`,
510
+ );
511
+ }
483
512
  // A --reuse run keeps using a kept stack that stays up afterwards — hold the
484
513
  // lock in kept state so it isn't freed out from under the reused network.
485
514
  if (flagReuse) markKept();
@@ -494,6 +523,25 @@ async function main() {
494
523
  throw err;
495
524
  }
496
525
 
526
+ // Preflight the environment BEFORE touching Docker. Every check here failed
527
+ // silently once and surfaced later somewhere unrelated — a missing bake as an
528
+ // SSH error against a firewall IP, a pruned base image as a TLS timeout 16
529
+ // images into a build. Refusing now costs seconds; not refusing cost a full
530
+ // run plus a debugging cycle each time. Lock state is excluded: we already
531
+ // hold the lock, so checkRunLock would report ourselves as contention.
532
+ const health = diagnose({ pkgDir: PKG_DIR, skipLock: true });
533
+ if (!health.ok) {
534
+ console.error(
535
+ `\n${red}✗ cele2e preflight failed — not starting a run that cannot succeed.${reset}`,
536
+ );
537
+ for (const line of formatReport(health)) console.error(line);
538
+ console.error(`\n${dim}Full environment report: cele2e doctor${reset}\n`);
539
+ process.exit(1);
540
+ }
541
+ for (const c of health.checks) {
542
+ if (c.status === 'warn') console.log(`${dim}! ${c.name}: ${c.detail}${reset}`);
543
+ }
544
+
497
545
  let testFiles: string[] = [];
498
546
 
499
547
  if (moduleDirs.length > 0) {
@@ -656,6 +704,22 @@ async function main() {
656
704
  if (!process.env.CELE2E_RUN_ID) {
657
705
  console.log(`${dim}runId: ${runId}${reset}`);
658
706
  }
707
+ // Print the results dir HERE, not only at the end: a run that dies mid-way
708
+ // still wrote logs there, and `ls -t results | head -1` is not a safe way to
709
+ // find them — after a refused start it returns the PREVIOUS run's numbers,
710
+ // which look exactly like a clean pass. `cele2e last` reads the same record.
711
+ console.log(`${dim}results: ${resultsDir}${reset}`);
712
+ writeLastRun({
713
+ runId,
714
+ resultsDir,
715
+ startedAt: new Date(suiteStart).toISOString(),
716
+ status: 'running',
717
+ total: testFiles.length,
718
+ passed: 0,
719
+ failed: 0,
720
+ skipped: 0,
721
+ durationMs: 0,
722
+ });
659
723
  emitRunStarted({
660
724
  runId,
661
725
  scenario: testFiles.length > 1 ? 'multi-test' : 'single-test',
@@ -784,7 +848,7 @@ async function main() {
784
848
  console.log(` ${red} └─ ${result.error}${reset}`);
785
849
  failed++;
786
850
  } else {
787
- console.log(` ${red}✗ failed (${result.duration}s)${reset}`);
851
+ console.log(` ${red}✗ failed (${result.duration}s)${reset}${stageSuffix(result.stages)}`);
788
852
  if (result.error) {
789
853
  console.log(` ${red} └─ ${result.error}${reset}`);
790
854
  }
@@ -817,18 +881,28 @@ async function main() {
817
881
 
818
882
  const suiteDuration = Math.floor((Date.now() - suiteStart) / 1000);
819
883
 
884
+ // Stages a failing earlier stage blocked are reported separately from real
885
+ // failures. Counting them together turns one bad fixture line into "9 failed"
886
+ // and buries the single defect that actually exists.
887
+ const stageSkipped = results.reduce((sum, r) => sum + (r.stages?.skipped ?? 0), 0);
888
+
820
889
  console.log();
821
890
  console.log(`${bold}╔══════════════════════════════════════════════════════════════${reset}`);
822
891
  console.log(
823
892
  `${bold}║ Results: ${green}${passed} passed${reset}${bold}, ${red}${failed} failed${reset}${bold} — ${formatDuration(suiteDuration)} total${reset}`,
824
893
  );
894
+ if (stageSkipped > 0) {
895
+ console.log(
896
+ `${bold}║${reset} ${dim}${stageSkipped} stage(s) skipped — blocked by an earlier stage, not separate defects${reset}`,
897
+ );
898
+ }
825
899
  console.log(`${bold}╠══════════════════════════════════════════════════════════════${reset}`);
826
900
 
827
901
  for (const r of results) {
828
902
  const icon = r.status === 'pass' ? '✓' : '✗';
829
903
  const color = r.status === 'pass' ? green : red;
830
904
  console.log(
831
- `${bold}║${reset} ${color}${icon}${reset} ${r.name.padEnd(28)} ${dim}${(`${r.duration}s`).padStart(6)}${reset}`,
905
+ `${bold}║${reset} ${color}${icon}${reset} ${r.name.padEnd(28)} ${dim}${(`${r.duration}s`).padStart(6)}${reset}${stageSuffix(r.stages)}`,
832
906
  );
833
907
  if (r.error) {
834
908
  console.log(`${bold}║${reset} ${red}└─ ${r.error}${reset}`);
@@ -838,16 +912,36 @@ async function main() {
838
912
  console.log(`${bold}╚══════════════════════════════════════════════════════════════${reset}`);
839
913
  console.log();
840
914
  console.log(`${dim}Full logs: ${resultsDir}${reset}`);
915
+ console.log(`${dim}Machine-readable: cele2e last --json${reset}`);
841
916
 
842
917
  writeFileSync(
843
918
  join(resultsDir, 'summary.json'),
844
919
  JSON.stringify(
845
- { timestamp, total: testFiles.length, passed, failed, duration: suiteDuration },
920
+ {
921
+ timestamp,
922
+ total: testFiles.length,
923
+ passed,
924
+ failed,
925
+ stageSkipped,
926
+ duration: suiteDuration,
927
+ },
846
928
  null,
847
929
  2,
848
930
  ),
849
931
  );
850
932
 
933
+ writeLastRun({
934
+ runId,
935
+ resultsDir,
936
+ startedAt: new Date(suiteStart).toISOString(),
937
+ status: failed > 0 ? 'failed' : 'completed',
938
+ total: testFiles.length,
939
+ passed,
940
+ failed,
941
+ skipped: stageSkipped,
942
+ durationMs: suiteDuration * 1000,
943
+ });
944
+
851
945
  const gitignore = join(testDir, '.gitignore');
852
946
  const content = existsSync(gitignore) ? readFileSync(gitignore, 'utf-8') : '';
853
947
  const adds: string[] = [];
@@ -44,7 +44,7 @@ export interface SocksProxyHandle {
44
44
  * Why custom: an off-the-shelf SOCKS5 image (e.g. serjs/go-socks5-proxy)
45
45
  * starts fine on the test network but can't actually route to anything.
46
46
  * Docker's IPAM-default gateway points at an IP with no listener (e.g.
47
- * 100.100.0.250 on isp-external), and docker rewrites resolv.conf to
47
+ * 203.0.113.250 on isp-external), and docker rewrites resolv.conf to
48
48
  * its embedded resolver, which can't cleanly forward to the simulated
49
49
  * network's DNS. The custom image's entrypoint replaces both.
50
50
  */
@@ -61,7 +61,7 @@ interface VantageConfig {
61
61
  const VANTAGE_CONFIGS: Record<SocksProxyVantage, VantageConfig> = {
62
62
  'isp-external': {
63
63
  network: 'isp-external',
64
- ip: '100.100.0.150',
64
+ ip: '203.0.113.150',
65
65
  },
66
66
  internal: {
67
67
  network: 'internal',
package/src/types.ts CHANGED
@@ -363,7 +363,12 @@ 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
- * @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)
367
372
  * @param opts.natIp - NAT IP for port-forwarded traffic (default 10.226.1.253)
368
373
  */
369
374
  deployFirewall(opts?: {
@@ -371,6 +376,22 @@ export interface NetworkHandle {
371
376
  natIp?: string;
372
377
  }): Promise<void>;
373
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
+
374
395
  /** Tear down the entire network */
375
396
  stop(): Promise<void>;
376
397
 
@@ -511,7 +532,7 @@ export function greenwaveRouterIp(): string {
511
532
  * by construction. Reference this instead of hardcoding it.
512
533
  */
513
534
  export function externalWanIp(): string {
514
- return '100.100.0.100';
535
+ return '203.0.113.100';
515
536
  }
516
537
 
517
538
  /**
@@ -520,7 +541,7 @@ export function externalWanIp(): string {
520
541
  * WAN address when it reaches a public service.
521
542
  */
522
543
  export function externalWanSubnet(): string {
523
- return '100.100.0.0/24';
544
+ return '203.0.113.0/24';
524
545
  }
525
546
 
526
547
  /** The internal split-horizon DNS resolver (dns-int) in the e2e topology. */
@@ -530,5 +551,5 @@ export function internalResolverIp(): string {
530
551
 
531
552
  /** The public upstream recursive resolver (the comcast-resolver sim). */
532
553
  export function publicResolverIp(): string {
533
- return '100.100.0.1';
554
+ return '203.0.113.1';
534
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
  }