@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.
- package/README.md +5 -5
- package/bin/e2e-status +1 -1
- package/bin/e2e-up +2 -2
- package/config/dns/tangohost.com.zone +1 -1
- package/config/dns/templates/example.net.zone +3 -3
- package/config/dns/templates/iamtheinternet.org.zone +2 -2
- package/config/routing/fw-ext-routes.sh +8 -8
- package/config/routing/fw-isp-routes.sh +2 -2
- package/config/routing/fw-main-routes.sh +5 -5
- package/config/routing/management-routes.sh +1 -1
- package/config/routing/observer-setup.sh +1 -1
- package/config/routing/public-sim-entrypoint.sh +1 -1
- package/config/routing/resolver-internal-routes.sh +2 -2
- package/config/routing/resolver-routes.sh +1 -1
- package/config/routing/target-routes.sh +1 -1
- package/config/routing/target-setup.sh +1 -1
- package/config/socks/startup.sh +2 -2
- package/package.json +4 -3
- package/simulators/greenwave/server.ts +35 -0
- package/simulators/greenwave/state.ts +64 -11
- package/simulators/ip-echo/server.ts +1 -1
- package/src/address-plan.test.ts +4 -4
- package/src/cli/build.ts +30 -0
- package/src/cli/command-registry.ts +19 -0
- package/src/cli/completion.ts +16 -1
- package/src/cli/index.ts +85 -4
- package/src/container-manager.ts +75 -12
- package/src/docker-compose-generator.ts +55 -26
- package/src/doctor.test.ts +279 -0
- package/src/doctor.ts +421 -0
- package/src/extract-failure.ts +41 -0
- package/src/index.ts +12 -0
- package/src/last-run.test.ts +62 -0
- package/src/last-run.ts +54 -0
- package/src/observer.test.ts +2 -2
- package/src/observer.ts +1 -1
- package/src/router-swap.ts +121 -0
- package/src/run-lock.test.ts +73 -2
- package/src/run-lock.ts +110 -14
- package/src/runner.ts +99 -5
- package/src/socks-proxy.ts +2 -2
- package/src/types.ts +25 -4
- package/src/vantage.test.ts +1 -1
- package/src/zone-classifier.test.ts +11 -16
- 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 {
|
|
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
|
-
{
|
|
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[] = [];
|
package/src/socks-proxy.ts
CHANGED
|
@@ -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
|
-
*
|
|
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: '
|
|
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
|
-
*
|
|
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 '
|
|
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 '
|
|
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 '
|
|
554
|
+
return '203.0.113.1';
|
|
534
555
|
}
|
package/src/vantage.test.ts
CHANGED
|
@@ -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: ['
|
|
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
|
-
|
|
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('
|
|
23
|
+
describe('subnetContains (shared, @celilo/capabilities)', () => {
|
|
29
24
|
test('membership within /24', () => {
|
|
30
|
-
expect(
|
|
31
|
-
expect(
|
|
32
|
-
expect(
|
|
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(
|
|
38
|
-
expect(
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
package/src/zone-classifier.ts
CHANGED
|
@@ -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
|
|
12
|
-
*
|
|
13
|
-
* (
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
37
|
-
*
|
|
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 (
|
|
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 (
|
|
60
|
+
if (subnetContains(ZONE_SUBNETS[zone], ip)) return { kind: 'zone', zone };
|
|
76
61
|
}
|
|
77
62
|
return { kind: 'public' };
|
|
78
63
|
}
|