@celilo/e2e 0.7.14 → 0.7.16
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/proxy/startup.sh +14 -0
- package/config/resolver/unbound.conf +2 -2
- package/config/routing/fw-ext-routes.sh +13 -0
- 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/registry-server/src/auth.test.ts +56 -30
- package/registry-server/src/auth.ts +88 -19
- package/registry-server/src/scoped-token-store.test.ts +93 -0
- package/registry-server/src/scoped-token-store.ts +110 -0
- package/registry-server/src/server.test.ts +92 -10
- package/registry-server/src/server.ts +104 -31
- package/scripts/pack-celilo-packages.ts +114 -22
- 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/shared-infra.ts +18 -1
- 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,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
|
+
});
|
package/src/vantage.ts
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vantage-point network assertions (ISS-0117, §D3/§D4).
|
|
3
|
+
*
|
|
4
|
+
* Every network check runs FROM a named vantage — the box a real consumer occupies —
|
|
5
|
+
* and classifies the result against the zone topology (see ./zone-classifier). A green
|
|
6
|
+
* from the wrong vantage is worse than no test: ISS-0101 (www -> DMZ container IP),
|
|
7
|
+
* ISS-0111 (git-ssh -> container IP) and the ISS-0095 risk all passed e2e because the
|
|
8
|
+
* assertion ran from the management box, which has routes into every zone.
|
|
9
|
+
*
|
|
10
|
+
* This module is the typed VERB layer. It performs no I/O itself: it orchestrates a
|
|
11
|
+
* `ProbeTransport` (the injected boundary, Rule 2.3 / Rule 10.4) and applies zone
|
|
12
|
+
* classification + assertion policy on the typed results. The real transports — a
|
|
13
|
+
* dockerExec escape hatch now, a probe-agent later (§D2) — implement `ProbeTransport`
|
|
14
|
+
* elsewhere, so the verbs stay pure and unit-testable without Docker.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { type Vantage, type Zone } from './types';
|
|
18
|
+
import { classifyIp } from './zone-classifier';
|
|
19
|
+
|
|
20
|
+
// `Vantage` is topology vocabulary — defined in ./types alongside Zone — and re-exported
|
|
21
|
+
// here so verb-layer consumers can import it from the same module as the verbs.
|
|
22
|
+
export type { Vantage };
|
|
23
|
+
|
|
24
|
+
/** How a name is resolved (§D3) — lets us test the resolution layers independently. */
|
|
25
|
+
export type ResolveMethod =
|
|
26
|
+
| 'system' // the vantage's real stack: resolv.conf AND /etc/hosts
|
|
27
|
+
| 'dns' // query a resolver directly (optionally a specific `server`)
|
|
28
|
+
| 'hostfiles'; // the /etc/hosts layer only (the host-pin path — ISS-0095)
|
|
29
|
+
|
|
30
|
+
/** A zone the answer may legitimately land in, plus `public` for a routable internet address. */
|
|
31
|
+
export type ExpectedZone = Zone | 'public';
|
|
32
|
+
|
|
33
|
+
export interface ResolveResult {
|
|
34
|
+
name: string;
|
|
35
|
+
from: Vantage;
|
|
36
|
+
method: ResolveMethod;
|
|
37
|
+
ips: string[]; // resolved A records; empty == NXDOMAIN / no answer
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface CertInfo {
|
|
41
|
+
cn: string;
|
|
42
|
+
issuer: string;
|
|
43
|
+
subjectAltNames: string[];
|
|
44
|
+
selfSigned: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface HttpsResult {
|
|
48
|
+
from: Vantage;
|
|
49
|
+
url: string;
|
|
50
|
+
status: number;
|
|
51
|
+
body: string;
|
|
52
|
+
cert: CertInfo;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ResolutionInspection {
|
|
56
|
+
from: Vantage;
|
|
57
|
+
name: string;
|
|
58
|
+
resolvConf: string;
|
|
59
|
+
hostsLines: string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The injected I/O boundary. Implementations (dockerExec-based now, probe-agent later)
|
|
64
|
+
* are the ONLY place raw shell / network calls live; the verbs below never touch I/O.
|
|
65
|
+
*/
|
|
66
|
+
export interface ProbeTransport {
|
|
67
|
+
resolve(opts: { from: Vantage; name: string; method: ResolveMethod; server?: string }): Promise<{ ips: string[] }>;
|
|
68
|
+
tcpConnect(opts: { from: Vantage; host: string; port: number }): Promise<{ connected: boolean; detail?: string }>;
|
|
69
|
+
ping(opts: { from: Vantage; target: string }): Promise<{ alive: boolean }>;
|
|
70
|
+
httpsRequest(opts: { from: Vantage; url: string }): Promise<{ status: number; body: string; cert: CertInfo }>;
|
|
71
|
+
inspectResolution(opts: { from: Vantage; name: string }): Promise<{ resolvConf: string; hostsLines: string[] }>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Thrown when a vantage assertion fails. Messages name the bug class they guard. */
|
|
75
|
+
export class VantageAssertionError extends Error {
|
|
76
|
+
constructor(message: string) {
|
|
77
|
+
super(message);
|
|
78
|
+
this.name = 'VantageAssertionError';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface ResolveOpts {
|
|
83
|
+
from: Vantage;
|
|
84
|
+
name: string;
|
|
85
|
+
method?: ResolveMethod;
|
|
86
|
+
server?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface ReachOpts {
|
|
90
|
+
from: Vantage;
|
|
91
|
+
host: string;
|
|
92
|
+
port: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface HttpsAssertOpts {
|
|
96
|
+
from: Vantage;
|
|
97
|
+
url: string;
|
|
98
|
+
expectStatus?: number;
|
|
99
|
+
expectCN?: string;
|
|
100
|
+
expectCA?: string; // expected issuer substring (Let's Encrypt / Pebble in e2e); never self-signed
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function describeZone(ip: string): string {
|
|
104
|
+
const c = classifyIp(ip);
|
|
105
|
+
switch (c.kind) {
|
|
106
|
+
case 'zone':
|
|
107
|
+
return `zone ${c.zone}`;
|
|
108
|
+
case 'reserved':
|
|
109
|
+
return `reserved (${c.label})`;
|
|
110
|
+
case 'public':
|
|
111
|
+
return 'public';
|
|
112
|
+
case 'invalid':
|
|
113
|
+
return 'invalid IP';
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The typed probe surface. Construct with a transport; every verb takes a single
|
|
119
|
+
* options object with a required `from` (§D1 — no default vantage).
|
|
120
|
+
*/
|
|
121
|
+
export class VantageProbe {
|
|
122
|
+
constructor(private readonly transport: ProbeTransport) {}
|
|
123
|
+
|
|
124
|
+
async resolveFrom(opts: ResolveOpts): Promise<ResolveResult> {
|
|
125
|
+
const method = opts.method ?? 'system';
|
|
126
|
+
const { ips } = await this.transport.resolve({
|
|
127
|
+
from: opts.from,
|
|
128
|
+
name: opts.name,
|
|
129
|
+
method,
|
|
130
|
+
server: opts.server,
|
|
131
|
+
});
|
|
132
|
+
return { name: opts.name, from: opts.from, method, ips };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async assertResolves(opts: ResolveOpts): Promise<ResolveResult> {
|
|
136
|
+
const result = await this.resolveFrom(opts);
|
|
137
|
+
if (result.ips.length === 0) {
|
|
138
|
+
throw new VantageAssertionError(
|
|
139
|
+
`${opts.name} did not resolve from ${opts.from} (NXDOMAIN / no A record), expected an answer.`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async assertNxdomain(opts: ResolveOpts): Promise<void> {
|
|
146
|
+
const result = await this.resolveFrom(opts);
|
|
147
|
+
if (result.ips.length > 0) {
|
|
148
|
+
throw new VantageAssertionError(
|
|
149
|
+
`${opts.name} resolved from ${opts.from} to ${result.ips.join(', ')}, expected NXDOMAIN ` +
|
|
150
|
+
`(this name must not be visible from ${opts.from}).`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The killer verb: resolve, classify every answer, assert the expected zone.
|
|
157
|
+
* `www.<domain>` from `internalDevice` MUST be `internal`/`public` (natIp/public),
|
|
158
|
+
* never a managed `dmz`/`app`/`secure` container IP — the ISS-0101 / ISS-0111 class.
|
|
159
|
+
*/
|
|
160
|
+
async assertResolvesInZone(opts: ResolveOpts & { zone: ExpectedZone }): Promise<ResolveResult> {
|
|
161
|
+
const result = await this.assertResolves(opts);
|
|
162
|
+
const offenders = result.ips.filter((ip) => !ipMatchesExpectedZone(ip, opts.zone));
|
|
163
|
+
if (offenders.length > 0) {
|
|
164
|
+
const detail = offenders.map((ip) => `${ip} (${describeZone(ip)})`).join(', ');
|
|
165
|
+
throw new VantageAssertionError(
|
|
166
|
+
`${opts.name} from ${opts.from} resolved to ${detail}, expected zone "${opts.zone}". ` +
|
|
167
|
+
'A managed-zone container IP is unroutable from outside that zone — this is the ' +
|
|
168
|
+
'e2e-green/prod-broke class (ISS-0101, ISS-0111). The right answer from a consumer ' +
|
|
169
|
+
'vantage is the firewall natIp (internal) or a public address.',
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Resolve and assert the answer includes the EXACT expected IP(s) — the DNS-correctness
|
|
177
|
+
* verb. Pair with `method: 'dns', server` to assert what a SPECIFIC resolver returns (e.g.
|
|
178
|
+
* the internal split-horizon resolver vs the public resolver). Used by the DNS suites to
|
|
179
|
+
* check a DDNS-registered IP, an nsupdate'd A record, or a split-horizon answer.
|
|
180
|
+
*/
|
|
181
|
+
async assertResolvesTo(opts: ResolveOpts & { expected: string | string[] }): Promise<ResolveResult> {
|
|
182
|
+
const result = await this.assertResolves(opts);
|
|
183
|
+
const expected = Array.isArray(opts.expected) ? opts.expected : [opts.expected];
|
|
184
|
+
const missing = expected.filter((ip) => !result.ips.includes(ip));
|
|
185
|
+
if (missing.length > 0) {
|
|
186
|
+
const via = opts.server ? ` via ${opts.server}` : '';
|
|
187
|
+
throw new VantageAssertionError(
|
|
188
|
+
`${opts.name} from ${opts.from}${via} resolved to [${result.ips.join(', ')}], ` +
|
|
189
|
+
`expected to include [${missing.join(', ')}].`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return result;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async ping(opts: { from: Vantage; target: string }): Promise<boolean> {
|
|
196
|
+
const { alive } = await this.transport.ping(opts);
|
|
197
|
+
return alive;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async assertReachable(opts: ReachOpts): Promise<void> {
|
|
201
|
+
const { connected, detail } = await this.transport.tcpConnect(opts);
|
|
202
|
+
if (!connected) {
|
|
203
|
+
throw new VantageAssertionError(
|
|
204
|
+
`${opts.host}:${opts.port} was NOT reachable from ${opts.from}` +
|
|
205
|
+
(detail ? ` (${detail})` : '') +
|
|
206
|
+
', expected a successful TCP connection.',
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Isolation assertion (§D6): prove what shouldn't work, e.g. appSystem cannot reach a secure host. */
|
|
212
|
+
async assertUnreachable(opts: ReachOpts): Promise<void> {
|
|
213
|
+
const { connected } = await this.transport.tcpConnect(opts);
|
|
214
|
+
if (connected) {
|
|
215
|
+
throw new VantageAssertionError(
|
|
216
|
+
`${opts.host}:${opts.port} WAS reachable from ${opts.from}, expected it to be blocked ` +
|
|
217
|
+
'(zone isolation / firewall boundary violated).',
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Connect, validate the REAL cert chain (right CN, expected issuer, never self-signed),
|
|
224
|
+
* then status. Cert provenance catches the skip-cert / 0.0.0.0-sentinel cheats.
|
|
225
|
+
*/
|
|
226
|
+
async assertHttps(opts: HttpsAssertOpts): Promise<HttpsResult> {
|
|
227
|
+
const res = await this.transport.httpsRequest({ from: opts.from, url: opts.url });
|
|
228
|
+
const result: HttpsResult = { from: opts.from, url: opts.url, ...res };
|
|
229
|
+
|
|
230
|
+
if (res.cert.selfSigned) {
|
|
231
|
+
throw new VantageAssertionError(
|
|
232
|
+
`${opts.url} from ${opts.from} presented a self-signed cert (issuer "${res.cert.issuer}"). ` +
|
|
233
|
+
'A real cert from the e2e CA (Pebble / Let’s Encrypt) is required — no --insecure.',
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
if (opts.expectCN && res.cert.cn !== opts.expectCN) {
|
|
237
|
+
throw new VantageAssertionError(
|
|
238
|
+
`${opts.url} from ${opts.from} cert CN "${res.cert.cn}", expected "${opts.expectCN}".`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
if (opts.expectCA && !res.cert.issuer.includes(opts.expectCA)) {
|
|
242
|
+
throw new VantageAssertionError(
|
|
243
|
+
`${opts.url} from ${opts.from} cert issuer "${res.cert.issuer}", expected to contain "${opts.expectCA}".`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
if (opts.expectStatus !== undefined && res.status !== opts.expectStatus) {
|
|
247
|
+
throw new VantageAssertionError(
|
|
248
|
+
`${opts.url} from ${opts.from} returned status ${res.status}, expected ${opts.expectStatus}.`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
return result;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Diagnostic (§D3): the vantage's resolv.conf + matching /etc/hosts lines. Also asserts a host-pin (ISS-0095). */
|
|
255
|
+
async inspectResolution(opts: { from: Vantage; name: string }): Promise<ResolutionInspection> {
|
|
256
|
+
const res = await this.transport.inspectResolution(opts);
|
|
257
|
+
return { from: opts.from, name: opts.name, ...res };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** True if `ip` satisfies the expected zone (managed zone exact, or routable `public`). */
|
|
262
|
+
function ipMatchesExpectedZone(ip: string, expected: ExpectedZone): boolean {
|
|
263
|
+
const c = classifyIp(ip);
|
|
264
|
+
if (expected === 'public') return c.kind === 'public';
|
|
265
|
+
return c.kind === 'zone' && c.zone === expected;
|
|
266
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { classifyIp, inCidr, isPublicLeakSafe, isSegmentedZoneIp, parseIpv4 } from './zone-classifier';
|
|
3
|
+
|
|
4
|
+
describe('parseIpv4', () => {
|
|
5
|
+
test('parses dotted-quad to unsigned 32-bit', () => {
|
|
6
|
+
expect(parseIpv4('0.0.0.0')).toBe(0);
|
|
7
|
+
expect(parseIpv4('255.255.255.255')).toBe(0xffffffff);
|
|
8
|
+
// High first octet must not go negative (sign-bit overflow guard).
|
|
9
|
+
expect(parseIpv4('192.168.0.253')).toBe(((192 << 24) | (168 << 16) | (0 << 8) | 253) >>> 0);
|
|
10
|
+
expect(parseIpv4('192.168.0.253')).toBeGreaterThan(0);
|
|
11
|
+
});
|
|
12
|
+
test('rejects malformed / out-of-range', () => {
|
|
13
|
+
expect(parseIpv4('not.an.ip')).toBeNull();
|
|
14
|
+
expect(parseIpv4('999.1.1.1')).toBeNull();
|
|
15
|
+
expect(parseIpv4('10.0.0')).toBeNull();
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('inCidr', () => {
|
|
20
|
+
test('membership within /24', () => {
|
|
21
|
+
expect(inCidr('10.0.10.5', '10.0.10.0/24')).toBe(true);
|
|
22
|
+
expect(inCidr('10.0.10.255', '10.0.10.0/24')).toBe(true);
|
|
23
|
+
expect(inCidr('10.0.11.1', '10.0.10.0/24')).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
test('high-octet subnet (192.168) — no sign-bit bug', () => {
|
|
26
|
+
expect(inCidr('192.168.0.253', '192.168.0.0/24')).toBe(true);
|
|
27
|
+
expect(inCidr('192.168.1.1', '192.168.0.0/24')).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('classifyIp', () => {
|
|
32
|
+
test('managed zones (the segmented ones)', () => {
|
|
33
|
+
expect(classifyIp('10.0.10.10')).toEqual({ kind: 'zone', zone: 'dmz' }); // caddy
|
|
34
|
+
expect(classifyIp('10.0.20.30')).toEqual({ kind: 'zone', zone: 'app' }); // authentik
|
|
35
|
+
expect(classifyIp('10.0.30.5')).toEqual({ kind: 'zone', zone: 'secure' });
|
|
36
|
+
});
|
|
37
|
+
test('internal zone (the LAN — natIp lives here)', () => {
|
|
38
|
+
expect(classifyIp('192.168.0.253')).toEqual({ kind: 'zone', zone: 'internal' });
|
|
39
|
+
});
|
|
40
|
+
test('public: the CGNAT stand-in and real public space', () => {
|
|
41
|
+
expect(classifyIp('100.100.0.100')).toEqual({ kind: 'public' }); // sim firewall external
|
|
42
|
+
expect(classifyIp('8.8.8.8')).toEqual({ kind: 'public' });
|
|
43
|
+
expect(classifyIp('71.36.115.155')).toEqual({ kind: 'public' }); // the prod DDNS target
|
|
44
|
+
});
|
|
45
|
+
test('reserved ranges (incl. the 0.0.0.0 sentinel cheat)', () => {
|
|
46
|
+
expect(classifyIp('0.0.0.0').kind).toBe('reserved');
|
|
47
|
+
expect(classifyIp('127.0.0.1').kind).toBe('reserved');
|
|
48
|
+
expect(classifyIp('169.254.1.1').kind).toBe('reserved');
|
|
49
|
+
expect(classifyIp('224.0.0.1').kind).toBe('reserved');
|
|
50
|
+
});
|
|
51
|
+
test('invalid input', () => {
|
|
52
|
+
expect(classifyIp('nope').kind).toBe('invalid');
|
|
53
|
+
expect(classifyIp('256.0.0.1').kind).toBe('invalid');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('isSegmentedZoneIp', () => {
|
|
58
|
+
test('dmz/app/secure are segmented; internal and public are not', () => {
|
|
59
|
+
expect(isSegmentedZoneIp('10.0.10.10')).toBe(true); // caddy DMZ — the ISS-0101/0111 bug value
|
|
60
|
+
expect(isSegmentedZoneIp('10.0.20.30')).toBe(true);
|
|
61
|
+
expect(isSegmentedZoneIp('192.168.0.253')).toBe(false); // internal IS the LAN
|
|
62
|
+
expect(isSegmentedZoneIp('100.100.0.100')).toBe(false); // public
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('isPublicLeakSafe (CLAUDE.md inviolable rule #1)', () => {
|
|
67
|
+
test('only public addresses are leak-safe', () => {
|
|
68
|
+
expect(isPublicLeakSafe('100.100.0.100')).toBe(true);
|
|
69
|
+
expect(isPublicLeakSafe('8.8.8.8')).toBe(true);
|
|
70
|
+
expect(isPublicLeakSafe('10.0.10.10')).toBe(false); // managed segmented zone
|
|
71
|
+
expect(isPublicLeakSafe('192.168.0.253')).toBe(false); // internal/RFC-1918 must not leak
|
|
72
|
+
expect(isPublicLeakSafe('0.0.0.0')).toBe(false); // reserved sentinel
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('the ISS-0101 scenario: a fronted name resolving to caddy DMZ IP is NOT leak-safe', () => {
|
|
76
|
+
// www.<domain> from a LAN device must be the natIp (internal) or public, never
|
|
77
|
+
// caddy's DMZ container IP. The doctor + e2e share this judgment.
|
|
78
|
+
const wwwResolvedWrong = '10.0.10.10';
|
|
79
|
+
expect(isSegmentedZoneIp(wwwResolvedWrong)).toBe(true);
|
|
80
|
+
expect(classifyIp('192.168.0.253').kind).toBe('zone'); // the RIGHT answer (natIp, internal)
|
|
81
|
+
});
|
|
82
|
+
});
|