@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.
- package/config/dns/knot-namecheap.conf +2 -2
- package/config/dns/net.zone +2 -2
- package/config/dns/{lunacycle.net.zone → templates/example.net.zone} +3 -3
- package/config/resolver/unbound.conf +2 -2
- package/config/routing/observer-setup.sh +60 -0
- package/config/routing/resolver-internal-routes.sh +2 -2
- package/docker/Dockerfile.observer +26 -0
- package/package.json +2 -2
- package/src/container-manager.ts +4 -4
- package/src/docker-compose-generator.ts +19 -4
- package/src/index.ts +28 -1
- package/src/network-builder.ts +18 -1
- package/src/observer.test.ts +155 -0
- package/src/observer.ts +270 -0
- package/src/types.ts +58 -0
- package/src/vantage-helpers.ts +78 -0
- package/src/vantage.test.ts +200 -0
- package/src/vantage.ts +266 -0
- package/src/zone-classifier.test.ts +82 -0
- package/src/zone-classifier.ts +92 -0
- package/config/dns/iamtheinternet.org.zone +0 -16
- package/config/dns/templates/lunacycle.net.zone +0 -16
|
@@ -10,8 +10,8 @@ zone:
|
|
|
10
10
|
file: "/config/celilo.computer.zone"
|
|
11
11
|
storage: "/var/lib/knot"
|
|
12
12
|
|
|
13
|
-
- domain: "
|
|
14
|
-
file: "/config/
|
|
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"
|
package/config/dns/net.zone
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
$ORIGIN
|
|
1
|
+
$ORIGIN example.net.
|
|
2
2
|
$TTL 300
|
|
3
3
|
|
|
4
|
-
@ IN SOA ns1.
|
|
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.
|
|
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: "
|
|
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 (
|
|
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
|
-
# (
|
|
12
|
+
# (example.net). Tests can override via DEFAULT_GATEWAY/DOMAINS env
|
|
13
13
|
# vars in docker-compose.
|
|
14
|
-
DOMAINS="${DOMAINS:-iamtheinternet.org,
|
|
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.
|
|
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.
|
|
40
|
+
"@celilo/event-bus": "^0.1.7",
|
|
41
41
|
"yaml": "^2.8.0",
|
|
42
42
|
"zod": "^3.24.1"
|
|
43
43
|
},
|
package/src/container-manager.ts
CHANGED
|
@@ -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', '
|
|
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 ??
|
|
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 ??
|
|
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","
|
|
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/
|
|
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","
|
|
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/
|
|
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 {
|
|
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 {
|
package/src/network-builder.ts
CHANGED
|
@@ -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
|
+
});
|