@celilo/e2e 0.7.13 → 0.7.15

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.
@@ -10,8 +10,8 @@ zone:
10
10
  file: "/config/celilo.computer.zone"
11
11
  storage: "/var/lib/knot"
12
12
 
13
- - domain: "lunacycle.net"
14
- file: "/config/lunacycle.net.zone"
13
+ - domain: "example.net"
14
+ file: "/config/example.net.zone"
15
15
  storage: "/var/lib/knot"
16
16
 
17
17
  - domain: "park-your-domain.com"
@@ -7,5 +7,5 @@ $TTL 86400
7
7
 
8
8
  net. IN NS tld-dns.lab.
9
9
 
10
- lunacycle IN NS ns1.lunacycle.net.
11
- ns1.lunacycle.net. IN A 100.64.0.55
10
+ example IN NS ns1.example.net.
11
+ ns1.example.net. IN A 100.64.0.55
@@ -1,7 +1,7 @@
1
- $ORIGIN lunacycle.net.
1
+ $ORIGIN example.net.
2
2
  $TTL 300
3
3
 
4
- @ IN SOA ns1.lunacycle.net. admin.lunacycle.net. (
4
+ @ IN SOA ns1.example.net. admin.example.net. (
5
5
  2024020108 300 60 604800 300
6
6
  )
7
7
 
@@ -10,7 +10,7 @@ $TTL 300
10
10
  ; private IPs are unreachable from outside. All public-facing names
11
11
  ; resolve to the firewall's external IP (100.100.0.100), which DNATs
12
12
  ; inbound to the right internal host.
13
- @ IN NS ns1.lunacycle.net.
13
+ @ IN NS ns1.example.net.
14
14
  ns1 IN A 100.64.0.55
15
15
  @ IN A 100.100.0.100
16
16
  www IN A 100.100.0.100
@@ -34,11 +34,11 @@ stub-zone:
34
34
  stub-addr: 100.64.0.55
35
35
 
36
36
  stub-zone:
37
- name: "lunacycle.net."
37
+ name: "example.net."
38
38
  stub-addr: 100.64.0.55
39
39
 
40
40
  # celilo.computer hosts the website + install.sh (served by the website-sim).
41
- # Without this stub it falls through to the real internet (71.36.115.155), so the
41
+ # Without this stub it falls through to the real internet (203.0.113.11), so the
42
42
  # website-sim's ACME challenge can't be validated in-topology and the bake's
43
43
  # `curl https://celilo.computer/install.sh` never sees a cert. Covers
44
44
  # www.celilo.computer too. (ISS-0063)
@@ -0,0 +1,60 @@
1
+ #!/bin/bash
2
+ # Observer vantage setup (ISS-0117).
3
+ #
4
+ # Installs the routing profile that makes this container a FAITHFUL vantage — the
5
+ # load-bearing part of the framework. A vantage's correctness is its routing table:
6
+ # an internalDevice must reach a fronted service only via the firewall natIp, never a
7
+ # segmented-zone container IP directly. Driven by env from the compose generator:
8
+ # OBSERVER_GATEWAY - default route (the home router for internalDevice, the zone
9
+ # firewall for dmz/app/secure, fw-ext for publicInternet)
10
+ # OBSERVER_INTERZONE - "1" to add the management-style explicit inter-zone routes
11
+ # (the all-VLAN trap). "0" for every faithful consumer vantage.
12
+ # OBSERVER_RESOLVERS - space-separated nameservers for /etc/resolv.conf, in order
13
+ set -u
14
+
15
+ GATEWAY="${OBSERVER_GATEWAY:?OBSERVER_GATEWAY is required}"
16
+ INTERZONE="${OBSERVER_INTERZONE:-0}"
17
+ RESOLVERS="${OBSERVER_RESOLVERS:-100.100.0.1}"
18
+
19
+ echo "Observer starting: gateway=${GATEWAY} interzone=${INTERZONE} resolvers='${RESOLVERS}'"
20
+
21
+ # --- Default route ---
22
+ # Docker seeds a default via the network's own gateway; replace it with the gateway a
23
+ # real device at this location would use.
24
+ ip route del default 2>/dev/null || true
25
+ ip route add default via "${GATEWAY}"
26
+
27
+ # --- Inter-zone routes (the management trap; only when explicitly requested) ---
28
+ # A real consumer vantage NEVER has these — that is the whole point. Present only so a
29
+ # `management`-style all-VLAN box can be modelled deliberately.
30
+ if [ "${INTERZONE}" = "1" ]; then
31
+ ip route add 10.0.10.0/24 via 192.168.0.254 2>/dev/null || true # dmz via fw-main
32
+ ip route add 10.0.20.0/24 via 192.168.0.254 2>/dev/null || true # app via fw-main
33
+ ip route add 10.0.30.0/24 via 192.168.0.254 2>/dev/null || true # secure via fw-main
34
+ fi
35
+
36
+ # --- Resolver ---
37
+ : > /etc/resolv.conf
38
+ for ns in ${RESOLVERS}; do
39
+ echo "nameserver ${ns}" >> /etc/resolv.conf
40
+ done
41
+
42
+ # --- Pebble runtime ACME root CA ---
43
+ # Pebble mints a fresh root each startup, so the build-time CA can't verify certs Caddy
44
+ # obtained at runtime. Fetch the live root so curl/openssl can validate real chains —
45
+ # the no-self-signed / CA-provenance assertions depend on a correct trust store.
46
+ echo "Fetching Pebble ACME root CA..."
47
+ for _ in $(seq 1 30); do
48
+ if curl -sk https://100.64.0.100:15000/roots/0 -o /usr/local/share/ca-certificates/pebble-acme-root.crt 2>/dev/null; then
49
+ if [ -s /usr/local/share/ca-certificates/pebble-acme-root.crt ]; then
50
+ update-ca-certificates 2>/dev/null
51
+ echo "Pebble ACME root CA installed"
52
+ break
53
+ fi
54
+ fi
55
+ sleep 2
56
+ done
57
+
58
+ echo "Observer ready: $(hostname)"
59
+ ip route
60
+ sleep infinity
@@ -9,9 +9,9 @@ CADDY_IP="${CADDY_IP:-10.0.10.10}"
9
9
  # DOMAINS: comma-separated list of e2e test domains that should be
10
10
  # split-horizon redirected to caddy. Default covers the canonical
11
11
  # (iamtheinternet.org) plus the cross-domain test's second domain
12
- # (lunacycle.net). Tests can override via DEFAULT_GATEWAY/DOMAINS env
12
+ # (example.net). Tests can override via DEFAULT_GATEWAY/DOMAINS env
13
13
  # vars in docker-compose.
14
- DOMAINS="${DOMAINS:-iamtheinternet.org,lunacycle.net}"
14
+ DOMAINS="${DOMAINS:-iamtheinternet.org,example.net}"
15
15
 
16
16
  # Routing — reach comcast-resolver (100.100.0.1) via fw-main or fw-isp.
17
17
  # DEFAULT_GATEWAY is set by docker-compose from the topology config.
@@ -0,0 +1,26 @@
1
+ # Observer — a passive vantage-point spy (ISS-0117).
2
+ #
3
+ # Carries the full probe toolbox (dig, curl, openssl, nc, ping, ip) so a single image
4
+ # can be injected at any network location and assert resolve/reach/TLS from that seat.
5
+ # It runs NO services and deploys NO modules — it never perturbs the system under test.
6
+ # Its faithfulness as a vantage comes from the routing profile installed at startup
7
+ # (see config/routing/observer-setup.sh), driven by the OBSERVER_* env the compose
8
+ # generator sets per vantage.
9
+ FROM ubuntu:22.04
10
+
11
+ ENV DEBIAN_FRONTEND=noninteractive
12
+
13
+ RUN apt-get update && apt-get install -y \
14
+ dnsutils \
15
+ curl \
16
+ openssl \
17
+ netcat-openbsd \
18
+ iputils-ping \
19
+ iproute2 \
20
+ ca-certificates \
21
+ && rm -rf /var/lib/apt/lists/*
22
+
23
+ COPY config/routing/observer-setup.sh /startup.sh
24
+ RUN chmod +x /startup.sh
25
+
26
+ CMD ["/startup.sh"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/e2e",
3
- "version": "0.7.13",
3
+ "version": "0.7.15",
4
4
  "description": "E2E test infrastructure for Celilo-deployed applications. Provides a simulated internet with DNS hierarchy, ACME server, firewalls, and target machines in Docker.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -37,7 +37,7 @@
37
37
  ],
38
38
  "dependencies": {
39
39
  "@celilo/cli-display": "^0.1.9",
40
- "@celilo/event-bus": "^0.1.5",
40
+ "@celilo/event-bus": "^0.1.7",
41
41
  "yaml": "^2.8.0",
42
42
  "zod": "^3.24.1"
43
43
  },
@@ -23,15 +23,21 @@ export interface FlagDef {
23
23
  export const COMMANDS: CommandDef[] = [
24
24
  {
25
25
  name: 'run',
26
- description: 'Run e2e tests — pass a module path or pattern, or --all-modules',
26
+ description: 'Run e2e tests — pass a module path or pattern, or --all / --all-modules',
27
27
  flags: [
28
+ { name: '--all', description: 'Run the full regression: every module suite + top-level e2e/tests/' },
29
+ { name: '--all-modules', description: 'Run e2e tests for all modules with e2e/ directories' },
28
30
  { name: '--keep', description: 'Keep network running after tests' },
29
31
  { name: '--reuse', description: 'Reuse existing network if running' },
30
32
  { name: '--live', description: 'Use live (non-simulated) internet' },
31
33
  { name: '--published', description: 'Use published .netapp packages' },
32
- { name: '--all-modules', description: 'Run e2e tests for all modules with e2e/ directories' },
33
34
  ],
34
35
  },
36
+ {
37
+ name: 'list',
38
+ description: 'List every discoverable Docker e2e test (module + top-level)',
39
+ flags: [{ name: '--paths', description: 'Also show each test file path' }],
40
+ },
35
41
  {
36
42
  name: 'up',
37
43
  description: 'Start interactive test network',
@@ -93,7 +99,7 @@ export const COMMANDS: CommandDef[] = [
93
99
 
94
100
  export const UP_PRESETS = ['--caddy', '--full-stack', '--infrastructure'];
95
101
 
96
- export const RUN_FLAGS = ['--keep', '--reuse', '--live', '--published', '--all-modules'];
102
+ export const RUN_FLAGS = ['--all', '--all-modules', '--keep', '--reuse', '--live', '--published'];
97
103
 
98
104
  export const DOWN_FLAGS = ['--keep', '--all'];
99
105
 
@@ -56,6 +56,11 @@ export function getCompletions(words: string[], current: number): string[] {
56
56
  );
57
57
  }
58
58
 
59
+ if (command === 'list' && currentIndex >= 1) {
60
+ const used = new Set(args.slice(1));
61
+ return filterSuggestions(['--paths'].filter((f) => !used.has(f)), args[currentIndex] || '');
62
+ }
63
+
59
64
  if (command === 'completion' && currentIndex === 1) {
60
65
  return filterSuggestions(['zsh', 'bash'], args[1] || '');
61
66
  }
@@ -108,13 +113,17 @@ _cele2e() {
108
113
  case $line[1] in
109
114
  run)
110
115
  _arguments \\
116
+ '--all[Run the full regression: every module suite + top-level e2e/tests/]' \\
117
+ '--all-modules[Run e2e tests for all modules with e2e/ directories]' \\
111
118
  '--keep[Keep network running after tests]' \\
112
119
  '--reuse[Reuse existing network if running]' \\
113
120
  '--live[Use live (non-simulated) internet]' \\
114
121
  '--published[Use published .netapp packages]' \\
115
- '--all-modules[Run e2e tests for all modules with e2e/ directories]' \\
116
122
  '*::test name or module path:_cele2e_run_args'
117
123
  ;;
124
+ list)
125
+ _arguments '--paths[Also show each test file path]'
126
+ ;;
118
127
  up)
119
128
  _arguments '1: :_cele2e_up_presets'
120
129
  ;;
@@ -137,7 +146,7 @@ _cele2e() {
137
146
  completion)
138
147
  _arguments '1: :_cele2e_completion_shells'
139
148
  ;;
140
- status|load|clear-timing)
149
+ status|load|clear-timing|list)
141
150
  ;;
142
151
  esac
143
152
  ;;
@@ -147,6 +156,7 @@ _cele2e() {
147
156
  _cele2e_commands() {
148
157
  local commands=(
149
158
  'run:Run e2e tests (pattern filters by test file name)'
159
+ 'list:List every discoverable Docker e2e test (module + top-level)'
150
160
  'up:Start interactive test network'
151
161
  'down:Tear down running network'
152
162
  'build-infra:Rebuild @celilo/e2e Docker images and standard module .netapps'
package/src/cli/index.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  import { spawnSync } from 'node:child_process';
19
- import { existsSync, readFileSync, rmSync } from 'node:fs';
19
+ import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
20
20
  import { basename, dirname, join, resolve } from 'node:path';
21
21
  import { parse as parseYaml } from 'yaml';
22
22
  import { runBuild } from './build';
@@ -55,6 +55,53 @@ function detectTestDir(): { stateDir: string; testsPath: string } {
55
55
  return { stateDir: cwd, testsPath: join(cwd, 'tests') };
56
56
  }
57
57
 
58
+ /**
59
+ * Resolve the repo root to scan for the full e2e suite (`run --all` / `list`).
60
+ *
61
+ * The workspace `./cele2e` wrapper cd's into packages/e2e before exec'ing, so
62
+ * process.cwd() points at the e2e package — useless for module discovery. The
63
+ * wrapper exports E2E_INVOKE_CWD with the operator's real PWD; we walk up from
64
+ * there (or process.cwd() as a fallback) to the nearest ancestor that has a
65
+ * `modules/` directory, which is the infra repo root. This is what makes the
66
+ * full-regression command immune to the wrapper's internal cd (ISS-0013).
67
+ */
68
+ function resolveSuiteRoot(): string {
69
+ const start = resolve(process.env.E2E_INVOKE_CWD || process.cwd());
70
+ let dir = start;
71
+ for (;;) {
72
+ if (existsSync(join(dir, 'modules'))) return dir;
73
+ const parent = dirname(dir);
74
+ if (parent === dir) break;
75
+ dir = parent;
76
+ }
77
+ return start;
78
+ }
79
+
80
+ /**
81
+ * Enumerate every discoverable Docker e2e test (all module suites + the repo's
82
+ * top-level e2e/tests/) as `{ name, file }`, sorted, with module tests first.
83
+ */
84
+ function discoverSuiteTests(suiteRoot: string): { name: string; file: string; group: string }[] {
85
+ const out: { name: string; file: string; group: string }[] = [];
86
+ for (const moduleDir of findAllModules(suiteRoot)) {
87
+ const tp = join(moduleDir, 'e2e');
88
+ const testsDir = existsSync(join(moduleDir, 'manifest.yml'))
89
+ ? resolve(moduleDir, readManifestTestsDir(join(moduleDir, 'manifest.yml')) ?? 'e2e')
90
+ : tp;
91
+ if (!existsSync(testsDir)) continue;
92
+ for (const f of readdirSync(testsDir).filter((f) => f.endsWith('.test.ts')).sort()) {
93
+ out.push({ name: f.replace(/\.test\.ts$/, ''), file: join(testsDir, f), group: basename(moduleDir) });
94
+ }
95
+ }
96
+ const topLevel = join(suiteRoot, 'e2e', 'tests');
97
+ if (existsSync(topLevel)) {
98
+ for (const f of readdirSync(topLevel).filter((f) => f.endsWith('.test.ts')).sort()) {
99
+ out.push({ name: f.replace(/\.test\.ts$/, ''), file: join(topLevel, f), group: '(top-level)' });
100
+ }
101
+ }
102
+ return out;
103
+ }
104
+
58
105
  function runScript(script: string, args: string[], env?: Record<string, string>): never {
59
106
  const result = spawnSync(join(BIN_DIR, script), args, {
60
107
  stdio: 'inherit',
@@ -72,6 +119,8 @@ Usage:
72
119
 
73
120
  Commands:
74
121
  run [pattern] Run e2e tests (pattern filters by test file name)
122
+ run --all Run the FULL regression: every module suite + top-level e2e/tests/
123
+ list List every discoverable Docker e2e test (module + top-level)
75
124
  up [preset] Start interactive test network
76
125
  down Tear down running network
77
126
  build-infra [dirs...] Rebuild Docker images and package all modules as .netapps
@@ -84,6 +133,8 @@ Commands:
84
133
  completion <shell> Generate shell completion script (zsh|bash)
85
134
 
86
135
  Options for \`run\`:
136
+ --all Run every module suite AND the top-level e2e/tests/ (full regression)
137
+ --all-modules Run every module suite (no top-level tests)
87
138
  --keep Keep network running after tests
88
139
  --reuse Reuse existing network if running
89
140
  --live Use live (non-simulated) internet
@@ -103,6 +154,8 @@ Options for \`build-infra\`:
103
154
  --skip-modules Skip .netapp packaging (only rebuild Docker images)
104
155
 
105
156
  Examples:
157
+ cele2e run --all # FULL regression: every module suite + top-level e2e/tests/
158
+ cele2e list # enumerate every discoverable Docker e2e test
106
159
  cele2e run # in a module: run its tests; outside: run all modules' tests
107
160
  cele2e run deploy # run tests matching "deploy"
108
161
  cele2e run caddy-direct-internet # from modules/, finds the matching module automatically
@@ -130,6 +183,34 @@ if (command === '--get-completions') {
130
183
 
131
184
  switch (command) {
132
185
  case 'run': {
186
+ // Full-suite modes (ISS-0013). `--all` runs every module suite AND the
187
+ // repo's top-level e2e/tests/ in one invocation; `--all-modules` runs just
188
+ // the module suites. Both resolve the repo root via resolveSuiteRoot() so
189
+ // they work through the `./cele2e` wrapper's cd into packages/e2e.
190
+ if (args.includes('--all') || args.includes('--all-modules')) {
191
+ const wantTopLevel = args.includes('--all');
192
+ const suiteRoot = resolveSuiteRoot();
193
+ const allModules = findAllModules(suiteRoot);
194
+ const env: Record<string, string> = {
195
+ E2E_TEST_DIR: join(suiteRoot, 'e2e'),
196
+ E2E_MODULE_DIRS: allModules.join('\n'),
197
+ };
198
+ const topLevel = join(suiteRoot, 'e2e', 'tests');
199
+ if (wantTopLevel && existsSync(topLevel)) {
200
+ env.E2E_TOP_LEVEL_TESTS = topLevel;
201
+ }
202
+ if (allModules.length === 0 && !env.E2E_TOP_LEVEL_TESTS) {
203
+ console.error(`No e2e tests found under ${suiteRoot}`);
204
+ console.error('Run from the infra repo root (or set E2E_INVOKE_CWD to it).');
205
+ process.exit(1);
206
+ }
207
+ runScript(
208
+ 'e2e-run',
209
+ args.filter((a) => a !== '--all' && a !== '--all-modules'),
210
+ env,
211
+ );
212
+ }
213
+
133
214
  // Check if any non-flag arg is a module path (has manifest.yml)
134
215
  const modulePath = args
135
216
  .filter((a) => !a.startsWith('--'))
@@ -180,6 +261,29 @@ switch (command) {
180
261
  break;
181
262
  }
182
263
 
264
+ case 'list': {
265
+ // Enumerate every discoverable Docker e2e test (module + top-level) so the
266
+ // suite is inspectable without reading runner.ts (ISS-0013).
267
+ const suiteRoot = resolveSuiteRoot();
268
+ const tests = discoverSuiteTests(suiteRoot);
269
+ if (tests.length === 0) {
270
+ console.error(`No e2e tests found under ${suiteRoot}`);
271
+ console.error('Run from the infra repo root (or set E2E_INVOKE_CWD to it).');
272
+ process.exit(1);
273
+ }
274
+ const wantPaths = args.includes('--paths');
275
+ let lastGroup = '';
276
+ for (const t of tests) {
277
+ if (t.group !== lastGroup) {
278
+ console.log(`\n${t.group}:`);
279
+ lastGroup = t.group;
280
+ }
281
+ console.log(wantPaths ? ` ${t.name} ${t.file}` : ` ${t.name}`);
282
+ }
283
+ console.log(`\n${tests.length} tests. Run all: cele2e run --all`);
284
+ process.exit(0);
285
+ }
286
+
183
287
  case 'up':
184
288
  runScript('e2e-up', args);
185
289
 
@@ -16,7 +16,7 @@ import type {
16
16
  ProxyOptions,
17
17
  SocksProxyHandle,
18
18
  } from './types';
19
- import { CeliloCommandError, ZONE_GATEWAYS, ZONE_SUBNETS } from './types';
19
+ import { CeliloCommandError, internalNatIp, ZONE_GATEWAYS, ZONE_SUBNETS } from './types';
20
20
 
21
21
  /** Package root — where docker/, config/, simulators/ live */
22
22
  const PACKAGE_ROOT = join(__dirname, '..');
@@ -137,7 +137,7 @@ const SHARED_CONTAINERS = new Set([
137
137
  * set (useful for --keep debugging).
138
138
  */
139
139
  async function scrubDnsZones(): Promise<void> {
140
- const zones = ['iamtheinternet.org', 'lunacycle.net'];
140
+ const zones = ['iamtheinternet.org', 'example.net'];
141
141
  const composeDir = PACKAGE_ROOT;
142
142
 
143
143
  for (const zone of zones) {
@@ -675,7 +675,7 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
675
675
  async deployFirewall(opts = {}): Promise<void> {
676
676
  const zones = opts.zones ?? ['dmz', 'app', 'secure'];
677
677
  const firewallIp = ZONE_GATEWAYS.internal; // fw-main on internal
678
- const natIp = opts.natIp ?? '192.168.0.253';
678
+ const natIp = opts.natIp ?? internalNatIp();
679
679
  const providedNetworks = zones.map((zone) => ({
680
680
  zone,
681
681
  subnet: ZONE_SUBNETS[zone],
@@ -950,7 +950,7 @@ export function reconnectNetwork(projectName: string): NetworkHandle {
950
950
  async deployFirewall(opts = {}): Promise<void> {
951
951
  const zones = opts.zones ?? ['dmz', 'app', 'secure'];
952
952
  const firewallIp = ZONE_GATEWAYS.internal; // fw-main on internal
953
- const natIp = opts.natIp ?? '192.168.0.253';
953
+ const natIp = opts.natIp ?? internalNatIp();
954
954
  const providedNetworks = zones.map((zone) => ({
955
955
  zone,
956
956
  subnet: ZONE_SUBNETS[zone],
@@ -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;