@celilo/e2e 0.10.4 → 0.11.1
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/README.md +37 -28
- package/bin/e2e-status +1 -1
- package/bin/e2e-up +14 -14
- package/config/dhcp/dnsmasq.conf +3 -3
- package/config/resolver/unbound.conf +2 -2
- package/config/routing/fw-ext-routes.sh +4 -4
- package/config/routing/fw-isp-routes.sh +3 -3
- package/config/routing/fw-main-routes.sh +15 -4
- package/config/routing/management-routes.sh +11 -11
- package/config/routing/observer-setup.sh +3 -3
- package/config/routing/resolver-internal-routes.sh +5 -5
- package/config/routing/target-routes.sh +1 -1
- package/config/routing/target-setup.sh +18 -16
- package/config/socks/startup.sh +3 -3
- package/docker/Dockerfile.target-machine +15 -1
- package/docker/Dockerfile.target-machine-docker +6 -0
- package/docker/Dockerfile.target-technitium +6 -0
- package/npm-registry-server/src/packument.test.ts +2 -5
- package/npm-registry-server/src/packument.ts +3 -3
- package/npm-registry-server/src/server.ts +1 -1
- package/package.json +3 -3
- package/registry-server/src/bootstrap.ts +1 -1
- package/registry-server/src/introspection.test.ts +8 -12
- package/registry-server/src/server.test.ts +2 -2
- package/registry-server/src/server.ts +1 -1
- package/registry-server/src/storage.test.ts +8 -4
- package/registry-server/src/storage.ts +1 -1
- package/registry-server/src/validation.ts +2 -4
- package/scripts/pack-celilo-packages.ts +1 -4
- package/scripts/stage-apt-repo.ts +1 -1
- package/scripts/stage-libsignal.ts +1 -3
- package/simulators/greenwave/server.ts +4 -2
- package/simulators/greenwave/state.ts +24 -17
- package/simulators/isitup/server.ts +1 -5
- package/simulators/namecheap-ddns/server.ts +2 -4
- package/simulators/namecheap-ddns/zone-updater.test.ts +3 -1
- package/simulators/namecheap-ddns/zone-updater.ts +6 -10
- package/simulators/signal-cli/server.ts +3 -1
- package/src/address-plan.test.ts +95 -0
- package/src/browser.ts +3 -3
- package/src/bus-events.ts +2 -2
- package/src/cli/command-tree-parser.ts +8 -2
- package/src/cli/completion.ts +5 -8
- package/src/cli/index.ts +9 -2
- package/src/cli/module-discovery.ts +3 -1
- package/src/cli/scaffold.ts +2 -2
- package/src/cli-version-contract.ts +5 -6
- package/src/container-manager.ts +25 -8
- package/src/docker-compose-generator.ts +67 -39
- package/src/extract-failure.ts +92 -0
- package/src/fixtures.ts +10 -17
- package/src/index.ts +2 -2
- package/src/network-builder.ts +14 -9
- package/src/observer.test.ts +50 -19
- package/src/observer.ts +15 -8
- package/src/registry-bundle.ts +1 -3
- package/src/run-lock.test.ts +2 -2
- package/src/run-lock.ts +19 -5
- package/src/runner.ts +9 -67
- package/src/shared-infra.ts +50 -22
- package/src/socks-proxy.ts +16 -19
- package/src/types.ts +67 -17
- package/src/vantage-helpers.ts +6 -2
- package/src/vantage.test.ts +94 -40
- package/src/vantage.ts +24 -17
- package/src/wait-for-run.ts +1 -1
- package/src/zone-classifier.test.ts +32 -21
- package/src/zone-classifier.ts +7 -6
package/src/vantage.test.ts
CHANGED
|
@@ -36,10 +36,12 @@ const LAN: Vantage = 'internalDevice';
|
|
|
36
36
|
|
|
37
37
|
describe('resolveFrom', () => {
|
|
38
38
|
test('defaults method to system, passes through ips', async () => {
|
|
39
|
-
const probe = new VantageProbe(
|
|
39
|
+
const probe = new VantageProbe(
|
|
40
|
+
fakeTransport({ resolve: async () => ({ ips: ['10.226.1.253'] }) }),
|
|
41
|
+
);
|
|
40
42
|
const r = await probe.resolveFrom({ from: LAN, name: 'www.example.com' });
|
|
41
43
|
expect(r.method).toBe('system');
|
|
42
|
-
expect(r.ips).toEqual(['
|
|
44
|
+
expect(r.ips).toEqual(['10.226.1.253']);
|
|
43
45
|
});
|
|
44
46
|
});
|
|
45
47
|
|
|
@@ -51,7 +53,9 @@ describe('assertResolves / assertNxdomain', () => {
|
|
|
51
53
|
);
|
|
52
54
|
});
|
|
53
55
|
test('assertNxdomain throws when a public vantage CAN see an internal-only name', async () => {
|
|
54
|
-
const probe = new VantageProbe(
|
|
56
|
+
const probe = new VantageProbe(
|
|
57
|
+
fakeTransport({ resolve: async () => ({ ips: ['10.226.20.5'] }) }),
|
|
58
|
+
);
|
|
55
59
|
await expect(
|
|
56
60
|
probe.assertNxdomain({ from: 'publicInternet', name: 'authentik.internal' }),
|
|
57
61
|
).rejects.toThrow(/expected NXDOMAIN/);
|
|
@@ -60,100 +64,150 @@ describe('assertResolves / assertNxdomain', () => {
|
|
|
60
64
|
|
|
61
65
|
describe('assertResolvesInZone — the killer verb', () => {
|
|
62
66
|
test('ISS-0101: www at caddy DMZ container IP FAILS from internalDevice', async () => {
|
|
63
|
-
const probe = new VantageProbe(
|
|
67
|
+
const probe = new VantageProbe(
|
|
68
|
+
fakeTransport({ resolve: async () => ({ ips: ['10.226.10.10'] }) }),
|
|
69
|
+
);
|
|
64
70
|
await expect(
|
|
65
71
|
probe.assertResolvesInZone({ from: LAN, name: 'www.example.com', zone: 'internal' }),
|
|
66
72
|
).rejects.toThrow(/ISS-0101/);
|
|
67
73
|
});
|
|
68
74
|
|
|
69
75
|
test('www at the firewall natIp PASSES from internalDevice (the right answer)', async () => {
|
|
70
|
-
const probe = new VantageProbe(
|
|
71
|
-
|
|
72
|
-
|
|
76
|
+
const probe = new VantageProbe(
|
|
77
|
+
fakeTransport({ resolve: async () => ({ ips: ['10.226.1.253'] }) }),
|
|
78
|
+
);
|
|
79
|
+
const r = await probe.assertResolvesInZone({
|
|
80
|
+
from: LAN,
|
|
81
|
+
name: 'www.example.com',
|
|
82
|
+
zone: 'internal',
|
|
83
|
+
});
|
|
84
|
+
expect(r.ips).toEqual(['10.226.1.253']);
|
|
73
85
|
});
|
|
74
86
|
|
|
75
87
|
test('public vantage expecting a public answer passes; a leaked zone IP fails', async () => {
|
|
76
|
-
const ok = new VantageProbe(
|
|
77
|
-
|
|
88
|
+
const ok = new VantageProbe(
|
|
89
|
+
fakeTransport({ resolve: async () => ({ ips: ['100.100.0.100'] }) }),
|
|
90
|
+
);
|
|
91
|
+
await ok.assertResolvesInZone({
|
|
92
|
+
from: 'publicInternet',
|
|
93
|
+
name: 'www.example.com',
|
|
94
|
+
zone: 'public',
|
|
95
|
+
});
|
|
78
96
|
|
|
79
|
-
const leaked = new VantageProbe(
|
|
97
|
+
const leaked = new VantageProbe(
|
|
98
|
+
fakeTransport({ resolve: async () => ({ ips: ['10.226.1.253'] }) }),
|
|
99
|
+
);
|
|
80
100
|
await expect(
|
|
81
|
-
leaked.assertResolvesInZone({
|
|
101
|
+
leaked.assertResolvesInZone({
|
|
102
|
+
from: 'publicInternet',
|
|
103
|
+
name: 'www.example.com',
|
|
104
|
+
zone: 'public',
|
|
105
|
+
}),
|
|
82
106
|
).rejects.toThrow(VantageAssertionError);
|
|
83
107
|
});
|
|
84
108
|
|
|
85
109
|
test('any one leaked IP among several fails the whole answer', async () => {
|
|
86
110
|
const probe = new VantageProbe(
|
|
87
|
-
fakeTransport({ resolve: async () => ({ ips: ['
|
|
111
|
+
fakeTransport({ resolve: async () => ({ ips: ['10.226.1.253', '10.226.30.9'] }) }),
|
|
88
112
|
);
|
|
89
113
|
await expect(
|
|
90
114
|
probe.assertResolvesInZone({ from: LAN, name: 'www.example.com', zone: 'internal' }),
|
|
91
|
-
).rejects.toThrow(/10\.
|
|
115
|
+
).rejects.toThrow(/10\.226\.30\.9/);
|
|
92
116
|
});
|
|
93
117
|
});
|
|
94
118
|
|
|
95
119
|
describe('assertResolvesTo — exact-IP DNS correctness', () => {
|
|
96
120
|
test('passes when the resolver returns the expected IP (via a specific server)', async () => {
|
|
97
|
-
const probe = new VantageProbe(
|
|
121
|
+
const probe = new VantageProbe(
|
|
122
|
+
fakeTransport({ resolve: async () => ({ ips: ['10.226.1.53'] }) }),
|
|
123
|
+
);
|
|
98
124
|
const r = await probe.assertResolvesTo({
|
|
99
125
|
from: 'internalDevice',
|
|
100
126
|
name: 'host.infra.example.com',
|
|
101
127
|
method: 'dns',
|
|
102
|
-
server: '
|
|
103
|
-
expected: '
|
|
128
|
+
server: '10.226.1.10',
|
|
129
|
+
expected: '10.226.1.53',
|
|
104
130
|
});
|
|
105
|
-
expect(r.ips).toEqual(['
|
|
131
|
+
expect(r.ips).toEqual(['10.226.1.53']);
|
|
106
132
|
});
|
|
107
133
|
|
|
108
134
|
test('fails (naming the server) when the expected IP is absent', async () => {
|
|
109
|
-
const probe = new VantageProbe(
|
|
135
|
+
const probe = new VantageProbe(
|
|
136
|
+
fakeTransport({ resolve: async () => ({ ips: ['10.226.10.99'] }) }),
|
|
137
|
+
);
|
|
110
138
|
await expect(
|
|
111
139
|
probe.assertResolvesTo({
|
|
112
140
|
from: 'internalDevice',
|
|
113
141
|
name: 'host.infra.example.com',
|
|
114
142
|
method: 'dns',
|
|
115
|
-
server: '
|
|
116
|
-
expected: '
|
|
143
|
+
server: '10.226.1.10',
|
|
144
|
+
expected: '10.226.1.53',
|
|
117
145
|
}),
|
|
118
|
-
).rejects.toThrow(/via
|
|
146
|
+
).rejects.toThrow(/via 10\.226\.1\.10.*expected to include \[10\.226\.1\.53\]/);
|
|
119
147
|
});
|
|
120
148
|
|
|
121
149
|
test('accepts an array of expected IPs (all must be present)', async () => {
|
|
122
|
-
const probe = new VantageProbe(
|
|
123
|
-
|
|
150
|
+
const probe = new VantageProbe(
|
|
151
|
+
fakeTransport({ resolve: async () => ({ ips: ['1.2.3.4', '5.6.7.8'] }) }),
|
|
152
|
+
);
|
|
153
|
+
await probe.assertResolvesTo({
|
|
154
|
+
from: 'publicInternet',
|
|
155
|
+
name: 'x',
|
|
156
|
+
expected: ['1.2.3.4', '5.6.7.8'],
|
|
157
|
+
});
|
|
124
158
|
await expect(
|
|
125
|
-
probe.assertResolvesTo({
|
|
159
|
+
probe.assertResolvesTo({
|
|
160
|
+
from: 'publicInternet',
|
|
161
|
+
name: 'x',
|
|
162
|
+
expected: ['1.2.3.4', '9.9.9.9'],
|
|
163
|
+
}),
|
|
126
164
|
).rejects.toThrow(/9\.9\.9\.9/);
|
|
127
165
|
});
|
|
128
166
|
});
|
|
129
167
|
|
|
130
168
|
describe('reachability + isolation (§D6)', () => {
|
|
131
169
|
test('assertReachable throws when the connection fails (ISS-0111 git-ssh:2222)', async () => {
|
|
132
|
-
const probe = new VantageProbe(
|
|
133
|
-
|
|
134
|
-
/timeout/,
|
|
170
|
+
const probe = new VantageProbe(
|
|
171
|
+
fakeTransport({ tcpConnect: async () => ({ connected: false, detail: 'timeout' }) }),
|
|
135
172
|
);
|
|
173
|
+
await expect(
|
|
174
|
+
probe.assertReachable({ from: LAN, host: 'git-ssh.example.com', port: 2222 }),
|
|
175
|
+
).rejects.toThrow(/timeout/);
|
|
136
176
|
});
|
|
137
177
|
test('assertUnreachable throws when isolation is violated', async () => {
|
|
138
|
-
const probe = new VantageProbe(
|
|
139
|
-
|
|
140
|
-
/zone isolation/,
|
|
178
|
+
const probe = new VantageProbe(
|
|
179
|
+
fakeTransport({ tcpConnect: async () => ({ connected: true }) }),
|
|
141
180
|
);
|
|
181
|
+
await expect(
|
|
182
|
+
probe.assertUnreachable({ from: 'appSystem', host: '10.226.30.5', port: 443 }),
|
|
183
|
+
).rejects.toThrow(/zone isolation/);
|
|
142
184
|
});
|
|
143
185
|
test('the reachable/unreachable happy paths do not throw', async () => {
|
|
144
|
-
const reachable = new VantageProbe(
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
await
|
|
186
|
+
const reachable = new VantageProbe(
|
|
187
|
+
fakeTransport({ tcpConnect: async () => ({ connected: true }) }),
|
|
188
|
+
);
|
|
189
|
+
await reachable.assertReachable({ from: LAN, host: '10.226.1.253', port: 443 });
|
|
190
|
+
const blocked = new VantageProbe(
|
|
191
|
+
fakeTransport({ tcpConnect: async () => ({ connected: false }) }),
|
|
192
|
+
);
|
|
193
|
+
await blocked.assertUnreachable({ from: 'appSystem', host: '10.226.30.5', port: 443 });
|
|
148
194
|
});
|
|
149
195
|
});
|
|
150
196
|
|
|
151
197
|
describe('assertHttps — cert provenance', () => {
|
|
152
198
|
test('self-signed cert is rejected (catches --insecure / skip-cert cheats)', async () => {
|
|
153
199
|
const probe = new VantageProbe(
|
|
154
|
-
fakeTransport({
|
|
200
|
+
fakeTransport({
|
|
201
|
+
httpsRequest: async () => ({
|
|
202
|
+
status: 200,
|
|
203
|
+
body: 'ok',
|
|
204
|
+
cert: realCert({ selfSigned: true }),
|
|
205
|
+
}),
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
await expect(probe.assertHttps({ from: LAN, url: 'https://www.example.com' })).rejects.toThrow(
|
|
209
|
+
/self-signed/,
|
|
155
210
|
);
|
|
156
|
-
await expect(probe.assertHttps({ from: LAN, url: 'https://www.example.com' })).rejects.toThrow(/self-signed/);
|
|
157
211
|
});
|
|
158
212
|
test('wrong CA issuer is rejected', async () => {
|
|
159
213
|
const probe = new VantageProbe(fakeTransport());
|
|
@@ -188,13 +242,13 @@ describe('inspectResolution', () => {
|
|
|
188
242
|
const probe = new VantageProbe(
|
|
189
243
|
fakeTransport({
|
|
190
244
|
inspectResolution: async () => ({
|
|
191
|
-
resolvConf: 'nameserver
|
|
192
|
-
hostsLines: ['10.
|
|
245
|
+
resolvConf: 'nameserver 10.226.1.253',
|
|
246
|
+
hostsLines: ['10.226.10.10 auth.example.com'],
|
|
193
247
|
}),
|
|
194
248
|
}),
|
|
195
249
|
);
|
|
196
250
|
const r = await probe.inspectResolution({ from: 'dmzSystem', name: 'auth.example.com' });
|
|
197
|
-
expect(r.hostsLines).toContain('10.
|
|
198
|
-
expect(r.resolvConf).toContain('
|
|
251
|
+
expect(r.hostsLines).toContain('10.226.10.10 auth.example.com');
|
|
252
|
+
expect(r.resolvConf).toContain('10.226.1.253');
|
|
199
253
|
});
|
|
200
254
|
});
|
package/src/vantage.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* elsewhere, so the verbs stay pure and unit-testable without Docker.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import {
|
|
17
|
+
import type { Vantage, Zone } from './types';
|
|
18
18
|
import { classifyIp } from './zone-classifier';
|
|
19
19
|
|
|
20
20
|
// `Vantage` is topology vocabulary — defined in ./types alongside Zone — and re-exported
|
|
@@ -64,11 +64,23 @@ export interface ResolutionInspection {
|
|
|
64
64
|
* are the ONLY place raw shell / network calls live; the verbs below never touch I/O.
|
|
65
65
|
*/
|
|
66
66
|
export interface ProbeTransport {
|
|
67
|
-
resolve(opts: { from: Vantage; name: string; method: ResolveMethod; server?: string }): Promise<{
|
|
68
|
-
|
|
67
|
+
resolve(opts: { from: Vantage; name: string; method: ResolveMethod; server?: string }): Promise<{
|
|
68
|
+
ips: string[];
|
|
69
|
+
}>;
|
|
70
|
+
tcpConnect(opts: { from: Vantage; host: string; port: number }): Promise<{
|
|
71
|
+
connected: boolean;
|
|
72
|
+
detail?: string;
|
|
73
|
+
}>;
|
|
69
74
|
ping(opts: { from: Vantage; target: string }): Promise<{ alive: boolean }>;
|
|
70
|
-
httpsRequest(opts: { from: Vantage; url: string }): Promise<{
|
|
71
|
-
|
|
75
|
+
httpsRequest(opts: { from: Vantage; url: string }): Promise<{
|
|
76
|
+
status: number;
|
|
77
|
+
body: string;
|
|
78
|
+
cert: CertInfo;
|
|
79
|
+
}>;
|
|
80
|
+
inspectResolution(opts: { from: Vantage; name: string }): Promise<{
|
|
81
|
+
resolvConf: string;
|
|
82
|
+
hostsLines: string[];
|
|
83
|
+
}>;
|
|
72
84
|
}
|
|
73
85
|
|
|
74
86
|
/** Thrown when a vantage assertion fails. Messages name the bug class they guard. */
|
|
@@ -163,10 +175,7 @@ export class VantageProbe {
|
|
|
163
175
|
if (offenders.length > 0) {
|
|
164
176
|
const detail = offenders.map((ip) => `${ip} (${describeZone(ip)})`).join(', ');
|
|
165
177
|
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.',
|
|
178
|
+
`${opts.name} from ${opts.from} resolved to ${detail}, expected zone "${opts.zone}". A managed-zone container IP is unroutable from outside that zone — this is the e2e-green/prod-broke class (ISS-0101, ISS-0111). The right answer from a consumer vantage is the firewall natIp (internal) or a public address.`,
|
|
170
179
|
);
|
|
171
180
|
}
|
|
172
181
|
return result;
|
|
@@ -178,7 +187,9 @@ export class VantageProbe {
|
|
|
178
187
|
* the internal split-horizon resolver vs the public resolver). Used by the DNS suites to
|
|
179
188
|
* check a DDNS-registered IP, an nsupdate'd A record, or a split-horizon answer.
|
|
180
189
|
*/
|
|
181
|
-
async assertResolvesTo(
|
|
190
|
+
async assertResolvesTo(
|
|
191
|
+
opts: ResolveOpts & { expected: string | string[] },
|
|
192
|
+
): Promise<ResolveResult> {
|
|
182
193
|
const result = await this.assertResolves(opts);
|
|
183
194
|
const expected = Array.isArray(opts.expected) ? opts.expected : [opts.expected];
|
|
184
195
|
const missing = expected.filter((ip) => !result.ips.includes(ip));
|
|
@@ -201,9 +212,7 @@ export class VantageProbe {
|
|
|
201
212
|
const { connected, detail } = await this.transport.tcpConnect(opts);
|
|
202
213
|
if (!connected) {
|
|
203
214
|
throw new VantageAssertionError(
|
|
204
|
-
`${opts.host}:${opts.port} was NOT reachable from ${opts.from}`
|
|
205
|
-
(detail ? ` (${detail})` : '') +
|
|
206
|
-
', expected a successful TCP connection.',
|
|
215
|
+
`${opts.host}:${opts.port} was NOT reachable from ${opts.from}${detail ? ` (${detail})` : ''}, expected a successful TCP connection.`,
|
|
207
216
|
);
|
|
208
217
|
}
|
|
209
218
|
}
|
|
@@ -213,8 +222,7 @@ export class VantageProbe {
|
|
|
213
222
|
const { connected } = await this.transport.tcpConnect(opts);
|
|
214
223
|
if (connected) {
|
|
215
224
|
throw new VantageAssertionError(
|
|
216
|
-
`${opts.host}:${opts.port} WAS reachable from ${opts.from}, expected it to be blocked
|
|
217
|
-
'(zone isolation / firewall boundary violated).',
|
|
225
|
+
`${opts.host}:${opts.port} WAS reachable from ${opts.from}, expected it to be blocked (zone isolation / firewall boundary violated).`,
|
|
218
226
|
);
|
|
219
227
|
}
|
|
220
228
|
}
|
|
@@ -229,8 +237,7 @@ export class VantageProbe {
|
|
|
229
237
|
|
|
230
238
|
if (res.cert.selfSigned) {
|
|
231
239
|
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.',
|
|
240
|
+
`${opts.url} from ${opts.from} presented a self-signed cert (issuer "${res.cert.issuer}"). A real cert from the e2e CA (Pebble / Let’s Encrypt) is required — no --insecure.`,
|
|
234
241
|
);
|
|
235
242
|
}
|
|
236
243
|
if (opts.expectCN && res.cert.cn !== opts.expectCN) {
|
package/src/wait-for-run.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* decides whether to spawn cele2e themselves or just observe.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import { type Bus, type BusEvent, defineEvents, openBus } from '@celilo/event-bus';
|
|
14
14
|
|
|
15
15
|
export type WaitResult =
|
|
16
16
|
| {
|
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
classifyIp,
|
|
4
|
+
inCidr,
|
|
5
|
+
isPublicLeakSafe,
|
|
6
|
+
isSegmentedZoneIp,
|
|
7
|
+
parseIpv4,
|
|
8
|
+
} from './zone-classifier';
|
|
3
9
|
|
|
4
10
|
describe('parseIpv4', () => {
|
|
5
11
|
test('parses dotted-quad to unsigned 32-bit', () => {
|
|
6
12
|
expect(parseIpv4('0.0.0.0')).toBe(0);
|
|
7
13
|
expect(parseIpv4('255.255.255.255')).toBe(0xffffffff);
|
|
8
|
-
// High first octet must not go negative (sign-bit overflow guard).
|
|
9
|
-
|
|
10
|
-
|
|
14
|
+
// High first octet must not go negative (sign-bit overflow guard). A pure
|
|
15
|
+
// arithmetic fixture, deliberately NOT an address from the sim plan: it used
|
|
16
|
+
// to be the sim's internal natIp, so the guard would have quietly
|
|
17
|
+
// evaporated the moment that zone was renumbered into 10/8 (#539).
|
|
18
|
+
expect(parseIpv4('200.100.50.253')).toBe(((200 << 24) | (100 << 16) | (50 << 8) | 253) >>> 0);
|
|
19
|
+
expect(parseIpv4('200.100.50.253')).toBeGreaterThan(0);
|
|
11
20
|
});
|
|
12
21
|
test('rejects malformed / out-of-range', () => {
|
|
13
22
|
expect(parseIpv4('not.an.ip')).toBeNull();
|
|
@@ -18,24 +27,26 @@ describe('parseIpv4', () => {
|
|
|
18
27
|
|
|
19
28
|
describe('inCidr', () => {
|
|
20
29
|
test('membership within /24', () => {
|
|
21
|
-
expect(inCidr('10.
|
|
22
|
-
expect(inCidr('10.
|
|
23
|
-
expect(inCidr('10.
|
|
30
|
+
expect(inCidr('10.226.10.5', '10.226.10.0/24')).toBe(true);
|
|
31
|
+
expect(inCidr('10.226.10.255', '10.226.10.0/24')).toBe(true);
|
|
32
|
+
expect(inCidr('10.226.11.1', '10.226.10.0/24')).toBe(false);
|
|
24
33
|
});
|
|
25
|
-
test('high-octet subnet
|
|
26
|
-
|
|
27
|
-
|
|
34
|
+
test('high-octet subnet — no sign-bit bug', () => {
|
|
35
|
+
// Arithmetic fixture, not a sim address: a first octet >= 128 is what trips
|
|
36
|
+
// the sign bit, and no address in the sim plan has one any more.
|
|
37
|
+
expect(inCidr('200.100.50.253', '200.100.50.0/24')).toBe(true);
|
|
38
|
+
expect(inCidr('200.100.51.1', '200.100.50.0/24')).toBe(false);
|
|
28
39
|
});
|
|
29
40
|
});
|
|
30
41
|
|
|
31
42
|
describe('classifyIp', () => {
|
|
32
43
|
test('managed zones (the segmented ones)', () => {
|
|
33
|
-
expect(classifyIp('10.
|
|
34
|
-
expect(classifyIp('10.
|
|
35
|
-
expect(classifyIp('10.
|
|
44
|
+
expect(classifyIp('10.226.10.10')).toEqual({ kind: 'zone', zone: 'dmz' }); // caddy
|
|
45
|
+
expect(classifyIp('10.226.20.30')).toEqual({ kind: 'zone', zone: 'app' }); // authentik
|
|
46
|
+
expect(classifyIp('10.226.30.5')).toEqual({ kind: 'zone', zone: 'secure' });
|
|
36
47
|
});
|
|
37
48
|
test('internal zone (the LAN — natIp lives here)', () => {
|
|
38
|
-
expect(classifyIp('
|
|
49
|
+
expect(classifyIp('10.226.1.253')).toEqual({ kind: 'zone', zone: 'internal' });
|
|
39
50
|
});
|
|
40
51
|
test('public: the CGNAT stand-in and real public space', () => {
|
|
41
52
|
expect(classifyIp('100.100.0.100')).toEqual({ kind: 'public' }); // sim firewall external
|
|
@@ -56,9 +67,9 @@ describe('classifyIp', () => {
|
|
|
56
67
|
|
|
57
68
|
describe('isSegmentedZoneIp', () => {
|
|
58
69
|
test('dmz/app/secure are segmented; internal and public are not', () => {
|
|
59
|
-
expect(isSegmentedZoneIp('10.
|
|
60
|
-
expect(isSegmentedZoneIp('10.
|
|
61
|
-
expect(isSegmentedZoneIp('
|
|
70
|
+
expect(isSegmentedZoneIp('10.226.10.10')).toBe(true); // caddy DMZ — the ISS-0101/0111 bug value
|
|
71
|
+
expect(isSegmentedZoneIp('10.226.20.30')).toBe(true);
|
|
72
|
+
expect(isSegmentedZoneIp('10.226.1.253')).toBe(false); // internal IS the LAN
|
|
62
73
|
expect(isSegmentedZoneIp('100.100.0.100')).toBe(false); // public
|
|
63
74
|
});
|
|
64
75
|
});
|
|
@@ -67,16 +78,16 @@ describe('isPublicLeakSafe (CLAUDE.md inviolable rule #1)', () => {
|
|
|
67
78
|
test('only public addresses are leak-safe', () => {
|
|
68
79
|
expect(isPublicLeakSafe('100.100.0.100')).toBe(true);
|
|
69
80
|
expect(isPublicLeakSafe('8.8.8.8')).toBe(true);
|
|
70
|
-
expect(isPublicLeakSafe('10.
|
|
71
|
-
expect(isPublicLeakSafe('
|
|
81
|
+
expect(isPublicLeakSafe('10.226.10.10')).toBe(false); // managed segmented zone
|
|
82
|
+
expect(isPublicLeakSafe('10.226.1.253')).toBe(false); // internal/RFC-1918 must not leak
|
|
72
83
|
expect(isPublicLeakSafe('0.0.0.0')).toBe(false); // reserved sentinel
|
|
73
84
|
});
|
|
74
85
|
|
|
75
86
|
test('the ISS-0101 scenario: a fronted name resolving to caddy DMZ IP is NOT leak-safe', () => {
|
|
76
87
|
// www.<domain> from a LAN device must be the natIp (internal) or public, never
|
|
77
88
|
// caddy's DMZ container IP. The doctor + e2e share this judgment.
|
|
78
|
-
const wwwResolvedWrong = '10.
|
|
89
|
+
const wwwResolvedWrong = '10.226.10.10';
|
|
79
90
|
expect(isSegmentedZoneIp(wwwResolvedWrong)).toBe(true);
|
|
80
|
-
expect(classifyIp('
|
|
91
|
+
expect(classifyIp('10.226.1.253').kind).toBe('zone'); // the RIGHT answer (natIp, internal)
|
|
81
92
|
});
|
|
82
93
|
});
|
package/src/zone-classifier.ts
CHANGED
|
@@ -10,12 +10,13 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Classification uses `ZONE_SUBNETS` (exact) + a reserved-range check — deliberately
|
|
12
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
|
|
14
|
-
* *public*. (A real deployment
|
|
15
|
-
* non-public; that's the
|
|
13
|
+
* (100.100.0.100), so in this topology the RFC-1918 zones under
|
|
14
|
+
* `SIM_PRIVATE_SUPERNET` are *zones* and 100.x is *public*. (A real deployment
|
|
15
|
+
* would additionally treat RFC-1918 + RFC-6598 as non-public; that's the
|
|
16
|
+
* caller's policy, layered on top of this primitive.)
|
|
16
17
|
*/
|
|
17
18
|
|
|
18
|
-
import { type Zone
|
|
19
|
+
import { ZONE_SUBNETS, type Zone } from './types';
|
|
19
20
|
|
|
20
21
|
export type ZoneClass =
|
|
21
22
|
| { kind: 'zone'; zone: Zone }
|
|
@@ -52,7 +53,7 @@ export function parseIpv4(ip: string): number | null {
|
|
|
52
53
|
return ((octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]) >>> 0;
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
/** True if `ip` falls within `cidr` (e.g. "10.
|
|
56
|
+
/** True if `ip` falls within `cidr` (e.g. "10.226.10.0/24"). */
|
|
56
57
|
export function inCidr(ip: string, cidr: string): boolean {
|
|
57
58
|
const [base, bitsStr] = cidr.split('/');
|
|
58
59
|
const bits = Number(bitsStr);
|
|
@@ -61,7 +62,7 @@ export function inCidr(ip: string, cidr: string): boolean {
|
|
|
61
62
|
if (ipN === null || baseN === null || Number.isNaN(bits) || bits < 0 || bits > 32) return false;
|
|
62
63
|
if (bits === 0) return true;
|
|
63
64
|
const mask = (0xffffffff << (32 - bits)) >>> 0;
|
|
64
|
-
return (ipN & mask) >>> 0 === (
|
|
65
|
+
return (ipN & mask) >>> 0 === (baseN & mask) >>> 0;
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
/** Classify a resolved IPv4 against the e2e topology. */
|