@celilo/e2e 0.7.14 → 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.14",
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.6",
40
+ "@celilo/event-bus": "^0.1.7",
41
41
  "yaml": "^2.8.0",
42
42
  "zod": "^3.24.1"
43
43
  },
@@ -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;
@@ -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
+ });