@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
package/src/observer.ts
ADDED
|
@@ -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
|
+
}
|
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
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable positive vantage assertions (ISS-0117).
|
|
3
|
+
*
|
|
4
|
+
* Module e2e suites call these after deploying a service to assert the positive network
|
|
5
|
+
* facts the right consumer should see — TLS works from the internet, the name resolves to
|
|
6
|
+
* a public address, an internal device reaches the service via the firewall natIp. The
|
|
7
|
+
* negative/isolation gates (a system that SHOULDN'T reach another) are intentionally not
|
|
8
|
+
* here — they wait on real firewall isolation (ISS-0118).
|
|
9
|
+
*
|
|
10
|
+
* Topology prerequisite: inject the observers the assertion needs, e.g.
|
|
11
|
+
* network().dmz({ caddy: '10.0.10.10' }).observe('internalDevice', 'publicInternet')
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createObserverTransport } from './observer';
|
|
15
|
+
import type { NetworkHandle } from './types';
|
|
16
|
+
import { VantageProbe } from './vantage';
|
|
17
|
+
|
|
18
|
+
export interface FrontedServiceExpectation {
|
|
19
|
+
/** The public hostname the service is fronted at. */
|
|
20
|
+
fqdn: string;
|
|
21
|
+
/**
|
|
22
|
+
* The firewall natIp, when the topology deploys a firewall. Enables the internalDevice
|
|
23
|
+
* reachability check (an internal device reaches the service via the controlled DNAT).
|
|
24
|
+
* Omit for topologies without a firewall (e.g. direct-internet).
|
|
25
|
+
*/
|
|
26
|
+
natIp?: string;
|
|
27
|
+
/** Service port (default 443). */
|
|
28
|
+
port?: number;
|
|
29
|
+
/** Expected HTTPS status (default 200). Override for roots that redirect (e.g. 302). */
|
|
30
|
+
expectStatus?: number;
|
|
31
|
+
/** Optional path appended to the URL for the HTTPS check (e.g. '/-/ping'). */
|
|
32
|
+
path?: string;
|
|
33
|
+
/** Optional expected cert issuer substring (e.g. 'Pebble') — a CA-provenance assertion. */
|
|
34
|
+
expectCA?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Assert the positive facts a web-fronted service should present from the right vantages:
|
|
39
|
+
* - `publicInternet` resolves the FQDN to a public IP (not a private/segmented-zone IP),
|
|
40
|
+
* - `publicInternet` gets a valid HTTPS response with a real, non-self-signed cert,
|
|
41
|
+
* - `internalDevice` reaches the service via the firewall natIp (when `natIp` is given).
|
|
42
|
+
*
|
|
43
|
+
* Requires the topology to have injected the `publicInternet` (and, for the natIp check,
|
|
44
|
+
* `internalDevice`) observers.
|
|
45
|
+
*/
|
|
46
|
+
export async function assertFrontedServicePositives(
|
|
47
|
+
net: NetworkHandle,
|
|
48
|
+
expectation: FrontedServiceExpectation,
|
|
49
|
+
): Promise<void> {
|
|
50
|
+
const probe = new VantageProbe(createObserverTransport(net));
|
|
51
|
+
const port = expectation.port ?? 443;
|
|
52
|
+
const url = `https://${expectation.fqdn}${expectation.path ?? ''}`;
|
|
53
|
+
|
|
54
|
+
// The internet sees the service at a public IP and a valid HTTPS endpoint.
|
|
55
|
+
await probe.assertResolvesInZone({ from: 'publicInternet', name: expectation.fqdn, zone: 'public' });
|
|
56
|
+
await probe.assertHttps({
|
|
57
|
+
from: 'publicInternet',
|
|
58
|
+
url,
|
|
59
|
+
expectStatus: expectation.expectStatus ?? 200,
|
|
60
|
+
expectCA: expectation.expectCA,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// An internal device reaches the service via the controlled natIp DNAT (when firewalled).
|
|
64
|
+
if (expectation.natIp) {
|
|
65
|
+
await probe.assertReachable({ from: 'internalDevice', host: expectation.natIp, port });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Assert several fronted hostnames at once (multi-hostname caddy, etc.). */
|
|
70
|
+
export async function assertFrontedHostnames(
|
|
71
|
+
net: NetworkHandle,
|
|
72
|
+
fqdns: string[],
|
|
73
|
+
shared: Omit<FrontedServiceExpectation, 'fqdn'>,
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
for (const fqdn of fqdns) {
|
|
76
|
+
await assertFrontedServicePositives(net, { ...shared, fqdn });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
type CertInfo,
|
|
4
|
+
type ProbeTransport,
|
|
5
|
+
type Vantage,
|
|
6
|
+
VantageAssertionError,
|
|
7
|
+
VantageProbe,
|
|
8
|
+
} from './vantage';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A scriptable fake transport: the verbs are pure orchestration over this boundary,
|
|
12
|
+
* so we can drive every assertion path without Docker (Rule 2.3).
|
|
13
|
+
*/
|
|
14
|
+
function fakeTransport(overrides: Partial<ProbeTransport> = {}): ProbeTransport {
|
|
15
|
+
return {
|
|
16
|
+
resolve: async () => ({ ips: [] }),
|
|
17
|
+
tcpConnect: async () => ({ connected: false }),
|
|
18
|
+
ping: async () => ({ alive: false }),
|
|
19
|
+
httpsRequest: async () => ({ status: 200, body: '', cert: realCert() }),
|
|
20
|
+
inspectResolution: async () => ({ resolvConf: '', hostsLines: [] }),
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function realCert(over: Partial<CertInfo> = {}): CertInfo {
|
|
26
|
+
return {
|
|
27
|
+
cn: 'www.example.com',
|
|
28
|
+
issuer: 'Pebble Intermediate CA',
|
|
29
|
+
subjectAltNames: ['www.example.com'],
|
|
30
|
+
selfSigned: false,
|
|
31
|
+
...over,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const LAN: Vantage = 'internalDevice';
|
|
36
|
+
|
|
37
|
+
describe('resolveFrom', () => {
|
|
38
|
+
test('defaults method to system, passes through ips', async () => {
|
|
39
|
+
const probe = new VantageProbe(fakeTransport({ resolve: async () => ({ ips: ['192.168.0.253'] }) }));
|
|
40
|
+
const r = await probe.resolveFrom({ from: LAN, name: 'www.example.com' });
|
|
41
|
+
expect(r.method).toBe('system');
|
|
42
|
+
expect(r.ips).toEqual(['192.168.0.253']);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('assertResolves / assertNxdomain', () => {
|
|
47
|
+
test('assertResolves throws on empty answer', async () => {
|
|
48
|
+
const probe = new VantageProbe(fakeTransport({ resolve: async () => ({ ips: [] }) }));
|
|
49
|
+
await expect(probe.assertResolves({ from: LAN, name: 'gone.example.com' })).rejects.toThrow(
|
|
50
|
+
VantageAssertionError,
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
test('assertNxdomain throws when a public vantage CAN see an internal-only name', async () => {
|
|
54
|
+
const probe = new VantageProbe(fakeTransport({ resolve: async () => ({ ips: ['10.0.20.5'] }) }));
|
|
55
|
+
await expect(
|
|
56
|
+
probe.assertNxdomain({ from: 'publicInternet', name: 'authentik.internal' }),
|
|
57
|
+
).rejects.toThrow(/expected NXDOMAIN/);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('assertResolvesInZone — the killer verb', () => {
|
|
62
|
+
test('ISS-0101: www at caddy DMZ container IP FAILS from internalDevice', async () => {
|
|
63
|
+
const probe = new VantageProbe(fakeTransport({ resolve: async () => ({ ips: ['10.0.10.10'] }) }));
|
|
64
|
+
await expect(
|
|
65
|
+
probe.assertResolvesInZone({ from: LAN, name: 'www.example.com', zone: 'internal' }),
|
|
66
|
+
).rejects.toThrow(/ISS-0101/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('www at the firewall natIp PASSES from internalDevice (the right answer)', async () => {
|
|
70
|
+
const probe = new VantageProbe(fakeTransport({ resolve: async () => ({ ips: ['192.168.0.253'] }) }));
|
|
71
|
+
const r = await probe.assertResolvesInZone({ from: LAN, name: 'www.example.com', zone: 'internal' });
|
|
72
|
+
expect(r.ips).toEqual(['192.168.0.253']);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('public vantage expecting a public answer passes; a leaked zone IP fails', async () => {
|
|
76
|
+
const ok = new VantageProbe(fakeTransport({ resolve: async () => ({ ips: ['100.100.0.100'] }) }));
|
|
77
|
+
await ok.assertResolvesInZone({ from: 'publicInternet', name: 'www.example.com', zone: 'public' });
|
|
78
|
+
|
|
79
|
+
const leaked = new VantageProbe(fakeTransport({ resolve: async () => ({ ips: ['192.168.0.253'] }) }));
|
|
80
|
+
await expect(
|
|
81
|
+
leaked.assertResolvesInZone({ from: 'publicInternet', name: 'www.example.com', zone: 'public' }),
|
|
82
|
+
).rejects.toThrow(VantageAssertionError);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('any one leaked IP among several fails the whole answer', async () => {
|
|
86
|
+
const probe = new VantageProbe(
|
|
87
|
+
fakeTransport({ resolve: async () => ({ ips: ['192.168.0.253', '10.0.30.9'] }) }),
|
|
88
|
+
);
|
|
89
|
+
await expect(
|
|
90
|
+
probe.assertResolvesInZone({ from: LAN, name: 'www.example.com', zone: 'internal' }),
|
|
91
|
+
).rejects.toThrow(/10\.0\.30\.9/);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('assertResolvesTo — exact-IP DNS correctness', () => {
|
|
96
|
+
test('passes when the resolver returns the expected IP (via a specific server)', async () => {
|
|
97
|
+
const probe = new VantageProbe(fakeTransport({ resolve: async () => ({ ips: ['192.168.0.53'] }) }));
|
|
98
|
+
const r = await probe.assertResolvesTo({
|
|
99
|
+
from: 'internalDevice',
|
|
100
|
+
name: 'host.infra.example.com',
|
|
101
|
+
method: 'dns',
|
|
102
|
+
server: '192.168.0.10',
|
|
103
|
+
expected: '192.168.0.53',
|
|
104
|
+
});
|
|
105
|
+
expect(r.ips).toEqual(['192.168.0.53']);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('fails (naming the server) when the expected IP is absent', async () => {
|
|
109
|
+
const probe = new VantageProbe(fakeTransport({ resolve: async () => ({ ips: ['10.0.10.99'] }) }));
|
|
110
|
+
await expect(
|
|
111
|
+
probe.assertResolvesTo({
|
|
112
|
+
from: 'internalDevice',
|
|
113
|
+
name: 'host.infra.example.com',
|
|
114
|
+
method: 'dns',
|
|
115
|
+
server: '192.168.0.10',
|
|
116
|
+
expected: '192.168.0.53',
|
|
117
|
+
}),
|
|
118
|
+
).rejects.toThrow(/via 192\.168\.0\.10.*expected to include \[192\.168\.0\.53\]/);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('accepts an array of expected IPs (all must be present)', async () => {
|
|
122
|
+
const probe = new VantageProbe(fakeTransport({ resolve: async () => ({ ips: ['1.2.3.4', '5.6.7.8'] }) }));
|
|
123
|
+
await probe.assertResolvesTo({ from: 'publicInternet', name: 'x', expected: ['1.2.3.4', '5.6.7.8'] });
|
|
124
|
+
await expect(
|
|
125
|
+
probe.assertResolvesTo({ from: 'publicInternet', name: 'x', expected: ['1.2.3.4', '9.9.9.9'] }),
|
|
126
|
+
).rejects.toThrow(/9\.9\.9\.9/);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('reachability + isolation (§D6)', () => {
|
|
131
|
+
test('assertReachable throws when the connection fails (ISS-0111 git-ssh:2222)', async () => {
|
|
132
|
+
const probe = new VantageProbe(fakeTransport({ tcpConnect: async () => ({ connected: false, detail: 'timeout' }) }));
|
|
133
|
+
await expect(probe.assertReachable({ from: LAN, host: 'git-ssh.example.com', port: 2222 })).rejects.toThrow(
|
|
134
|
+
/timeout/,
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
test('assertUnreachable throws when isolation is violated', async () => {
|
|
138
|
+
const probe = new VantageProbe(fakeTransport({ tcpConnect: async () => ({ connected: true }) }));
|
|
139
|
+
await expect(probe.assertUnreachable({ from: 'appSystem', host: '10.0.30.5', port: 443 })).rejects.toThrow(
|
|
140
|
+
/zone isolation/,
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
test('the reachable/unreachable happy paths do not throw', async () => {
|
|
144
|
+
const reachable = new VantageProbe(fakeTransport({ tcpConnect: async () => ({ connected: true }) }));
|
|
145
|
+
await reachable.assertReachable({ from: LAN, host: '192.168.0.253', port: 443 });
|
|
146
|
+
const blocked = new VantageProbe(fakeTransport({ tcpConnect: async () => ({ connected: false }) }));
|
|
147
|
+
await blocked.assertUnreachable({ from: 'appSystem', host: '10.0.30.5', port: 443 });
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('assertHttps — cert provenance', () => {
|
|
152
|
+
test('self-signed cert is rejected (catches --insecure / skip-cert cheats)', async () => {
|
|
153
|
+
const probe = new VantageProbe(
|
|
154
|
+
fakeTransport({ httpsRequest: async () => ({ status: 200, body: 'ok', cert: realCert({ selfSigned: true }) }) }),
|
|
155
|
+
);
|
|
156
|
+
await expect(probe.assertHttps({ from: LAN, url: 'https://www.example.com' })).rejects.toThrow(/self-signed/);
|
|
157
|
+
});
|
|
158
|
+
test('wrong CA issuer is rejected', async () => {
|
|
159
|
+
const probe = new VantageProbe(fakeTransport());
|
|
160
|
+
await expect(
|
|
161
|
+
probe.assertHttps({ from: LAN, url: 'https://www.example.com', expectCA: "Let's Encrypt" }),
|
|
162
|
+
).rejects.toThrow(/issuer/);
|
|
163
|
+
});
|
|
164
|
+
test('matching CN + CA + status passes', async () => {
|
|
165
|
+
const probe = new VantageProbe(fakeTransport());
|
|
166
|
+
const r = await probe.assertHttps({
|
|
167
|
+
from: LAN,
|
|
168
|
+
url: 'https://www.example.com',
|
|
169
|
+
expectStatus: 200,
|
|
170
|
+
expectCN: 'www.example.com',
|
|
171
|
+
expectCA: 'Pebble',
|
|
172
|
+
});
|
|
173
|
+
expect(r.status).toBe(200);
|
|
174
|
+
expect(r.cert.cn).toBe('www.example.com');
|
|
175
|
+
});
|
|
176
|
+
test('unexpected status is rejected', async () => {
|
|
177
|
+
const probe = new VantageProbe(
|
|
178
|
+
fakeTransport({ httpsRequest: async () => ({ status: 502, body: '', cert: realCert() }) }),
|
|
179
|
+
);
|
|
180
|
+
await expect(
|
|
181
|
+
probe.assertHttps({ from: LAN, url: 'https://www.example.com', expectStatus: 200 }),
|
|
182
|
+
).rejects.toThrow(/status 502/);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('inspectResolution', () => {
|
|
187
|
+
test('returns the vantage resolv.conf + matching hosts lines (host-pin check, ISS-0095)', async () => {
|
|
188
|
+
const probe = new VantageProbe(
|
|
189
|
+
fakeTransport({
|
|
190
|
+
inspectResolution: async () => ({
|
|
191
|
+
resolvConf: 'nameserver 192.168.0.253',
|
|
192
|
+
hostsLines: ['10.0.10.10 auth.example.com'],
|
|
193
|
+
}),
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
const r = await probe.inspectResolution({ from: 'dmzSystem', name: 'auth.example.com' });
|
|
197
|
+
expect(r.hostsLines).toContain('10.0.10.10 auth.example.com');
|
|
198
|
+
expect(r.resolvConf).toContain('192.168.0.253');
|
|
199
|
+
});
|
|
200
|
+
});
|