@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.
- 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/cli/command-registry.ts +9 -3
- package/src/cli/completion.ts +12 -2
- package/src/cli/index.ts +105 -1
- 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/runner.ts +18 -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
|
@@ -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
|
+
});
|
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/runner.ts
CHANGED
|
@@ -58,6 +58,8 @@ function resolveModuleTestsDir(dir: string): string {
|
|
|
58
58
|
const testsPath = moduleDir
|
|
59
59
|
? resolveModuleTestsDir(moduleDir)
|
|
60
60
|
: (process.env.E2E_TESTS_PATH ?? join(testDir, 'tests'));
|
|
61
|
+
/** Set by `cele2e run --all` so a module-dirs run ALSO picks up the repo's top-level e2e/tests/. */
|
|
62
|
+
const topLevelTestsPath = process.env.E2E_TOP_LEVEL_TESTS;
|
|
61
63
|
const timingFile = join(testDir, '.e2e-timing.json');
|
|
62
64
|
const persistentFile = join(testDir, '.e2e-persistent.json');
|
|
63
65
|
|
|
@@ -427,6 +429,22 @@ async function main() {
|
|
|
427
429
|
.map((f) => resolve(tp, f));
|
|
428
430
|
testFiles.push(...files);
|
|
429
431
|
}
|
|
432
|
+
// `cele2e run --all` sets E2E_MODULE_DIRS (every module) AND
|
|
433
|
+
// E2E_TOP_LEVEL_TESTS (the repo's top-level e2e/tests/), so one invocation
|
|
434
|
+
// covers BOTH the module suites and the top-level tests. Without this, the
|
|
435
|
+
// module branch and the testsPath branch below are mutually exclusive and
|
|
436
|
+
// no single run can cover everything (ISS-0013).
|
|
437
|
+
if (topLevelTestsPath) {
|
|
438
|
+
if (!existsSync(topLevelTestsPath)) {
|
|
439
|
+
console.error(`${red}No top-level tests directory found at ${topLevelTestsPath}${reset}`);
|
|
440
|
+
process.exit(1);
|
|
441
|
+
}
|
|
442
|
+
const files = readdirSync(topLevelTestsPath)
|
|
443
|
+
.filter((f) => f.endsWith('.test.ts'))
|
|
444
|
+
.sort()
|
|
445
|
+
.map((f) => resolve(topLevelTestsPath, f));
|
|
446
|
+
testFiles.push(...files);
|
|
447
|
+
}
|
|
430
448
|
} else {
|
|
431
449
|
if (!existsSync(testsPath)) {
|
|
432
450
|
console.error(`${red}No tests/ directory found at ${testsPath}${reset}`);
|
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
|
+
}
|