@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/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
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zone classification for e2e network assertions (ISS-0117, §D5).
|
|
3
|
+
*
|
|
4
|
+
* Pure functions: given a resolved IPv4, say which celilo zone it belongs to (by
|
|
5
|
+
* matching the topology's `ZONE_SUBNETS`), or whether it is a reserved/special-use
|
|
6
|
+
* range, or a globally-routable "public" address. The vantage-assertion verbs
|
|
7
|
+
* (`resolveFrom` / `assertResolvesInZone`, ISS-0117 §D3) build on this to enforce
|
|
8
|
+
* e.g. "www.<domain> resolved from internalDevice must NOT be a segmented-zone
|
|
9
|
+
* container IP" — the ISS-0101 / ISS-0111 bug class.
|
|
10
|
+
*
|
|
11
|
+
* Classification uses `ZONE_SUBNETS` (exact) + a reserved-range check — deliberately
|
|
12
|
+
* NOT a blanket RFC-1918/CGNAT ban: the sim's "public" IP is a CGNAT stand-in
|
|
13
|
+
* (100.100.0.100), so in this topology 10.x / 192.168.x are *zones* and 100.x is
|
|
14
|
+
* *public*. (A real deployment would additionally treat RFC-1918 + RFC-6598 as
|
|
15
|
+
* non-public; that's the caller's policy, layered on top of this primitive.)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { type Zone, ZONE_SUBNETS } from './types';
|
|
19
|
+
|
|
20
|
+
export type ZoneClass =
|
|
21
|
+
| { kind: 'zone'; zone: Zone }
|
|
22
|
+
| { kind: 'reserved'; label: string }
|
|
23
|
+
| { kind: 'public' }
|
|
24
|
+
| { kind: 'invalid' };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Segmented zones a device on the operator's LAN (`internal`) cannot route into —
|
|
28
|
+
* traffic to them must go via the firewall natIp. `internal` IS the LAN, so it is
|
|
29
|
+
* NOT segmented (a record at an internal-zone IP is reachable).
|
|
30
|
+
*/
|
|
31
|
+
export const SEGMENTED_ZONES: readonly Zone[] = ['dmz', 'app', 'secure'];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Special-use IPv4 ranges that must never be a real service address (nor appear in
|
|
35
|
+
* any DNS answer). RFC-1918 and RFC-6598 (CGNAT) are intentionally absent — in this
|
|
36
|
+
* topology the former are the managed zones and the latter is the public stand-in.
|
|
37
|
+
*/
|
|
38
|
+
const RESERVED: ReadonlyArray<{ cidr: string; label: string }> = [
|
|
39
|
+
{ cidr: '0.0.0.0/8', label: 'unspecified/this-host (incl. the 0.0.0.0 sentinel)' },
|
|
40
|
+
{ cidr: '127.0.0.0/8', label: 'loopback' },
|
|
41
|
+
{ cidr: '169.254.0.0/16', label: 'link-local (RFC 3927)' },
|
|
42
|
+
{ cidr: '224.0.0.0/4', label: 'multicast' },
|
|
43
|
+
{ cidr: '240.0.0.0/4', label: 'reserved (RFC 1112)' },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/** Parse a dotted-quad IPv4 to an unsigned 32-bit int, or null if malformed. */
|
|
47
|
+
export function parseIpv4(ip: string): number | null {
|
|
48
|
+
const m = ip.trim().match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
49
|
+
if (!m) return null;
|
|
50
|
+
const octets = m.slice(1, 5).map(Number);
|
|
51
|
+
if (octets.some((o) => o > 255)) return null;
|
|
52
|
+
return ((octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]) >>> 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** True if `ip` falls within `cidr` (e.g. "10.0.10.0/24"). */
|
|
56
|
+
export function inCidr(ip: string, cidr: string): boolean {
|
|
57
|
+
const [base, bitsStr] = cidr.split('/');
|
|
58
|
+
const bits = Number(bitsStr);
|
|
59
|
+
const ipN = parseIpv4(ip);
|
|
60
|
+
const baseN = parseIpv4(base);
|
|
61
|
+
if (ipN === null || baseN === null || Number.isNaN(bits) || bits < 0 || bits > 32) return false;
|
|
62
|
+
if (bits === 0) return true;
|
|
63
|
+
const mask = (0xffffffff << (32 - bits)) >>> 0;
|
|
64
|
+
return (ipN & mask) >>> 0 === ((baseN & mask) >>> 0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Classify a resolved IPv4 against the e2e topology. */
|
|
68
|
+
export function classifyIp(ip: string): ZoneClass {
|
|
69
|
+
if (parseIpv4(ip) === null) return { kind: 'invalid' };
|
|
70
|
+
for (const r of RESERVED) {
|
|
71
|
+
if (inCidr(ip, r.cidr)) return { kind: 'reserved', label: r.label };
|
|
72
|
+
}
|
|
73
|
+
for (const zone of Object.keys(ZONE_SUBNETS) as Zone[]) {
|
|
74
|
+
if (inCidr(ip, ZONE_SUBNETS[zone])) return { kind: 'zone', zone };
|
|
75
|
+
}
|
|
76
|
+
return { kind: 'public' };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** True if `ip` is inside a segmented zone (dmz/app/secure) — unroutable from the LAN. */
|
|
80
|
+
export function isSegmentedZoneIp(ip: string): boolean {
|
|
81
|
+
const c = classifyIp(ip);
|
|
82
|
+
return c.kind === 'zone' && SEGMENTED_ZONES.includes(c.zone);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The "no leak past the public boundary" rule (ISS-0117 §D5; CLAUDE.md inviolable
|
|
87
|
+
* rule #1): a public DNS answer must be a routable public address — never a managed
|
|
88
|
+
* zone (incl. `internal`/RFC-1918) nor a reserved range.
|
|
89
|
+
*/
|
|
90
|
+
export function isPublicLeakSafe(ip: string): boolean {
|
|
91
|
+
return classifyIp(ip).kind === 'public';
|
|
92
|
+
}
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
$ORIGIN iamtheinternet.org.
|
|
2
|
-
$TTL 300
|
|
3
|
-
|
|
4
|
-
@ IN SOA ns1.iamtheinternet.org. admin.iamtheinternet.org. (
|
|
5
|
-
2024020247 300 60 604800 300
|
|
6
|
-
)
|
|
7
|
-
|
|
8
|
-
; Public DNS — RFC 1918 addresses NEVER appear here. The whole point of
|
|
9
|
-
; the e2e simulation is to model real internet connectivity, where
|
|
10
|
-
; private IPs are unreachable from outside. All public-facing names
|
|
11
|
-
; resolve to the firewall's external IP (100.100.0.100), which DNATs
|
|
12
|
-
; inbound to the right internal host.
|
|
13
|
-
@ IN NS ns1.iamtheinternet.org.
|
|
14
|
-
ns1 IN A 100.64.0.55
|
|
15
|
-
@ IN A 0.0.0.0
|
|
16
|
-
www IN A 100.100.0.100
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
$ORIGIN lunacycle.net.
|
|
2
|
-
$TTL 300
|
|
3
|
-
|
|
4
|
-
@ IN SOA ns1.lunacycle.net. admin.lunacycle.net. (
|
|
5
|
-
2024020108 300 60 604800 300
|
|
6
|
-
)
|
|
7
|
-
|
|
8
|
-
; Public DNS — RFC 1918 addresses NEVER appear here. The whole point of
|
|
9
|
-
; the e2e simulation is to model real internet connectivity, where
|
|
10
|
-
; private IPs are unreachable from outside. All public-facing names
|
|
11
|
-
; resolve to the firewall's external IP (100.100.0.100), which DNATs
|
|
12
|
-
; inbound to the right internal host.
|
|
13
|
-
@ IN NS ns1.lunacycle.net.
|
|
14
|
-
ns1 IN A 100.64.0.55
|
|
15
|
-
@ IN A 100.100.0.100
|
|
16
|
-
www IN A 100.100.0.100
|