@celilo/e2e 0.7.14 → 0.7.16

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.
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { stringify } from 'yaml';
4
+ import { normalizeObservers, observerEnv, observerPlacement } from './observer';
4
5
  import { SIMULATOR_IPS } from './simulator-ips';
5
6
  import type { MachineSpec, NetworkConfig, TopologyPreset } from './types';
6
7
  import { ZONE_GATEWAYS } from './types';
@@ -216,7 +217,7 @@ export function generateSharedInfraYaml(): string {
216
217
  // `test123` still applies to any domain not listed.
217
218
  environment: {
218
219
  DDNS_PASSWORDS:
219
- '{"iamtheinternet.org":"test123","celilo.computer":"test456","lunacycle.net":"test789"}',
220
+ '{"iamtheinternet.org":"test123","celilo.computer":"test456","example.net":"test789"}',
220
221
  },
221
222
  volumes: [
222
223
  './config/dns/knot-namecheap.conf:/config/knot.conf:ro',
@@ -227,7 +228,7 @@ export function generateSharedInfraYaml(): string {
227
228
  // namecheap-startup.sh for the full rationale).
228
229
  './config/dns/iamtheinternet.org.zone:/seed/iamtheinternet.org.zone:ro',
229
230
  './config/dns/celilo.computer.zone:/seed/celilo.computer.zone:ro',
230
- './config/dns/lunacycle.net.zone:/seed/lunacycle.net.zone:ro',
231
+ './config/dns/example.net.zone:/seed/example.net.zone:ro',
231
232
  './config/dns/park-your-domain.com.zone:/seed/park-your-domain.com.zone:ro',
232
233
  ],
233
234
  });
@@ -504,6 +505,20 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
504
505
  });
505
506
  }
506
507
 
508
+ // --- Observer vantages (passive spies; ISS-0117) ---
509
+ // Each is the SAME image at a different network location with a routing profile that
510
+ // mirrors a real device there — that routing profile is what makes it a faithful seat
511
+ // to assert from, rather than the all-VLAN management trap.
512
+ for (const spec of normalizeObservers(config.observers ?? [])) {
513
+ const p = observerPlacement(spec.vantage);
514
+ services[p.service] = baseService({
515
+ build: { context: '.', dockerfile: 'docker/Dockerfile.observer' },
516
+ networks: { [p.network]: { ipv4_address: p.ip } },
517
+ cap_add: ['NET_ADMIN'],
518
+ environment: observerEnv(p),
519
+ });
520
+ }
521
+
507
522
  const compose = { networks, volumes, services };
508
523
  return stringify(compose, { lineWidth: 120 });
509
524
  }
@@ -619,7 +634,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
619
634
  // `test123` still applies to any domain not listed.
620
635
  environment: {
621
636
  DDNS_PASSWORDS:
622
- '{"iamtheinternet.org":"test123","celilo.computer":"test456","lunacycle.net":"test789"}',
637
+ '{"iamtheinternet.org":"test123","celilo.computer":"test456","example.net":"test789"}',
623
638
  },
624
639
  volumes: [
625
640
  './config/dns/knot-namecheap.conf:/config/knot.conf:ro',
@@ -630,7 +645,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
630
645
  // namecheap-startup.sh for the full rationale).
631
646
  './config/dns/iamtheinternet.org.zone:/seed/iamtheinternet.org.zone:ro',
632
647
  './config/dns/celilo.computer.zone:/seed/celilo.computer.zone:ro',
633
- './config/dns/lunacycle.net.zone:/seed/lunacycle.net.zone:ro',
648
+ './config/dns/example.net.zone:/seed/example.net.zone:ro',
634
649
  './config/dns/park-your-domain.com.zone:/seed/park-your-domain.com.zone:ro',
635
650
  ],
636
651
  });
