/** * Observer vantages (ISS-0117 §D2/§D4) — the topology side of the vantage framework. * * An *observer* is a passive spy: a purpose-built container carrying all the probe * tooling, injected at a named vantage's network location with a routing profile that * mirrors a REAL device there. The point is faithfulness — a vantage's correctness is * its routing table, not just which network it attaches to. The management box reaches * a DMZ container IP directly only because management-routes.sh adds an explicit * inter-zone route; a real LAN device has no such route, so it must go via the firewall * natIp. An `internalDevice` observer reproduces the LAN device's routing exactly, which * is how it catches the ISS-0101 / ISS-0111 "e2e-green/prod-broke" class. * * This file holds the single source of truth for each vantage's placement + routing * profile (consumed by the compose generator), the vantage->container mapping, and the * dockerExec-based ProbeTransport (the developer escape hatch the design retains; the * typed probe-agent will wrap this same surface later). * * Commands sent through dockerExec deliberately avoid `$(...)`, backticks and unescaped * `$` — those are evaluated by the HOST shell before reaching the container (the wrapping * footgun CLAUDE.md documents). We rely on the container command's exit code (propagated * by `docker compose exec`) instead of `echo $?`. */ import { type NetworkHandle, type ObserverSpec, type Vantage, ZONE_GATEWAYS } from './types'; import type { CertInfo, ProbeTransport, ResolveMethod } from './vantage'; export interface ObserverPlacement { /** docker compose service name for this observer */ service: string; /** docker network to attach to */ network: string; /** the observer's own IP in that network */ ip: string; /** default route (gateway) — the load-bearing faithfulness knob */ gateway: string; /** add the management-style explicit inter-zone routes (only the `management` trap) */ interZoneRoutes: boolean; /** nameservers written to /etc/resolv.conf, in order */ resolvers: string[]; } // The internal LAN's home router (hands out DHCP option:router) and split-horizon // resolver. Literals here match the values the compose generator + dnsmasq already use; // they are roles, not pins repeated into tests. ZONE_GATEWAYS.internal (.254) is fw-main // — the firewall to the segmented zones — NOT the LAN's default route, which is the home // router below. A faithful internalDevice routes its default via the home router and has // NO route to the segmented zones, so a dmz/app/secure container IP is unreachable. const HOME_ROUTER = '10.226.1.1'; const INTERNAL_RESOLVER = '10.226.1.10'; const PUBLIC_RESOLVER = '203.0.113.1'; const INTERNET_GATEWAY = '100.64.0.1'; // fw-ext, on internet-external /** Derive an observer host IP in a zone from its gateway (no new literal subnets). */ function observerIpInZone(zone: 'dmz' | 'app' | 'secure'): string { return ZONE_GATEWAYS[zone].replace(/\.\d+$/, '.240'); } /** * Placement + routing profile per injectable vantage. `management` is intentionally * absent — that vantage reuses the real `management` container (see vantageContainer), * so the trap is demonstrated with the actual all-VLAN box, not a copy. */ export const OBSERVER_PLACEMENTS: Record, ObserverPlacement> = { internalDevice: { service: 'observer-internal', network: 'internal', ip: '10.226.1.211', // a LAN host (dhcp range is .200-.220; dhcp-client is .210) gateway: HOME_ROUTER, interZoneRoutes: false, // Identical DNS to the management box on purpose: the only difference vs management // is the routing profile, so any reachability gap is provably routing — the bug. resolvers: [INTERNAL_RESOLVER, PUBLIC_RESOLVER], }, dmzSystem: { service: 'observer-dmz', network: 'dmz', ip: observerIpInZone('dmz'), gateway: ZONE_GATEWAYS.dmz, interZoneRoutes: false, resolvers: [INTERNAL_RESOLVER, PUBLIC_RESOLVER], }, appSystem: { service: 'observer-app', network: 'app', ip: observerIpInZone('app'), gateway: ZONE_GATEWAYS.app, interZoneRoutes: false, resolvers: [INTERNAL_RESOLVER, PUBLIC_RESOLVER], }, secureSystem: { service: 'observer-secure', network: 'secure', ip: observerIpInZone('secure'), gateway: ZONE_GATEWAYS.secure, interZoneRoutes: false, resolvers: [INTERNAL_RESOLVER, PUBLIC_RESOLVER], }, publicInternet: { service: 'observer-public', network: 'internet-external', ip: '100.64.0.240', gateway: INTERNET_GATEWAY, interZoneRoutes: false, // Outside the firewall: only the public resolver, never the internal split-horizon view. resolvers: [PUBLIC_RESOLVER], }, }; /** The placement for an injectable vantage; throws for `management` (not injectable). */ export function observerPlacement(vantage: Vantage): ObserverPlacement { if (vantage === 'management') { throw new Error( 'management has no observer placement — it reuses the real management container.', ); } return OBSERVER_PLACEMENTS[vantage]; } /** The container a vantage probes from. `management` reuses the real management box. */ export function vantageContainer(vantage: Vantage): string { if (vantage === 'management') return 'management'; return OBSERVER_PLACEMENTS[vantage].service; } /** Environment a generated observer service needs for its routing-profile setup script. */ export function observerEnv(placement: ObserverPlacement): Record { return { OBSERVER_GATEWAY: placement.gateway, OBSERVER_INTERZONE: placement.interZoneRoutes ? '1' : '0', OBSERVER_RESOLVERS: placement.resolvers.join(' '), }; } /** Dedupe + validate a list of observer specs (a vantage can't be injected twice). */ export function normalizeObservers(observers: ObserverSpec[]): ObserverSpec[] { const seen = new Set(); const out: ObserverSpec[] = []; for (const spec of observers) { if (spec.vantage === 'management') { throw new Error( 'management is not an injectable observer — that vantage reuses the real management container.', ); } if (seen.has(spec.vantage)) continue; seen.add(spec.vantage); out.push(spec); } return out; } // --- dockerExec-based ProbeTransport (escape hatch; substitution-free commands) --- const IPV4 = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; /** * The first whitespace token of each line, kept if it's an IPv4. Works for all three * resolve methods without shell-side column extraction (which would need `$1`/awk — a * host-shell-expansion footgun): `dig +short` lines ARE the IP; `getent hosts` and * `/etc/hosts` lines are "IP name…", so token[0] is the IP either way. */ function firstColumnIps(stdout: string): string[] { return stdout .split('\n') .map((l) => l.trim().split(/\s+/)[0] ?? '') .filter((tok) => IPV4.test(tok)); } function hostPort(url: string): { host: string; port: number } { const u = new URL(url); return { host: u.hostname, port: u.port ? Number(u.port) : u.protocol === 'https:' ? 443 : 80 }; } function parseCert(opensslOut: string): CertInfo { const subjectLine = opensslOut.match(/^subject=(.*)$/m)?.[1]?.trim() ?? ''; const issuerLine = opensslOut.match(/^issuer=(.*)$/m)?.[1]?.trim() ?? ''; const cn = subjectLine.match(/CN\s*=\s*([^,/]+)/)?.[1]?.trim() ?? ''; const issuerCn = issuerLine.match(/CN\s*=\s*([^,/]+)/)?.[1]?.trim() ?? issuerLine; const sanLine = opensslOut.match(/DNS:[^\n]*/)?.[0] ?? ''; const subjectAltNames = sanLine .split(',') .map((s) => s.replace(/.*DNS:/, '').trim()) .filter(Boolean); return { cn, issuer: issuerCn, subjectAltNames, // Pebble's leaf issuer differs from the leaf subject; equal subject==issuer ⇒ self-signed. selfSigned: subjectLine !== '' && subjectLine === issuerLine, }; } /** * A ProbeTransport that runs each verb's probe inside the vantage's observer container * via `handle.exec`. Returns typed results; raw dockerExec stays available on `handle`. */ export function createObserverTransport(handle: NetworkHandle): ProbeTransport { return { async resolve(opts) { const c = vantageContainer(opts.from); const cmd = resolveCommand(opts.name, opts.method, opts.server); const res = await handle.exec(c, cmd, 15_000); return { ips: firstColumnIps(res.stdout) }; }, async tcpConnect(opts) { const c = vantageContainer(opts.from); // bash's /dev/tcp builtin, not `nc`: the management box (a valid vantage) ships no // netcat, whereas /dev/tcp works on every vantage. `timeout` bounds a hung connect. const res = await handle.exec( c, `timeout 4 bash -c 'exec 3<>/dev/tcp/${opts.host}/${opts.port}'`, 8_000, ); return { connected: res.exitCode === 0, detail: res.exitCode === 0 ? undefined : 'connect failed/timed out', }; }, async ping(opts) { const c = vantageContainer(opts.from); const res = await handle.exec(c, `ping -c 1 -W 2 ${opts.target}`, 6_000); return { alive: res.exitCode === 0 }; }, async httpsRequest(opts) { const c = vantageContainer(opts.from); const { host, port } = hostPort(opts.url); const certRes = await handle.exec( c, `echo | openssl s_client -connect ${host}:${port} -servername ${host} 2>/dev/null | openssl x509 -noout -subject -issuer -ext subjectAltName`, 10_000, ); // -k gathers the status even for an untrusted chain; the cert is reported truthfully // above so the verb layer asserts provenance separately (this is fact-gathering, not // validation-skipping — the no-insecure rule is enforced by the verb, not relaxed here). const statusRes = await handle.exec( c, `curl -sk -o /dev/null -w '%{http_code}' --max-time 8 ${opts.url}`, 10_000, ); const bodyRes = await handle.exec(c, `curl -sk --max-time 8 ${opts.url}`, 10_000); return { status: Number.parseInt(statusRes.stdout.trim(), 10) || 0, body: bodyRes.stdout, cert: parseCert(certRes.stdout), }; }, async inspectResolution(opts) { const c = vantageContainer(opts.from); const resolv = await handle.exec(c, 'cat /etc/resolv.conf', 5_000); const hosts = await handle.exec(c, `grep -F ${opts.name} /etc/hosts`, 5_000); return { resolvConf: resolv.stdout.trim(), hostsLines: hosts.stdout .split('\n') .map((l) => l.trim()) .filter(Boolean), }; }, }; } function resolveCommand(name: string, method: ResolveMethod, server?: string): string { switch (method) { case 'dns': // DNS only (ignores /etc/hosts); optionally a specific resolver. return `dig +short A ${name}${server ? ` @${server}` : ''}`; case 'hostfiles': // The /etc/hosts layer only (the host-pin path — ISS-0095). Raw; parsed host-side. return `grep -F ${name} /etc/hosts`; default: // system: the real stack — nsswitch consults files AND dns. getent prints "IP name...". return `getent hosts ${name}`; } }