/** * Zone classification for e2e network assertions (ISS-0117, §D5). * * Pure functions: given a resolved IPv4, say which celilo zone it belongs to (by * matching the topology's `ZONE_SUBNETS`), or whether it is a reserved/special-use * range, or a globally-routable "public" address. The vantage-assertion verbs * (`resolveFrom` / `assertResolvesInZone`, ISS-0117 §D3) build on this to enforce * e.g. "www. resolved from internalDevice must NOT be a segmented-zone * container IP" — the ISS-0101 / ISS-0111 bug class. * * Classification uses `ZONE_SUBNETS` (exact) + a reserved-range check. RFC-1918 is * deliberately absent from the reserved list: in this topology those ranges are the * managed zones. RFC-6598 (CGNAT) used to be absent too, because the sim's "public" * edge was a CGNAT stand-in — that exemption is gone with the renumber to TEST-NET-3 * (RFC 5737), and a carrier address is now correctly non-public here as it is in * `@celilo/capabilities`. */ import { parseIpv4, subnetContains } from '@celilo/capabilities'; import { ZONE_SUBNETS, type Zone } from './types'; export type ZoneClass = | { kind: 'zone'; zone: Zone } | { kind: 'reserved'; label: string } | { kind: 'public' } | { kind: 'invalid' }; /** * Segmented zones a device on the operator's LAN (`internal`) cannot route into — * traffic to them must go via the firewall natIp. `internal` IS the LAN, so it is * NOT segmented (a record at an internal-zone IP is reachable). */ export const SEGMENTED_ZONES: readonly Zone[] = ['dmz', 'app', 'secure']; /** * Special-use IPv4 ranges that must never be a real service address (nor appear in * any DNS answer). RFC-1918 is intentionally absent — in this topology those ranges * are the managed zones. * * The `internet-external` network still numbers its public simulators out of * `100.64.0.0/24`; nothing classifies those addresses, and renumbering that network * is not part of this change. */ const RESERVED: ReadonlyArray<{ cidr: string; label: string }> = [ { cidr: '0.0.0.0/8', label: 'unspecified/this-host (incl. the 0.0.0.0 sentinel)' }, { cidr: '100.64.0.0/10', label: 'carrier-grade NAT (RFC 6598)' }, { cidr: '127.0.0.0/8', label: 'loopback' }, { cidr: '169.254.0.0/16', label: 'link-local (RFC 3927)' }, { cidr: '224.0.0.0/4', label: 'multicast' }, { cidr: '240.0.0.0/4', label: 'reserved (RFC 1112)' }, ]; /** Classify a resolved IPv4 against the e2e topology. */ export function classifyIp(ip: string): ZoneClass { if (parseIpv4(ip) === null) return { kind: 'invalid' }; for (const r of RESERVED) { if (subnetContains(r.cidr, ip)) return { kind: 'reserved', label: r.label }; } for (const zone of Object.keys(ZONE_SUBNETS) as Zone[]) { if (subnetContains(ZONE_SUBNETS[zone], ip)) return { kind: 'zone', zone }; } return { kind: 'public' }; } /** True if `ip` is inside a segmented zone (dmz/app/secure) — unroutable from the LAN. */ export function isSegmentedZoneIp(ip: string): boolean { const c = classifyIp(ip); return c.kind === 'zone' && SEGMENTED_ZONES.includes(c.zone); } /** * The "no leak past the public boundary" rule (ISS-0117 §D5; CLAUDE.md inviolable * rule #1): a public DNS answer must be a routable public address — never a managed * zone (incl. `internal`/RFC-1918) nor a reserved range. */ export function isPublicLeakSafe(ip: string): boolean { return classifyIp(ip).kind === 'public'; }