package/src/index.ts CHANGED
@@ -37,6 +37,8 @@ export type {
37
37
  ExecResult,
38
38
  MachineSpec,
39
39
  Zone,
40
+ Vantage,
41
+ ObserverSpec,
40
42
  TopologyPreset,
41
43
  ProxyOptions,
42
44
  SocksProxyHandle,
@@ -44,7 +46,32 @@ export type {
44
46
  BrowserHandle,
45
47
  BrowserLauncher,
46
48
  } from './types';
47
- export { CeliloCommandError, ZONE_GATEWAYS, ZONE_SUBNETS } from './types';
49
+ export {
50
+ CeliloCommandError,
51
+ greenwaveRouterIp,
52
+ internalNatIp,
53
+ internalResolverIp,
54
+ publicResolverIp,
55
+ ZONE_GATEWAYS,
56
+ ZONE_SUBNETS,
57
+ } from './types';
58
+
59
+ // Vantage-point network assertions (ISS-0117)
60
+ export { VantageProbe, VantageAssertionError } from './vantage';
61
+ export type {
62
+ ProbeTransport,
63
+ ResolveMethod,
64
+ ResolveResult,
65
+ HttpsResult,
66
+ CertInfo,
67
+ ResolutionInspection,
68
+ ExpectedZone,
69
+ } from './vantage';
70
+ export { createObserverTransport, vantageContainer, OBSERVER_PLACEMENTS } from './observer';
71
+ export { classifyIp, isSegmentedZoneIp, isPublicLeakSafe } from './zone-classifier';
72
+ export type { ZoneClass } from './zone-classifier';
73
+ export { assertFrontedServicePositives, assertFrontedHostnames } from './vantage-helpers';
74
+ export type { FrontedServiceExpectation } from './vantage-helpers';
48
75
 
49
76
  // Common fixtures
50
77
  export {
@@ -2,7 +2,7 @@ import { execSync } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { startNetwork } from './container-manager';
5
- import type { NetworkConfig, NetworkHandle, TopologyPreset } from './types';
5
+ import type { NetworkConfig, NetworkHandle, TopologyPreset, Vantage } from './types';
6
6
 
7
7
  /**
8
8
  * If a mounted path looks like a celilo module (has scripts/package.json),
@@ -39,6 +39,7 @@ export class NetworkBuilder {
39
39
  secureMachines: [],
40
40
  internalMachines: [],
41
41
  dhcpClient: false,
42
+ observers: [],
42
43
  domain: 'iamtheinternet.org',
43
44
  ddnsPassword: 'test123',
44
45
  verifyRouting: process.env.VERIFY_ROUTING === '1',
@@ -104,6 +105,22 @@ export class NetworkBuilder {
104
105
  return this;
105
106
  }
106
107
 
108
+ /**
109
+ * Inject one or more observer vantages (ISS-0117) — passive spies carrying the probe
110
+ * toolbox, each placed at its vantage's network location with a routing profile that
111
+ * mirrors a real device there. Drive them via `createObserverTransport(handle)` +
112
+ * `VantageProbe`. `management` is not injectable (that vantage reuses the real
113
+ * management container).
114
+ *
115
+ * network().dmz({ caddy: '10.0.10.10' }).observe('internalDevice', 'publicInternet')
116
+ */
117
+ observe(...vantages: Vantage[]): this {
118
+ for (const vantage of vantages) {
119
+ (this.config.observers ??= []).push({ vantage });
120
+ }
121
+ return this;
122
+ }
123
+
107
124
  domain(domain: string): this {
108
125
  this.config.domain = domain;
109
126
  return this;
@@ -0,0 +1,155 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ createObserverTransport,
4
+ normalizeObservers,
5
+ OBSERVER_PLACEMENTS,
6
+ observerEnv,
7
+ vantageContainer,
8
+ } from './observer';
9
+ import type { ExecResult, NetworkHandle } from './types';
10
+ import { ZONE_GATEWAYS } from './types';
11
+
12
+ /** A NetworkHandle whose `exec` records calls and returns scripted results. */
13
+ function fakeHandle(script: (container: string, cmd: string) => Partial<ExecResult>) {
14
+ const calls: { container: string; cmd: string }[] = [];
15
+ const exec = (container: string, cmd: string): Promise<ExecResult> => {
16
+ calls.push({ container, cmd });
17
+ return Promise.resolve({ stdout: '', stderr: '', exitCode: 0, ...script(container, cmd) });
18
+ };
19
+ return { handle: { exec } as unknown as NetworkHandle, calls };
20
+ }
21
+
22
+ describe('placement faithfulness (the load-bearing invariants)', () => {
23
+ test('internalDevice routes via the home router, NOT fw-main, and has no inter-zone routes', () => {
24
+ const p = OBSERVER_PLACEMENTS.internalDevice;
25
+ expect(p.network).toBe('internal');
26
+ expect(p.interZoneRoutes).toBe(false);
27
+ // The whole point: default route is the LAN's home router, never ZONE_GATEWAYS.internal
28
+ // (fw-main), which would hand it a path into the segmented zones.
29
+ expect(p.gateway).not.toBe(ZONE_GATEWAYS.internal);
30
+ });
31
+
32
+ test('no injectable observer carries inter-zone routes (only the management trap would)', () => {
33
+ for (const p of Object.values(OBSERVER_PLACEMENTS)) {
34
+ expect(p.interZoneRoutes).toBe(false);
35
+ }
36
+ });
37
+
38
+ test('segmented-zone observer IPs derive from their zone gateway (no pinned subnets)', () => {
39
+ expect(OBSERVER_PLACEMENTS.dmzSystem.gateway).toBe(ZONE_GATEWAYS.dmz);
40
+ expect(OBSERVER_PLACEMENTS.dmzSystem.ip).toBe(ZONE_GATEWAYS.dmz.replace(/\.\d+$/, '.240'));
41
+ expect(OBSERVER_PLACEMENTS.secureSystem.ip).toBe(ZONE_GATEWAYS.secure.replace(/\.\d+$/, '.240'));
42
+ });
43
+
44
+ test('publicInternet uses only the public resolver (never the internal split-horizon view)', () => {
45
+ expect(OBSERVER_PLACEMENTS.publicInternet.resolvers).toEqual(['100.100.0.1']);
46
+ });
47
+
48
+ test('observerEnv serializes the routing profile for the setup script', () => {
49
+ const env = observerEnv(OBSERVER_PLACEMENTS.internalDevice);
50
+ expect(env.OBSERVER_INTERZONE).toBe('0');
51
+ expect(env.OBSERVER_GATEWAY).toBe(OBSERVER_PLACEMENTS.internalDevice.gateway);
52
+ expect(env.OBSERVER_RESOLVERS).toContain('100.100.0.1');
53
+ });
54
+ });
55
+
56
+ describe('normalizeObservers + vantageContainer', () => {
57
+ test('dedupes and rejects management as an injectable observer', () => {
58
+ expect(normalizeObservers([{ vantage: 'internalDevice' }, { vantage: 'internalDevice' }])).toHaveLength(1);
59
+ expect(() => normalizeObservers([{ vantage: 'management' }])).toThrow(/management/);
60
+ });
61
+ test('management vantage maps to the real management container; others to observers', () => {
62
+ expect(vantageContainer('management')).toBe('management');
63
+ expect(vantageContainer('internalDevice')).toBe(OBSERVER_PLACEMENTS.internalDevice.service);
64
+ });
65
+ });
66
+
67
+ describe('observer transport — parsing + container targeting', () => {
68
+ test('resolve(system) parses getent first column and targets the right observer', async () => {
69
+ const { handle, calls } = fakeHandle((_c, cmd) =>
70
+ cmd.startsWith('getent') ? { stdout: '192.168.0.253 www.iamtheinternet.org' } : {},
71
+ );
72
+ const t = createObserverTransport(handle);
73
+ const r = await t.resolve({ from: 'internalDevice', name: 'www.iamtheinternet.org', method: 'system' });
74
+ expect(r.ips).toEqual(['192.168.0.253']);
75
+ expect(calls[0].container).toBe('observer-internal');
76
+ });
77
+
78
+ test('resolve(dns) with explicit server', async () => {
79
+ const { handle, calls } = fakeHandle((_c, cmd) => (cmd.includes('dig') ? { stdout: '10.0.10.10\n' } : {}));
80
+ const t = createObserverTransport(handle);
81
+ const r = await t.resolve({ from: 'dmzSystem', name: 'auth.x', method: 'dns', server: '192.168.0.10' });
82
+ expect(r.ips).toEqual(['10.0.10.10']);
83
+ expect(calls[0].cmd).toContain('@192.168.0.10');
84
+ });
85
+
86
+ test('resolve(hostfiles) parses /etc/hosts first column', async () => {
87
+ const { handle } = fakeHandle((_c, cmd) =>
88
+ cmd.startsWith('grep -F') ? { stdout: '10.0.10.10 auth.iamtheinternet.org' } : {},
89
+ );
90
+ const t = createObserverTransport(handle);
91
+ const r = await t.resolve({ from: 'dmzSystem', name: 'auth.iamtheinternet.org', method: 'hostfiles' });
92
+ expect(r.ips).toEqual(['10.0.10.10']);
93
+ });
94
+
95
+ test('tcpConnect maps the container exit code', async () => {
96
+ const { handle } = fakeHandle((_c, cmd) => ({ exitCode: cmd.includes('443') ? 0 : 1 }));
97
+ const t = createObserverTransport(handle);
98
+ expect((await t.tcpConnect({ from: 'internalDevice', host: '192.168.0.253', port: 443 })).connected).toBe(true);
99
+ expect((await t.tcpConnect({ from: 'internalDevice', host: '10.0.10.10', port: 22 })).connected).toBe(false);
100
+ });
101
+
102
+ test('httpsRequest parses cert (CN/issuer/SAN/selfSigned) + status', async () => {
103
+ const { handle } = fakeHandle((_c, cmd) => {
104
+ if (cmd.includes('openssl s_client')) {
105
+ return {
106
+ stdout:
107
+ 'subject=CN = deathstar.iamtheinternet.org\n' +
108
+ 'issuer=CN = Pebble Intermediate CA 1a2b3c\n' +
109
+ 'X509v3 Subject Alternative Name:\n DNS:deathstar.iamtheinternet.org\n',
110
+ };
111
+ }
112
+ if (cmd.includes('%{http_code}')) return { stdout: '200' };
113
+ return { stdout: 'Caddy reverse proxy is running' };
114
+ });
115
+ const t = createObserverTransport(handle);
116
+ const r = await t.httpsRequest({ from: 'publicInternet', url: 'https://deathstar.iamtheinternet.org' });
117
+ expect(r.status).toBe(200);
118
+ expect(r.cert.cn).toBe('deathstar.iamtheinternet.org');
119
+ expect(r.cert.issuer).toContain('Pebble');
120
+ expect(r.cert.subjectAltNames).toContain('deathstar.iamtheinternet.org');
121
+ expect(r.cert.selfSigned).toBe(false);
122
+ });
123
+
124
+ test('httpsRequest flags a self-signed cert (subject == issuer)', async () => {
125
+ const { handle } = fakeHandle((_c, cmd) =>
126
+ cmd.includes('openssl s_client')
127
+ ? { stdout: 'subject=CN = sketchy.local\nissuer=CN = sketchy.local\n' }
128
+ : cmd.includes('%{http_code}')
129
+ ? { stdout: '200' }
130
+ : {},
131
+ );
132
+ const t = createObserverTransport(handle);
133
+ const r = await t.httpsRequest({ from: 'internalDevice', url: 'https://sketchy.local' });
134
+ expect(r.cert.selfSigned).toBe(true);
135
+ });
136
+ });
137
+
138
+ describe('host-shell footgun guard', () => {
139
+ test('no command contains $(...) or backticks (would be host-evaluated through the wrap)', async () => {
140
+ const { handle, calls } = fakeHandle(() => ({ stdout: '200', exitCode: 0 }));
141
+ const t = createObserverTransport(handle);
142
+ await t.resolve({ from: 'internalDevice', name: 'www.x', method: 'system' });
143
+ await t.resolve({ from: 'internalDevice', name: 'www.x', method: 'dns', server: '192.168.0.10' });
144
+ await t.resolve({ from: 'internalDevice', name: 'www.x', method: 'hostfiles' });
145
+ await t.tcpConnect({ from: 'internalDevice', host: '10.0.10.10', port: 443 });
146
+ await t.ping({ from: 'internalDevice', target: '192.168.0.253' });
147
+ await t.httpsRequest({ from: 'internalDevice', url: 'https://www.x' });
148
+ await t.inspectResolution({ from: 'internalDevice', name: 'www.x' });
149
+ expect(calls.length).toBeGreaterThan(0);
150
+ for (const { cmd } of calls) {
151
+ expect(cmd).not.toContain('$(');
152
+ expect(cmd).not.toContain('`');
153
+ }
154
+ });
155
+ });
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Observer vantages (ISS-0117 §D2/§D4) — the topology side of the vantage framework.
3
+ *
4
+ * An *observer* is a passive spy: a purpose-built container carrying all the probe
5
+ * tooling, injected at a named vantage's network location with a routing profile that
6
+ * mirrors a REAL device there. The point is faithfulness — a vantage's correctness is
7
+ * its routing table, not just which network it attaches to. The management box reaches
8
+ * a DMZ container IP directly only because management-routes.sh adds an explicit
9
+ * inter-zone route; a real LAN device has no such route, so it must go via the firewall
10
+ * natIp. An `internalDevice` observer reproduces the LAN device's routing exactly, which
11
+ * is how it catches the ISS-0101 / ISS-0111 "e2e-green/prod-broke" class.
12
+ *
13
+ * This file holds the single source of truth for each vantage's placement + routing
14
+ * profile (consumed by the compose generator), the vantage->container mapping, and the
15
+ * dockerExec-based ProbeTransport (the developer escape hatch the design retains; the
16
+ * typed probe-agent will wrap this same surface later).
17
+ *
18
+ * Commands sent through dockerExec deliberately avoid `$(...)`, backticks and unescaped
19
+ * `$` — those are evaluated by the HOST shell before reaching the container (the wrapping
20
+ * footgun CLAUDE.md documents). We rely on the container command's exit code (propagated
21
+ * by `docker compose exec`) instead of `echo $?`.
22
+ */
23
+
24
+ import { type NetworkHandle, type ObserverSpec, type Vantage, ZONE_GATEWAYS } from './types';
25
+ import type { CertInfo, ProbeTransport, ResolveMethod } from './vantage';
26
+
27
+ export interface ObserverPlacement {
28
+ /** docker compose service name for this observer */
29
+ service: string;
30
+ /** docker network to attach to */
31
+ network: string;
32
+ /** the observer's own IP in that network */
33
+ ip: string;
34
+ /** default route (gateway) — the load-bearing faithfulness knob */
35
+ gateway: string;
36
+ /** add the management-style explicit inter-zone routes (only the `management` trap) */
37
+ interZoneRoutes: boolean;
38
+ /** nameservers written to /etc/resolv.conf, in order */
39
+ resolvers: string[];
40
+ }
41
+
42
+ // The internal LAN's home router (hands out DHCP option:router) and split-horizon
43
+ // resolver. Literals here match the values the compose generator + dnsmasq already use;
44
+ // they are roles, not pins repeated into tests. ZONE_GATEWAYS.internal (.254) is fw-main
45
+ // — the firewall to the segmented zones — NOT the LAN's default route, which is the home
46
+ // router below. A faithful internalDevice routes its default via the home router and has
47
+ // NO route to the segmented zones, so a dmz/app/secure container IP is unreachable.
48
+ const HOME_ROUTER = '192.168.0.1';
49
+ const INTERNAL_RESOLVER = '192.168.0.10';
50
+ const PUBLIC_RESOLVER = '100.100.0.1';
51
+ const INTERNET_GATEWAY = '100.64.0.1'; // fw-ext, on internet-external
52
+
53
+ /** Derive an observer host IP in a zone from its gateway (no new literal subnets). */
54
+ function observerIpInZone(zone: 'dmz' | 'app' | 'secure'): string {
55
+ return ZONE_GATEWAYS[zone].replace(/\.\d+$/, '.240');
56
+ }
57
+
58
+ /**
59
+ * Placement + routing profile per injectable vantage. `management` is intentionally
60
+ * absent — that vantage reuses the real `management` container (see vantageContainer),
61
+ * so the trap is demonstrated with the actual all-VLAN box, not a copy.
62
+ */
63
+ export const OBSERVER_PLACEMENTS: Record<Exclude<Vantage, 'management'>, ObserverPlacement> = {
64
+ internalDevice: {
65
+ service: 'observer-internal',
66
+ network: 'internal',
67
+ ip: '192.168.0.211', // a LAN host (dhcp range is .200-.220; dhcp-client is .210)
68
+ gateway: HOME_ROUTER,
69
+ interZoneRoutes: false,
70
+ // Identical DNS to the management box on purpose: the only difference vs management
71
+ // is the routing profile, so any reachability gap is provably routing — the bug.
72
+ resolvers: [INTERNAL_RESOLVER, PUBLIC_RESOLVER],
73
+ },
74
+ dmzSystem: {
75
+ service: 'observer-dmz',
76
+ network: 'dmz',
77
+ ip: observerIpInZone('dmz'),
78
+ gateway: ZONE_GATEWAYS.dmz,
79
+ interZoneRoutes: false,
80
+ resolvers: [INTERNAL_RESOLVER, PUBLIC_RESOLVER],
81
+ },
82
+ appSystem: {
83
+ service: 'observer-app',
84
+ network: 'app',
85
+ ip: observerIpInZone('app'),
86
+ gateway: ZONE_GATEWAYS.app,
87
+ interZoneRoutes: false,
88
+ resolvers: [INTERNAL_RESOLVER, PUBLIC_RESOLVER],
89
+ },
90
+ secureSystem: {
91
+ service: 'observer-secure',
92
+ network: 'secure',
93
+ ip: observerIpInZone('secure'),
94
+ gateway: ZONE_GATEWAYS.secure,
95
+ interZoneRoutes: false,
96
+ resolvers: [INTERNAL_RESOLVER, PUBLIC_RESOLVER],
97
+ },
98
+ publicInternet: {
99
+ service: 'observer-public',
100
+ network: 'internet-external',
101
+ ip: '100.64.0.240',
102
+ gateway: INTERNET_GATEWAY,
103
+ interZoneRoutes: false,
104
+ // Outside the firewall: only the public resolver, never the internal split-horizon view.
105
+ resolvers: [PUBLIC_RESOLVER],
106
+ },
107
+ };
108
+
109
+ /** The placement for an injectable vantage; throws for `management` (not injectable). */
110
+ export function observerPlacement(vantage: Vantage): ObserverPlacement {
111
+ if (vantage === 'management') {
112
+ throw new Error('management has no observer placement — it reuses the real management container.');
113
+ }
114
+ return OBSERVER_PLACEMENTS[vantage];
115
+ }
116
+
117
+ /** The container a vantage probes from. `management` reuses the real management box. */
118
+ export function vantageContainer(vantage: Vantage): string {
119
+ if (vantage === 'management') return 'management';
120
+ return OBSERVER_PLACEMENTS[vantage].service;
121
+ }
122
+
123
+ /** Environment a generated observer service needs for its routing-profile setup script. */
124
+ export function observerEnv(placement: ObserverPlacement): Record<string, string> {
125
+ return {
126
+ OBSERVER_GATEWAY: placement.gateway,
127
+ OBSERVER_INTERZONE: placement.interZoneRoutes ? '1' : '0',
128
+ OBSERVER_RESOLVERS: placement.resolvers.join(' '),
129
+ };
130
+ }
131
+
132
+ /** Dedupe + validate a list of observer specs (a vantage can't be injected twice). */
133
+ export function normalizeObservers(observers: ObserverSpec[]): ObserverSpec[] {
134
+ const seen = new Set<Vantage>();
135
+ const out: ObserverSpec[] = [];
136
+ for (const spec of observers) {
137
+ if (spec.vantage === 'management') {
138
+ throw new Error(
139
+ 'management is not an injectable observer — that vantage reuses the real management container.',
140
+ );
141
+ }
142
+ if (seen.has(spec.vantage)) continue;
143
+ seen.add(spec.vantage);
144
+ out.push(spec);
145
+ }
146
+ return out;
147
+ }
148
+
149
+ // --- dockerExec-based ProbeTransport (escape hatch; substitution-free commands) ---
150
+
151
+ const IPV4 = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
152
+
153
+ /**
154
+ * The first whitespace token of each line, kept if it's an IPv4. Works for all three
155
+ * resolve methods without shell-side column extraction (which would need `$1`/awk — a
156
+ * host-shell-expansion footgun): `dig +short` lines ARE the IP; `getent hosts` and
157
+ * `/etc/hosts` lines are "IP name…", so token[0] is the IP either way.
158
+ */
159
+ function firstColumnIps(stdout: string): string[] {
160
+ return stdout
161
+ .split('\n')
162
+ .map((l) => l.trim().split(/\s+/)[0] ?? '')
163
+ .filter((tok) => IPV4.test(tok));
164
+ }
165
+
166
+ function hostPort(url: string): { host: string; port: number } {
167
+ const u = new URL(url);
168
+ return { host: u.hostname, port: u.port ? Number(u.port) : u.protocol === 'https:' ? 443 : 80 };
169
+ }
170
+
171
+ function parseCert(opensslOut: string): CertInfo {
172
+ const subjectLine = opensslOut.match(/^subject=(.*)$/m)?.[1]?.trim() ?? '';
173
+ const issuerLine = opensslOut.match(/^issuer=(.*)$/m)?.[1]?.trim() ?? '';
174
+ const cn = subjectLine.match(/CN\s*=\s*([^,/]+)/)?.[1]?.trim() ?? '';
175
+ const issuerCn = issuerLine.match(/CN\s*=\s*([^,/]+)/)?.[1]?.trim() ?? issuerLine;
176
+ const sanLine = opensslOut.match(/DNS:[^\n]*/)?.[0] ?? '';
177
+ const subjectAltNames = sanLine
178
+ .split(',')
179
+ .map((s) => s.replace(/.*DNS:/, '').trim())
180
+ .filter(Boolean);
181
+ return {
182
+ cn,
183
+ issuer: issuerCn,
184
+ subjectAltNames,
185
+ // Pebble's leaf issuer differs from the leaf subject; equal subject==issuer ⇒ self-signed.
186
+ selfSigned: subjectLine !== '' && subjectLine === issuerLine,
187
+ };
188
+ }
189
+
190
+ /**
191
+ * A ProbeTransport that runs each verb's probe inside the vantage's observer container
192
+ * via `handle.exec`. Returns typed results; raw dockerExec stays available on `handle`.
193
+ */
194
+ export function createObserverTransport(handle: NetworkHandle): ProbeTransport {
195
+ return {
196
+ async resolve(opts) {
197
+ const c = vantageContainer(opts.from);
198
+ const cmd = resolveCommand(opts.name, opts.method, opts.server);
199
+ const res = await handle.exec(c, cmd, 15_000);
200
+ return { ips: firstColumnIps(res.stdout) };
201
+ },
202
+
203
+ async tcpConnect(opts) {
204
+ const c = vantageContainer(opts.from);
205
+ // bash's /dev/tcp builtin, not `nc`: the management box (a valid vantage) ships no
206
+ // netcat, whereas /dev/tcp works on every vantage. `timeout` bounds a hung connect.
207
+ const res = await handle.exec(
208
+ c,
209
+ `timeout 4 bash -c 'exec 3<>/dev/tcp/${opts.host}/${opts.port}'`,
210
+ 8_000,
211
+ );
212
+ return { connected: res.exitCode === 0, detail: res.exitCode === 0 ? undefined : 'connect failed/timed out' };
213
+ },
214
+
215
+ async ping(opts) {
216
+ const c = vantageContainer(opts.from);
217
+ const res = await handle.exec(c, `ping -c 1 -W 2 ${opts.target}`, 6_000);
218
+ return { alive: res.exitCode === 0 };
219
+ },
220
+
221
+ async httpsRequest(opts) {
222
+ const c = vantageContainer(opts.from);
223
+ const { host, port } = hostPort(opts.url);
224
+ const certRes = await handle.exec(
225
+ c,
226
+ `echo | openssl s_client -connect ${host}:${port} -servername ${host} 2>/dev/null | ` +
227
+ 'openssl x509 -noout -subject -issuer -ext subjectAltName',
228
+ 10_000,
229
+ );
230
+ // -k gathers the status even for an untrusted chain; the cert is reported truthfully
231
+ // above so the verb layer asserts provenance separately (this is fact-gathering, not
232
+ // validation-skipping — the no-insecure rule is enforced by the verb, not relaxed here).
233
+ const statusRes = await handle.exec(
234
+ c,
235
+ `curl -sk -o /dev/null -w '%{http_code}' --max-time 8 ${opts.url}`,
236
+ 10_000,
237
+ );
238
+ const bodyRes = await handle.exec(c, `curl -sk --max-time 8 ${opts.url}`, 10_000);
239
+ return {
240
+ status: Number.parseInt(statusRes.stdout.trim(), 10) || 0,
241
+ body: bodyRes.stdout,
242
+ cert: parseCert(certRes.stdout),
243
+ };
244
+ },
245
+
246
+ async inspectResolution(opts) {
247
+ const c = vantageContainer(opts.from);
248
+ const resolv = await handle.exec(c, 'cat /etc/resolv.conf', 5_000);
249
+ const hosts = await handle.exec(c, `grep -F ${opts.name} /etc/hosts`, 5_000);
250
+ return {
251
+ resolvConf: resolv.stdout.trim(),
252
+ hostsLines: hosts.stdout.split('\n').map((l) => l.trim()).filter(Boolean),
253
+ };
254
+ },
255
+ };
256
+ }
257
+
258
+ function resolveCommand(name: string, method: ResolveMethod, server?: string): string {
259
+ switch (method) {
260
+ case 'dns':
261
+ // DNS only (ignores /etc/hosts); optionally a specific resolver.
262
+ return `dig +short A ${name}${server ? ` @${server}` : ''}`;
263
+ case 'hostfiles':
264
+ // The /etc/hosts layer only (the host-pin path — ISS-0095). Raw; parsed host-side.
265
+ return `grep -F ${name} /etc/hosts`;
266
+ default:
267
+ // system: the real stack — nsswitch consults files AND dns. getent prints "IP name...".
268
+ return `getent hosts ${name}`;
269
+ }
270
+ }
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { execSync } from 'node:child_process';
14
14
  import { join } from 'node:path';
15
- import { writeFileSync } from 'node:fs';
15
+ import { copyFileSync, existsSync, rmSync, statSync, writeFileSync } from 'node:fs';
16
16
  import { generateSharedInfraYaml, SHARED_PROJECT_NAME, SHARED_NETWORKS } from './docker-compose-generator';
17
17
  import { ensureRegistryServerBundle } from './registry-bundle';
18
18
 
@@ -146,6 +146,23 @@ export async function ensureSharedInfra(): Promise<void> {
146
146
  const yaml = generateSharedInfraYaml();
147
147
  writeFileSync(join(e2eDir, SHARED_COMPOSE_FILE), yaml);
148
148
 
149
+ // Seed the live DNS zone files from their templates BEFORE the compose
150
+ // mounts them. config/dns/{iamtheinternet.org,example.net}.zone are
151
+ // gitignored runtime state (scrubDnsZones rewrites them per-test); on a
152
+ // fresh checkout they don't exist yet, and a Docker bind-mount of a missing
153
+ // host path silently creates an empty DIRECTORY — so Knot serves no zone and
154
+ // DNS convergence times out. Seeding here makes the bind-mount targets real
155
+ // files, so shared infra bootstraps from a clean checkout (e.g. CI builder).
156
+ for (const zone of ['iamtheinternet.org', 'example.net']) {
157
+ const tpl = join(e2eDir, 'config', 'dns', 'templates', `${zone}.zone`);
158
+ const live = join(e2eDir, 'config', 'dns', `${zone}.zone`);
159
+ // Remove an empty dir left by a prior bind-mount on a missing path.
160
+ if (existsSync(live) && statSync(live).isDirectory()) {
161
+ rmSync(live, { recursive: true, force: true });
162
+ }
163
+ copyFileSync(tpl, live);
164
+ }
165
+
149
166
  // Build and start
150
167
  console.log('[progress:start] building shared infrastructure images | shared images built');
151
168
  run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} build`,
package/src/types.ts CHANGED
@@ -1,5 +1,25 @@
1
1
  export type Zone = 'dmz' | 'app' | 'secure' | 'internal';
2
2
 
3
+ /**
4
+ * Where a network assertion is made FROM — named by the box's network location
5
+ * (ISS-0117 §D4). A vantage is realized in the topology by an `observer` container
6
+ * (passive spy with all probe tooling) placed at that location with a routing profile
7
+ * that mirrors a real device there. `from` is always explicit — there is no default
8
+ * vantage, so adding one can't silently change an existing test.
9
+ */
10
+ export type Vantage =
11
+ | 'internalDevice' // unmanaged device on the internal/Home LAN; NO route into dmz/app/secure (the real end-user)
12
+ | 'dmzSystem'
13
+ | 'appSystem'
14
+ | 'secureSystem'
15
+ | 'publicInternet' // outside the firewall — what the real internet sees
16
+ | 'management'; // celilo-mgmt box; all-VLAN routes (the trap — explicit, rare)
17
+
18
+ /** A request to inject an observer at a vantage (passive spy with controlled routing). */
19
+ export interface ObserverSpec {
20
+ vantage: Vantage;
21
+ }
22
+
3
23
  export interface MachineSpec {
4
24
  name: string;
5
25
  ip: string;
@@ -25,6 +45,13 @@ export interface NetworkConfig {
25
45
  managementVolumes: string[];
26
46
  /** Include a DHCP client container on the internal network */
27
47
  dhcpClient: boolean;
48
+ /**
49
+ * Observer vantages to inject into the topology. Each becomes an `observer-*`
50
+ * container — a passive spy carrying the probe tooling, placed at the named
51
+ * vantage's network location with a routing profile that mirrors a real device
52
+ * there (ISS-0117). Drive them via the VantageProbe transport.
53
+ */
54
+ observers?: ObserverSpec[];
28
55
  /**
29
56
  * Absolute path to the Celilo project root (the directory containing apps/celilo/).
30
57
  * Mounted into the management container at /celilo so the CLI is available.
@@ -330,3 +357,34 @@ export const ZONE_SUBNETS: Record<Zone, string> = {
330
357
  secure: '10.0.30.0/24',
331
358
  internal: '192.168.0.0/24',
332
359
  };
360
+
361
+ /**
362
+ * The firewall's internal-side DNAT ingress (the natIp) in the e2e topology — the role by
363
+ * which an internal device reaches a service that lives in a segmented zone. Single source
364
+ * of truth: `deployFirewall` uses it as its default and tests assert against it, so the
365
+ * asserted natIp is by construction the one that was deployed. Reference this instead of
366
+ * hardcoding the address in a test.
367
+ */
368
+ export function internalNatIp(): string {
369
+ return '192.168.0.253';
370
+ }
371
+
372
+ /**
373
+ * The internal network's internet-facing gateway router (the greenwave / fw-isp sim) — the
374
+ * default route for internal devices toward the internet. Distinct from
375
+ * `ZONE_GATEWAYS.internal` (fw-main), which is the firewall to the segmented zones. Reference
376
+ * this instead of hardcoding the address in a test.
377
+ */
378
+ export function greenwaveRouterIp(): string {
379
+ return '192.168.0.1';
380
+ }
381
+
382
+ /** The internal split-horizon DNS resolver (dns-int) in the e2e topology. */
383
+ export function internalResolverIp(): string {
384
+ return '192.168.0.10';
385
+ }
386
+
387
+ /** The public upstream recursive resolver (the comcast-resolver sim). */
388
+ export function publicResolverIp(): string {
389
+ return '100.100.0.1';
390
+ }