/** * Vantage-point network assertions (ISS-0117, §D3/§D4). * * Every network check runs FROM a named vantage — the box a real consumer occupies — * and classifies the result against the zone topology (see ./zone-classifier). A green * from the wrong vantage is worse than no test: ISS-0101 (www -> DMZ container IP), * ISS-0111 (git-ssh -> container IP) and the ISS-0095 risk all passed e2e because the * assertion ran from the management box, which has routes into every zone. * * This module is the typed VERB layer. It performs no I/O itself: it orchestrates a * `ProbeTransport` (the injected boundary, Rule 2.3 / Rule 10.4) and applies zone * classification + assertion policy on the typed results. The real transports — a * dockerExec escape hatch now, a probe-agent later (§D2) — implement `ProbeTransport` * elsewhere, so the verbs stay pure and unit-testable without Docker. */ import type { Vantage, Zone } from './types'; import { classifyIp } from './zone-classifier'; // `Vantage` is topology vocabulary — defined in ./types alongside Zone — and re-exported // here so verb-layer consumers can import it from the same module as the verbs. export type { Vantage }; /** How a name is resolved (§D3) — lets us test the resolution layers independently. */ export type ResolveMethod = | 'system' // the vantage's real stack: resolv.conf AND /etc/hosts | 'dns' // query a resolver directly (optionally a specific `server`) | 'hostfiles'; // the /etc/hosts layer only (the host-pin path — ISS-0095) /** A zone the answer may legitimately land in, plus `public` for a routable internet address. */ export type ExpectedZone = Zone | 'public'; export interface ResolveResult { name: string; from: Vantage; method: ResolveMethod; ips: string[]; // resolved A records; empty == NXDOMAIN / no answer } export interface CertInfo { cn: string; issuer: string; subjectAltNames: string[]; selfSigned: boolean; } export interface HttpsResult { from: Vantage; url: string; status: number; body: string; cert: CertInfo; } export interface ResolutionInspection { from: Vantage; name: string; resolvConf: string; hostsLines: string[]; } /** * The injected I/O boundary. Implementations (dockerExec-based now, probe-agent later) * are the ONLY place raw shell / network calls live; the verbs below never touch I/O. */ export interface ProbeTransport { resolve(opts: { from: Vantage; name: string; method: ResolveMethod; server?: string }): Promise<{ ips: string[]; }>; tcpConnect(opts: { from: Vantage; host: string; port: number }): Promise<{ connected: boolean; detail?: string; }>; ping(opts: { from: Vantage; target: string }): Promise<{ alive: boolean }>; httpsRequest(opts: { from: Vantage; url: string }): Promise<{ status: number; body: string; cert: CertInfo; }>; inspectResolution(opts: { from: Vantage; name: string }): Promise<{ resolvConf: string; hostsLines: string[]; }>; } /** Thrown when a vantage assertion fails. Messages name the bug class they guard. */ export class VantageAssertionError extends Error { constructor(message: string) { super(message); this.name = 'VantageAssertionError'; } } interface ResolveOpts { from: Vantage; name: string; method?: ResolveMethod; server?: string; } interface ReachOpts { from: Vantage; host: string; port: number; } interface HttpsAssertOpts { from: Vantage; url: string; expectStatus?: number; expectCN?: string; expectCA?: string; // expected issuer substring (Let's Encrypt / Pebble in e2e); never self-signed } function describeZone(ip: string): string { const c = classifyIp(ip); switch (c.kind) { case 'zone': return `zone ${c.zone}`; case 'reserved': return `reserved (${c.label})`; case 'public': return 'public'; case 'invalid': return 'invalid IP'; } } /** * The typed probe surface. Construct with a transport; every verb takes a single * options object with a required `from` (§D1 — no default vantage). */ export class VantageProbe { constructor(private readonly transport: ProbeTransport) {} async resolveFrom(opts: ResolveOpts): Promise { const method = opts.method ?? 'system'; const { ips } = await this.transport.resolve({ from: opts.from, name: opts.name, method, server: opts.server, }); return { name: opts.name, from: opts.from, method, ips }; } async assertResolves(opts: ResolveOpts): Promise { const result = await this.resolveFrom(opts); if (result.ips.length === 0) { throw new VantageAssertionError( `${opts.name} did not resolve from ${opts.from} (NXDOMAIN / no A record), expected an answer.`, ); } return result; } async assertNxdomain(opts: ResolveOpts): Promise { const result = await this.resolveFrom(opts); if (result.ips.length > 0) { throw new VantageAssertionError( `${opts.name} resolved from ${opts.from} to ${result.ips.join(', ')}, expected NXDOMAIN ` + `(this name must not be visible from ${opts.from}).`, ); } } /** * The killer verb: resolve, classify every answer, assert the expected zone. * `www.` from `internalDevice` MUST be `internal`/`public` (natIp/public), * never a managed `dmz`/`app`/`secure` container IP — the ISS-0101 / ISS-0111 class. */ async assertResolvesInZone(opts: ResolveOpts & { zone: ExpectedZone }): Promise { const result = await this.assertResolves(opts); const offenders = result.ips.filter((ip) => !ipMatchesExpectedZone(ip, opts.zone)); if (offenders.length > 0) { const detail = offenders.map((ip) => `${ip} (${describeZone(ip)})`).join(', '); throw new VantageAssertionError( `${opts.name} from ${opts.from} resolved to ${detail}, expected zone "${opts.zone}". A managed-zone container IP is unroutable from outside that zone — this is the e2e-green/prod-broke class (ISS-0101, ISS-0111). The right answer from a consumer vantage is the firewall natIp (internal) or a public address.`, ); } return result; } /** * Resolve and assert the answer includes the EXACT expected IP(s) — the DNS-correctness * verb. Pair with `method: 'dns', server` to assert what a SPECIFIC resolver returns (e.g. * the internal split-horizon resolver vs the public resolver). Used by the DNS suites to * check a DDNS-registered IP, an nsupdate'd A record, or a split-horizon answer. */ async assertResolvesTo( opts: ResolveOpts & { expected: string | string[] }, ): Promise { const result = await this.assertResolves(opts); const expected = Array.isArray(opts.expected) ? opts.expected : [opts.expected]; const missing = expected.filter((ip) => !result.ips.includes(ip)); if (missing.length > 0) { const via = opts.server ? ` via ${opts.server}` : ''; throw new VantageAssertionError( `${opts.name} from ${opts.from}${via} resolved to [${result.ips.join(', ')}], ` + `expected to include [${missing.join(', ')}].`, ); } return result; } async ping(opts: { from: Vantage; target: string }): Promise { const { alive } = await this.transport.ping(opts); return alive; } async assertReachable(opts: ReachOpts): Promise { const { connected, detail } = await this.transport.tcpConnect(opts); if (!connected) { throw new VantageAssertionError( `${opts.host}:${opts.port} was NOT reachable from ${opts.from}${detail ? ` (${detail})` : ''}, expected a successful TCP connection.`, ); } } /** Isolation assertion (§D6): prove what shouldn't work, e.g. appSystem cannot reach a secure host. */ async assertUnreachable(opts: ReachOpts): Promise { const { connected } = await this.transport.tcpConnect(opts); if (connected) { throw new VantageAssertionError( `${opts.host}:${opts.port} WAS reachable from ${opts.from}, expected it to be blocked (zone isolation / firewall boundary violated).`, ); } } /** * Connect, validate the REAL cert chain (right CN, expected issuer, never self-signed), * then status. Cert provenance catches the skip-cert / 0.0.0.0-sentinel cheats. */ async assertHttps(opts: HttpsAssertOpts): Promise { const res = await this.transport.httpsRequest({ from: opts.from, url: opts.url }); const result: HttpsResult = { from: opts.from, url: opts.url, ...res }; if (res.cert.selfSigned) { throw new VantageAssertionError( `${opts.url} from ${opts.from} presented a self-signed cert (issuer "${res.cert.issuer}"). A real cert from the e2e CA (Pebble / Let’s Encrypt) is required — no --insecure.`, ); } if (opts.expectCN && res.cert.cn !== opts.expectCN) { throw new VantageAssertionError( `${opts.url} from ${opts.from} cert CN "${res.cert.cn}", expected "${opts.expectCN}".`, ); } if (opts.expectCA && !res.cert.issuer.includes(opts.expectCA)) { throw new VantageAssertionError( `${opts.url} from ${opts.from} cert issuer "${res.cert.issuer}", expected to contain "${opts.expectCA}".`, ); } if (opts.expectStatus !== undefined && res.status !== opts.expectStatus) { throw new VantageAssertionError( `${opts.url} from ${opts.from} returned status ${res.status}, expected ${opts.expectStatus}.`, ); } return result; } /** Diagnostic (§D3): the vantage's resolv.conf + matching /etc/hosts lines. Also asserts a host-pin (ISS-0095). */ async inspectResolution(opts: { from: Vantage; name: string }): Promise { const res = await this.transport.inspectResolution(opts); return { from: opts.from, name: opts.name, ...res }; } } /** True if `ip` satisfies the expected zone (managed zone exact, or routable `public`). */ function ipMatchesExpectedZone(ip: string, expected: ExpectedZone): boolean { const c = classifyIp(ip); if (expected === 'public') return c.kind === 'public'; return c.kind === 'zone' && c.zone === expected; }