@appforge-ci/core 0.1.2 → 0.2.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.
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Pure validation + addressing helpers for the site-to-site VPN
3
+ * (`VpnTunnel`, packages/db/migrations/0037_vpn.sql). No I/O, so the API
4
+ * route, CLI and dashboard all validate a tunnel config identically
5
+ * before it reaches the server, and the server never has to trust them.
6
+ *
7
+ * IPv4 only in v1: the tunnel's own /30, every LAN CIDR and the gateway's
8
+ * endpoint IP literal are IPv4. A customer gateway reachable by an IPv6
9
+ * hostname still works through DNS (a hostname endpoint is allowed) — what
10
+ * isn't supported yet is a LAN or tunnel range that is itself IPv6.
11
+ */
12
+ const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
13
+ export function parseIpv4(ip) {
14
+ const m = IPV4.exec(ip);
15
+ if (!m)
16
+ return null;
17
+ let n = 0;
18
+ for (let i = 1; i <= 4; i++) {
19
+ const part = m[i];
20
+ // "01" is ambiguous (octal in some parsers) — refuse rather than guess.
21
+ if (part.length > 1 && part.startsWith("0"))
22
+ return null;
23
+ const octet = Number(part);
24
+ if (octet > 255)
25
+ return null;
26
+ n = n * 256 + octet;
27
+ }
28
+ return n;
29
+ }
30
+ export function formatIpv4(n) {
31
+ return [(n / 2 ** 24) & 255, (n / 2 ** 16) & 255, (n / 2 ** 8) & 255, n & 255].join(".");
32
+ }
33
+ function maskOf(prefix) {
34
+ return prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
35
+ }
36
+ /** Parses "a.b.c.d/n". Returns null for anything malformed, and for a
37
+ * CIDR whose host bits are set ("10.0.0.5/24") — a silent normalize
38
+ * would hide a typo that routes a different range than the customer
39
+ * meant. */
40
+ export function parseCidr(cidr) {
41
+ const [ip, prefixText, ...rest] = cidr.split("/");
42
+ if (ip === undefined || prefixText === undefined || rest.length > 0)
43
+ return null;
44
+ if (!/^\d{1,2}$/.test(prefixText))
45
+ return null;
46
+ const prefix = Number(prefixText);
47
+ if (prefix > 32)
48
+ return null;
49
+ const addr = parseIpv4(ip);
50
+ if (addr === null)
51
+ return null;
52
+ if ((addr & maskOf(prefix)) >>> 0 !== addr)
53
+ return null;
54
+ return { network: addr, prefix };
55
+ }
56
+ export function cidrsOverlap(a, b) {
57
+ const prefix = Math.min(a.prefix, b.prefix);
58
+ const mask = maskOf(prefix);
59
+ return ((a.network & mask) >>> 0) === ((b.network & mask) >>> 0);
60
+ }
61
+ function cidrContainsIp(cidr, ip) {
62
+ return ((ip & maskOf(cidr.prefix)) >>> 0) === cidr.network;
63
+ }
64
+ const cidr = (text) => parseCidr(text);
65
+ /** Ranges a customer LAN must never claim: they are either never a real
66
+ * site network or would hijack traffic the build needs to go direct. */
67
+ const FORBIDDEN_LAN_RANGES = [
68
+ { range: cidr("0.0.0.0/8"), why: "0.0.0.0/8 is not a routable network" },
69
+ { range: cidr("127.0.0.0/8"), why: "127.0.0.0/8 is loopback" },
70
+ { range: cidr("169.254.0.0/16"), why: "169.254.0.0/16 is link-local (and reserved for the tunnel's own /30)" },
71
+ { range: cidr("224.0.0.0/3"), why: "224.0.0.0/3 is multicast/reserved" },
72
+ ];
73
+ /** Where tunnel /30s are allocated from. Link-local space: no customer LAN
74
+ * may use it (see above), and it is never routed on the public internet,
75
+ * so a collision needs a customer network that already misuses it. The
76
+ * 169.254.169.0/24 slice is skipped — cloud metadata services live at
77
+ * 169.254.169.254. */
78
+ const TUNNEL_POOL = cidr("169.254.192.0/18");
79
+ const TUNNEL_POOL_SKIP = cidr("169.254.169.0/24");
80
+ export const MAX_LAN_CIDRS = 16;
81
+ export const MIN_LAN_PREFIX = 8;
82
+ /** The two usable addresses of a tunnel /30: `gateway` is the customer
83
+ * side (network + 1), `local` is AppForge's side (network + 2). */
84
+ export function tunnelAddresses(tunnelCidr) {
85
+ const parsed = parseCidr(tunnelCidr);
86
+ if (!parsed || parsed.prefix !== 30)
87
+ return null;
88
+ return { gateway: formatIpv4(parsed.network + 1), local: formatIpv4(parsed.network + 2) };
89
+ }
90
+ /**
91
+ * Picks a free /30 from the tunnel pool that doesn't overlap any of the
92
+ * customer's LAN CIDRs. `random` returns a float in [0, 1) — injected so
93
+ * tests are deterministic and the Worker can pass a crypto-backed source.
94
+ */
95
+ export function allocateTunnelCidr(lanCidrs, random) {
96
+ const lans = lanCidrs.map(parseCidr).filter((c) => c !== null);
97
+ const slots = 2 ** (30 - TUNNEL_POOL.prefix);
98
+ for (let attempt = 0; attempt < 64; attempt++) {
99
+ const slot = Math.floor(random() * slots);
100
+ const candidate = { network: TUNNEL_POOL.network + slot * 4, prefix: 30 };
101
+ if (cidrsOverlap(candidate, TUNNEL_POOL_SKIP))
102
+ continue;
103
+ if (lans.some((lan) => cidrsOverlap(lan, candidate)))
104
+ continue;
105
+ return `${formatIpv4(candidate.network)}/30`;
106
+ }
107
+ throw new Error("could not allocate a tunnel range that avoids the declared LAN ranges");
108
+ }
109
+ /** A WireGuard key is 32 bytes, base64-encoded (44 chars, one trailing '='). */
110
+ export function isWireguardKey(value) {
111
+ if (!/^[A-Za-z0-9+/]{43}=$/.test(value))
112
+ return false;
113
+ // 43 base64 chars carry 258 bits; the final char's low two bits must be 0
114
+ // for a canonical 32-byte encoding.
115
+ return "AEIMQUYcgkosw048".includes(value[42]);
116
+ }
117
+ const HOSTNAME = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/i;
118
+ /** True for an IPv4 address (as a 32-bit integer, see `parseIpv4`) that is
119
+ * never a customer's public gateway: pointing the Mac's UDP at one would aim
120
+ * it at the Mac's own network. Exported for the agent, which must re-check a
121
+ * gateway *hostname's* resolved addresses with the same rule right before it
122
+ * dials (a DNS name can resolve to a private IP; the Worker can't see that).
123
+ *
124
+ * This is the DEFAULT rule. A tunnel in "lab mode" (`allowPrivateEndpoint`,
125
+ * migrations/0040) swaps it for the narrower `isForbiddenEndpointIpv4`. */
126
+ export function isNonPublicIpv4(ip) {
127
+ return [
128
+ cidr("0.0.0.0/8"),
129
+ cidr("10.0.0.0/8"),
130
+ cidr("100.64.0.0/10"),
131
+ cidr("127.0.0.0/8"),
132
+ cidr("169.254.0.0/16"),
133
+ cidr("172.16.0.0/12"),
134
+ cidr("192.168.0.0/16"),
135
+ cidr("224.0.0.0/3"),
136
+ ].some((range) => cidrContainsIp(range, ip));
137
+ }
138
+ /**
139
+ * The subset of `isNonPublicIpv4` that must be refused EVEN IN LAB MODE: the
140
+ * RFC1918 (10/8, 172.16/12, 192.168/16) and CGNAT (100.64/10) ranges are the
141
+ * ones a lab or an on-site Mac legitimately needs, so they are exactly what
142
+ * is left out here. What remains has no legitimate gateway behind it and is
143
+ * dangerous to aim the Mac's UDP at:
144
+ * - 127.0.0.0/8: the Mac itself (and anything bound to loopback on it);
145
+ * - 169.254.0.0/16: link-local, including the cloud-metadata address
146
+ * 169.254.169.254 and AppForge's own tunnel pool;
147
+ * - 224.0.0.0/3: multicast, the 240/4 reserved block and 255.255.255.255
148
+ * broadcast;
149
+ * - 0.0.0.0/8: "this host" / unspecified.
150
+ * Mirrored in the agent (vpn-session.ts) and the Go helper
151
+ * (packages/vpn-proxy/internal/config) — keep all three identical.
152
+ */
153
+ export function isForbiddenEndpointIpv4(ip) {
154
+ return [cidr("0.0.0.0/8"), cidr("127.0.0.0/8"), cidr("169.254.0.0/16"), cidr("224.0.0.0/3")].some((range) => cidrContainsIp(range, ip));
155
+ }
156
+ /** `host:port` where host is a DNS name or a *public* IPv4 literal (or, with
157
+ * `allowPrivate`, a public-or-RFC1918/CGNAT literal). A hostname is only
158
+ * syntax-checked here; what it resolves to is the agent's job, with the same
159
+ * predicate that matches `allowPrivate`. */
160
+ export function parseEndpoint(endpoint, opts = {}) {
161
+ const idx = endpoint.lastIndexOf(":");
162
+ if (idx <= 0)
163
+ return { error: "gatewayEndpoint must be host:port" };
164
+ const host = endpoint.slice(0, idx);
165
+ const portText = endpoint.slice(idx + 1);
166
+ if (!/^\d{1,5}$/.test(portText) || Number(portText) < 1 || Number(portText) > 65535) {
167
+ return { error: "gatewayEndpoint port must be 1-65535" };
168
+ }
169
+ const port = Number(portText);
170
+ if (host.startsWith("[") || host.includes(":")) {
171
+ return { error: "IPv6 literals are not supported as a gateway endpoint — use a DNS name" };
172
+ }
173
+ const ip = parseIpv4(host);
174
+ if (ip !== null) {
175
+ if (opts.allowPrivate) {
176
+ if (isForbiddenEndpointIpv4(ip)) {
177
+ return {
178
+ error: "gatewayEndpoint may not be a loopback, link-local (including cloud metadata), multicast/reserved or 0.0.0.0/8 address, even when private endpoints are allowed",
179
+ };
180
+ }
181
+ }
182
+ else if (isNonPublicIpv4(ip)) {
183
+ return { error: "gatewayEndpoint must be a publicly reachable address, not a private/loopback/link-local IP" };
184
+ }
185
+ return { host, port };
186
+ }
187
+ if (/^\d+(\.\d+)*$/.test(host))
188
+ return { error: "gatewayEndpoint host is not a valid IPv4 address" };
189
+ if (!HOSTNAME.test(host))
190
+ return { error: "gatewayEndpoint host is not a valid DNS name" };
191
+ // inet_aton-style resolvers read "0x7f.0.0.1", "0xa9.0xfe.0xa9.0xfe" or
192
+ // "0x0a.1" as IPv4 addresses (loopback, cloud metadata, 10.0.0.1), which
193
+ // the check above would otherwise wave through as "a DNS name". No real
194
+ // hostname has an all-digit or 0x-prefixed label, so refuse them anywhere
195
+ // — lab mode included, since the forbidden set must hold in every spelling.
196
+ if (host.split(".").some((label) => /^(0x[0-9a-f]*|\d+)$/i.test(label))) {
197
+ return { error: "gatewayEndpoint host has a numeric label that resolvers may read as an IP address — use a real DNS name or a plain dotted IPv4" };
198
+ }
199
+ if (host.toLowerCase() === "localhost" || host.toLowerCase().endsWith(".localhost")) {
200
+ return { error: "gatewayEndpoint must be publicly reachable, not localhost" };
201
+ }
202
+ return { host: host.toLowerCase(), port };
203
+ }
204
+ const DOMAIN_SUFFIX = /^(\*\.)?[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/i;
205
+ /**
206
+ * Validates a complete tunnel config and returns every problem found (an
207
+ * empty array means valid) — callers show them all at once rather than one
208
+ * round trip per mistake. Checks the cross-field rules a per-field zod
209
+ * schema can't: the tunnel /30 must not overlap any LAN range, and the
210
+ * DNS server must sit inside a LAN range (otherwise its queries would not
211
+ * go through the tunnel at all). `opts.allowPrivate` is the tunnel's lab-mode
212
+ * flag; callers pass the tunnel's *effective* value (never one the requester
213
+ * isn't entitled to set), and nothing else in the config is relaxed by it.
214
+ */
215
+ export function validateVpnTunnelConfig(config, opts = {}) {
216
+ const errors = [];
217
+ const endpoint = parseEndpoint(config.gatewayEndpoint, opts);
218
+ if ("error" in endpoint)
219
+ errors.push(endpoint.error);
220
+ if (!isWireguardKey(config.gatewayPublicKey)) {
221
+ errors.push("gatewayPublicKey must be a base64-encoded 32-byte WireGuard public key");
222
+ }
223
+ const lans = [];
224
+ if (config.lanCidrs.length === 0)
225
+ errors.push("lanCidrs must contain at least one CIDR");
226
+ if (config.lanCidrs.length > MAX_LAN_CIDRS)
227
+ errors.push(`lanCidrs may contain at most ${MAX_LAN_CIDRS} CIDRs`);
228
+ const seen = new Set();
229
+ for (const text of config.lanCidrs) {
230
+ const parsed = parseCidr(text);
231
+ if (!parsed) {
232
+ errors.push(`"${text}" is not a valid IPv4 CIDR with the host bits zeroed (e.g. 10.20.0.0/16)`);
233
+ continue;
234
+ }
235
+ if (seen.has(text)) {
236
+ errors.push(`"${text}" is listed more than once`);
237
+ continue;
238
+ }
239
+ seen.add(text);
240
+ if (parsed.prefix < MIN_LAN_PREFIX) {
241
+ errors.push(`"${text}" is too broad — LAN ranges must be /${MIN_LAN_PREFIX} or narrower (default routes are never allowed)`);
242
+ continue;
243
+ }
244
+ const forbidden = FORBIDDEN_LAN_RANGES.find((f) => cidrsOverlap(f.range, parsed));
245
+ if (forbidden) {
246
+ errors.push(`"${text}" overlaps a range that can't be tunneled: ${forbidden.why}`);
247
+ continue;
248
+ }
249
+ lans.push(parsed);
250
+ }
251
+ const tunnel = parseCidr(config.tunnelCidr);
252
+ if (!tunnel || tunnel.prefix !== 30) {
253
+ errors.push("tunnelCidr must be a /30 (e.g. 169.254.200.0/30)");
254
+ }
255
+ else {
256
+ if (lans.some((lan) => cidrsOverlap(lan, tunnel))) {
257
+ errors.push("tunnelCidr overlaps one of the lanCidrs — pick a different /30");
258
+ }
259
+ if (cidrsOverlap(tunnel, TUNNEL_POOL_SKIP) ||
260
+ cidrsOverlap(tunnel, cidr("127.0.0.0/8")) ||
261
+ cidrsOverlap(tunnel, cidr("224.0.0.0/3")) ||
262
+ cidrsOverlap(tunnel, cidr("0.0.0.0/8"))) {
263
+ errors.push("tunnelCidr can't use that range (reserved for cloud metadata, loopback, multicast or 0.0.0.0/8)");
264
+ }
265
+ }
266
+ if (config.dnsServer !== undefined && config.dnsServer !== null) {
267
+ const dns = parseIpv4(config.dnsServer);
268
+ if (dns === null)
269
+ errors.push("dnsServer must be an IPv4 address");
270
+ else if (lans.length > 0 && !lans.some((lan) => cidrContainsIp(lan, dns))) {
271
+ errors.push("dnsServer must be inside one of the lanCidrs so its queries go through the tunnel");
272
+ }
273
+ }
274
+ const domains = config.allowedDomains ?? [];
275
+ if (domains.length > 32)
276
+ errors.push("allowedDomains may contain at most 32 entries");
277
+ for (const d of domains) {
278
+ if (!DOMAIN_SUFFIX.test(d))
279
+ errors.push(`"${d}" is not a valid domain (use example.com or *.example.com)`);
280
+ }
281
+ if (domains.length > 0 && (config.dnsServer === undefined || config.dnsServer === null)) {
282
+ errors.push("allowedDomains needs a dnsServer to resolve them through the tunnel");
283
+ }
284
+ return errors;
285
+ }
286
+ /**
287
+ * The text the customer pastes into their own gateway so it accepts our
288
+ * side of the tunnel. Plain WireGuard `[Peer]` syntax — wg-quick, pfSense,
289
+ * OPNsense, MikroTik, FortiGate and UniFi all take these four values. The
290
+ * Mac dials out, so there is deliberately no `Endpoint` line: the gateway
291
+ * learns our address from the handshake and follows it as Macs change.
292
+ */
293
+ export function renderGatewayPeerConfig(input) {
294
+ const addrs = tunnelAddresses(input.tunnelCidr);
295
+ if (!addrs)
296
+ throw new Error("tunnelCidr must be a /30");
297
+ // The customer pastes this into a gateway whose wg-quick runs as root, so
298
+ // every interpolated value must be provably a single line of the shape we
299
+ // expect — a stray newline would let one value inject a whole extra
300
+ // `[Interface]`/`PostUp`/`[Peer]` stanza. Keys and CIDRs are re-checked
301
+ // here even though the route validated them; the org name is free text any
302
+ // org member can set (PATCH /orgs/:orgId), so it is stripped to printable
303
+ // characters on one line and only ever lands inside a `#` comment.
304
+ if (!isWireguardKey(input.ourPublicKey))
305
+ throw new Error("ourPublicKey is not a WireGuard key");
306
+ if (!isWireguardKey(input.presharedKey))
307
+ throw new Error("presharedKey is not a WireGuard key");
308
+ for (const lan of input.lanCidrs) {
309
+ if (parseCidr(lan) === null)
310
+ throw new Error(`lanCidrs entry is not a CIDR: ${JSON.stringify(lan)}`);
311
+ }
312
+ const orgName = input.orgName.replace(/[^\p{L}\p{N} ._,'()&+/-]+/gu, " ").replace(/\s+/g, " ").trim().slice(0, 80);
313
+ return [
314
+ `# AppForge CI site-to-site tunnel for ${orgName || "your organization"}`,
315
+ `# 1. On your gateway's WireGuard interface, use address ${addrs.gateway}/30.`,
316
+ `# 2. Add this peer. AppForge dials out to you, so no Endpoint is needed.`,
317
+ `[Peer]`,
318
+ `PublicKey = ${input.ourPublicKey}`,
319
+ `PresharedKey = ${input.presharedKey}`,
320
+ `AllowedIPs = ${addrs.local}/32`,
321
+ `# 3. Allow ${addrs.local} to reach only: ${input.lanCidrs.join(", ")}`,
322
+ `# and either masquerade it or route ${input.tunnelCidr} back to this interface.`,
323
+ ].join("\n");
324
+ }
325
+ //# sourceMappingURL=vpn-network.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vpn-network.js","sourceRoot":"","sources":["../src/vpn-network.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=vpn-network.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vpn-network.test.d.ts","sourceRoot":"","sources":["../src/vpn-network.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,296 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { allocateTunnelCidr, cidrsOverlap, formatIpv4, isForbiddenEndpointIpv4, isNonPublicIpv4, isWireguardKey, parseCidr, parseEndpoint, parseIpv4, renderGatewayPeerConfig, tunnelAddresses, validateVpnTunnelConfig, } from "./vpn-network.js";
4
+ // 32 zero bytes, base64 — a syntactically valid WireGuard key.
5
+ const KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
6
+ const valid = (over = {}) => ({
7
+ gatewayEndpoint: "vpn.example.com:51820",
8
+ gatewayPublicKey: KEY,
9
+ lanCidrs: ["10.20.0.0/16"],
10
+ dnsServer: "10.20.0.2",
11
+ allowedDomains: ["corp.example.com"],
12
+ tunnelCidr: "169.254.200.0/30",
13
+ ...over,
14
+ });
15
+ test("parseIpv4/formatIpv4 round-trip and reject malformed or ambiguous input", () => {
16
+ assert.equal(formatIpv4(parseIpv4("10.20.30.40")), "10.20.30.40");
17
+ assert.equal(formatIpv4(parseIpv4("255.255.255.255")), "255.255.255.255");
18
+ for (const bad of ["1.2.3", "256.1.1.1", "1.2.3.4.5", "01.2.3.4", "a.b.c.d", "1.2.3.-4", ""]) {
19
+ assert.equal(parseIpv4(bad), null, bad);
20
+ }
21
+ });
22
+ test("parseCidr rejects set host bits, bad prefixes and extra slashes instead of normalizing", () => {
23
+ assert.deepEqual(parseCidr("10.20.0.0/16"), { network: parseIpv4("10.20.0.0"), prefix: 16 });
24
+ for (const bad of ["10.20.0.5/16", "10.0.0.0/33", "10.0.0.0", "10.0.0.0/", "10.0.0.0/16/1", "10.0.0.0/-1", "10.0.0.0/ 8"]) {
25
+ assert.equal(parseCidr(bad), null, bad);
26
+ }
27
+ assert.ok(parseCidr("0.0.0.0/0"));
28
+ });
29
+ test("cidrsOverlap is symmetric and handles containment and adjacency", () => {
30
+ const p = (c) => parseCidr(c);
31
+ assert.equal(cidrsOverlap(p("10.0.0.0/8"), p("10.20.0.0/16")), true);
32
+ assert.equal(cidrsOverlap(p("10.20.0.0/16"), p("10.0.0.0/8")), true);
33
+ assert.equal(cidrsOverlap(p("10.20.0.0/16"), p("10.21.0.0/16")), false);
34
+ assert.equal(cidrsOverlap(p("10.20.0.0/24"), p("10.20.0.0/24")), true);
35
+ });
36
+ test("tunnelAddresses: customer gateway is .1, AppForge side is .2, only for a /30", () => {
37
+ assert.deepEqual(tunnelAddresses("169.254.200.4/30"), { gateway: "169.254.200.5", local: "169.254.200.6" });
38
+ assert.equal(tunnelAddresses("169.254.200.0/29"), null);
39
+ assert.equal(tunnelAddresses("nonsense"), null);
40
+ });
41
+ test("isWireguardKey accepts a canonical 32-byte key and rejects wrong length, charset and non-canonical tails", () => {
42
+ assert.equal(isWireguardKey(KEY), true);
43
+ assert.equal(isWireguardKey("AAAA"), false);
44
+ assert.equal(isWireguardKey(KEY.replace("=", "")), false);
45
+ assert.equal(isWireguardKey("A".repeat(42) + "B="), false); // low bits of last char set
46
+ assert.equal(isWireguardKey("!".repeat(43) + "="), false);
47
+ });
48
+ test("parseEndpoint accepts DNS names and public IPv4, rejects internal targets and IPv6 literals", () => {
49
+ assert.deepEqual(parseEndpoint("VPN.Example.com:51820"), { host: "vpn.example.com", port: 51820 });
50
+ assert.deepEqual(parseEndpoint("203.0.113.9:443"), { host: "203.0.113.9", port: 443 });
51
+ for (const bad of [
52
+ "vpn.example.com",
53
+ "vpn.example.com:0",
54
+ "vpn.example.com:70000",
55
+ "vpn.example.com:abc",
56
+ "10.0.0.1:51820",
57
+ "192.168.1.1:51820",
58
+ "172.16.0.1:51820",
59
+ "127.0.0.1:51820",
60
+ "169.254.169.254:51820",
61
+ "100.64.0.1:51820",
62
+ "[2001:db8::1]:51820",
63
+ "localhost:51820",
64
+ "a.localhost:51820",
65
+ "singlelabel:51820",
66
+ "999.1.1.1:51820",
67
+ "0x7f.0.0.1:51820",
68
+ "0xa9.0xfe.0xa9.0xfe:51820",
69
+ "0x0a.1:5",
70
+ "vpn.example.123:51820",
71
+ "0xff.example.com:51820",
72
+ ]) {
73
+ assert.ok("error" in parseEndpoint(bad), bad);
74
+ }
75
+ });
76
+ test("validateVpnTunnelConfig accepts a normal config", () => {
77
+ assert.deepEqual(validateVpnTunnelConfig(valid()), []);
78
+ assert.deepEqual(validateVpnTunnelConfig(valid({ dnsServer: null, allowedDomains: [] })), []);
79
+ });
80
+ test("validateVpnTunnelConfig refuses default routes, over-broad, loopback, link-local and multicast LANs", () => {
81
+ for (const lan of ["0.0.0.0/0", "10.0.0.0/7", "127.0.0.0/8", "169.254.0.0/16", "224.0.0.0/4", "240.0.0.0/8", "0.0.0.0/8"]) {
82
+ const errors = validateVpnTunnelConfig(valid({ lanCidrs: [lan], dnsServer: null, allowedDomains: [] }));
83
+ assert.ok(errors.length > 0, lan);
84
+ }
85
+ assert.deepEqual(validateVpnTunnelConfig(valid({ lanCidrs: ["10.0.0.0/8"], dnsServer: null, allowedDomains: [] })), []);
86
+ });
87
+ test("validateVpnTunnelConfig refuses empty, duplicate, malformed and too many LAN CIDRs", () => {
88
+ assert.ok(validateVpnTunnelConfig(valid({ lanCidrs: [], dnsServer: null, allowedDomains: [] })).length > 0);
89
+ assert.ok(validateVpnTunnelConfig(valid({ lanCidrs: ["10.1.0.0/16", "10.1.0.0/16"] })).some((e) => e.includes("more than once")));
90
+ assert.ok(validateVpnTunnelConfig(valid({ lanCidrs: ["10.1.0.5/16"] })).length > 0);
91
+ const many = Array.from({ length: 17 }, (_, i) => `10.${i}.0.0/16`);
92
+ assert.ok(validateVpnTunnelConfig(valid({ lanCidrs: many, dnsServer: null, allowedDomains: [] })).some((e) => e.includes("at most")));
93
+ });
94
+ test("validateVpnTunnelConfig refuses a tunnel /30 that overlaps a LAN or sits in a reserved range", () => {
95
+ assert.ok(validateVpnTunnelConfig(valid({ tunnelCidr: "10.20.5.0/30" })).some((e) => e.includes("overlaps")));
96
+ assert.ok(validateVpnTunnelConfig(valid({ tunnelCidr: "169.254.200.0/29" })).length > 0);
97
+ assert.ok(validateVpnTunnelConfig(valid({ tunnelCidr: "169.254.169.252/30" })).length > 0); // cloud metadata
98
+ assert.ok(validateVpnTunnelConfig(valid({ tunnelCidr: "127.0.0.0/30" })).length > 0);
99
+ // A customer who already uses 169.254.200.x on their LAN can't use it for the LAN (link-local is
100
+ // forbidden there), so the override they'd reach for is a private /30 outside their LAN:
101
+ assert.deepEqual(validateVpnTunnelConfig(valid({ tunnelCidr: "172.31.255.0/30" })), []);
102
+ });
103
+ test("validateVpnTunnelConfig requires the DNS server inside a LAN range and domains to have one", () => {
104
+ assert.ok(validateVpnTunnelConfig(valid({ dnsServer: "8.8.8.8" })).some((e) => e.includes("inside one of")));
105
+ assert.ok(validateVpnTunnelConfig(valid({ dnsServer: "not-an-ip" })).length > 0);
106
+ assert.ok(validateVpnTunnelConfig(valid({ dnsServer: null })).some((e) => e.includes("needs a dnsServer")));
107
+ assert.ok(validateVpnTunnelConfig(valid({ allowedDomains: ["not a domain"] })).length > 0);
108
+ assert.deepEqual(validateVpnTunnelConfig(valid({ allowedDomains: ["*.corp.example.com"] })), []);
109
+ });
110
+ test("validateVpnTunnelConfig reports every problem at once", () => {
111
+ const errors = validateVpnTunnelConfig({
112
+ gatewayEndpoint: "10.0.0.1",
113
+ gatewayPublicKey: "short",
114
+ lanCidrs: ["0.0.0.0/0"],
115
+ tunnelCidr: "x",
116
+ });
117
+ assert.ok(errors.length >= 4, errors.join(" | "));
118
+ });
119
+ test("allocateTunnelCidr returns a /30 in the pool, never the metadata slice, and avoids declared LANs", () => {
120
+ for (const r of [0, 0.25, 0.5, 0.999999]) {
121
+ const out = allocateTunnelCidr(["10.0.0.0/8"], () => r);
122
+ assert.deepEqual(validateVpnTunnelConfig(valid({ tunnelCidr: out, lanCidrs: ["10.0.0.0/8"], dnsServer: null, allowedDomains: [] })), []);
123
+ assert.ok(out.startsWith("169.254."));
124
+ assert.ok(!out.startsWith("169.254.169."));
125
+ }
126
+ // A random source stuck on the metadata slice still terminates with a valid answer.
127
+ let calls = 0;
128
+ const out = allocateTunnelCidr([], () => (calls++ < 3 ? 0.15 : 0.9));
129
+ assert.ok(!out.startsWith("169.254.169."));
130
+ });
131
+ test("allocateTunnelCidr throws rather than looping forever when nothing is free", () => {
132
+ // A random source that always lands on the same slot, with a LAN covering it.
133
+ assert.throws(() => allocateTunnelCidr(["169.254.192.0/18"], () => 0.5));
134
+ });
135
+ test("renderGatewayPeerConfig gives the customer a Peer block with our key, PSK and a /32 for our side, and no Endpoint", () => {
136
+ const text = renderGatewayPeerConfig({
137
+ orgName: "Acme",
138
+ tunnelCidr: "169.254.200.0/30",
139
+ ourPublicKey: KEY,
140
+ presharedKey: KEY,
141
+ lanCidrs: ["10.20.0.0/16", "10.30.0.0/16"],
142
+ });
143
+ assert.match(text, /\[Peer\]/);
144
+ assert.match(text, new RegExp(`^PublicKey = ${KEY}$`, "m"));
145
+ assert.match(text, new RegExp(`^PresharedKey = ${KEY}$`, "m"));
146
+ assert.match(text, /AllowedIPs = 169\.254\.200\.2\/32/);
147
+ assert.match(text, /address 169\.254\.200\.1\/30/);
148
+ assert.match(text, /10\.20\.0\.0\/16, 10\.30\.0\.0\/16/);
149
+ assert.doesNotMatch(text, /^Endpoint\s*=/m);
150
+ assert.throws(() => renderGatewayPeerConfig({ orgName: "x", tunnelCidr: "10.0.0.0/24", ourPublicKey: KEY, presharedKey: KEY, lanCidrs: [] }));
151
+ });
152
+ test("renderGatewayPeerConfig can't be made to emit extra stanzas through the org name, keys or LANs", () => {
153
+ const base = { tunnelCidr: "169.254.200.0/30", ourPublicKey: KEY, presharedKey: KEY, lanCidrs: ["10.20.0.0/16"] };
154
+ const evil = "Acme\n[Interface]\nPostUp = curl evil|sh\r\n[Peer]\nPublicKey = X\u2028AllowedIPs = 0.0.0.0/0";
155
+ const text = renderGatewayPeerConfig({ ...base, orgName: evil });
156
+ // Exactly the one [Peer] we wrote, no [Interface], and nothing but comments around it.
157
+ assert.equal(text.match(/^\[/gm)?.length, 1);
158
+ assert.doesNotMatch(text, /^\s*(\[Interface\]|PostUp)/m);
159
+ for (const line of text.split("\n"))
160
+ assert.match(line, /^(#|\[Peer\]$|(PublicKey|PresharedKey|AllowedIPs) = [A-Za-z0-9+/=.\/]+$)/);
161
+ assert.ok(!/[\r\u2028\u2029]/.test(text));
162
+ assert.throws(() => renderGatewayPeerConfig({ ...base, orgName: "x", ourPublicKey: `${KEY}\nPostUp = x` }));
163
+ assert.throws(() => renderGatewayPeerConfig({ ...base, orgName: "x", presharedKey: "nope" }));
164
+ assert.throws(() => renderGatewayPeerConfig({ ...base, orgName: "x", lanCidrs: ["10.0.0.0/8\nPostUp = x"] }));
165
+ // A name that is nothing but junk still yields a sensible header.
166
+ assert.match(renderGatewayPeerConfig({ ...base, orgName: "\n\n\t" }), /for your organization/);
167
+ // Ordinary punctuation and non-ASCII letters survive.
168
+ assert.match(renderGatewayPeerConfig({ ...base, orgName: "Müller & Söhne (EE)" }), /for Müller & Söhne \(EE\)/);
169
+ });
170
+ test("isNonPublicIpv4 flags every range a customer gateway can never legitimately be in, and passes public addresses", () => {
171
+ for (const ip of ["0.0.0.1", "10.1.2.3", "100.64.0.1", "100.127.255.255", "127.0.0.1", "169.254.169.254", "172.16.0.1", "172.31.255.255", "192.168.1.1", "224.0.0.1", "239.255.255.255", "240.0.0.1", "255.255.255.255"]) {
172
+ assert.equal(isNonPublicIpv4(parseIpv4(ip)), true, ip);
173
+ }
174
+ for (const ip of ["8.8.8.8", "1.1.1.1", "203.0.113.9", "172.15.255.255", "172.32.0.1", "100.63.255.255", "100.128.0.1", "192.169.0.1"]) {
175
+ assert.equal(isNonPublicIpv4(parseIpv4(ip)), false, ip);
176
+ }
177
+ });
178
+ // ───────────── lab mode (allowPrivate): private endpoints, forbidden set intact ─────────────
179
+ const FORBIDDEN_IPS = [
180
+ "0.0.0.0", "0.1.2.3", "0.255.255.255",
181
+ "127.0.0.1", "127.255.255.255", "127.1.2.3",
182
+ "169.254.0.1", "169.254.169.254", "169.254.192.1", "169.254.255.255",
183
+ "224.0.0.1", "239.255.255.255", "240.0.0.1", "255.255.255.255",
184
+ ];
185
+ const LAB_OK_IPS = [
186
+ "10.0.0.1", "10.255.255.255", "172.16.0.1", "172.31.255.255", "192.168.0.1", "192.168.1.50",
187
+ "100.64.0.1", "100.127.255.255", "8.8.8.8", "203.0.113.9",
188
+ ];
189
+ test("isForbiddenEndpointIpv4 covers exactly loopback, link-local, multicast/reserved and 0/8 — with exact range edges", () => {
190
+ for (const ip of FORBIDDEN_IPS)
191
+ assert.equal(isForbiddenEndpointIpv4(parseIpv4(ip)), true, ip);
192
+ for (const ip of LAB_OK_IPS)
193
+ assert.equal(isForbiddenEndpointIpv4(parseIpv4(ip)), false, ip);
194
+ // Neighbours of each forbidden block are NOT forbidden.
195
+ for (const ip of ["1.0.0.0", "126.255.255.255", "128.0.0.0", "169.253.255.255", "169.255.0.0", "223.255.255.255"]) {
196
+ assert.equal(isForbiddenEndpointIpv4(parseIpv4(ip)), false, ip);
197
+ }
198
+ });
199
+ test("isForbiddenEndpointIpv4 is a strict subset of isNonPublicIpv4 (lab mode can only relax, never newly allow something unsafe)", () => {
200
+ // Sweep every /16 base plus a few interior points across the whole space.
201
+ for (let hi = 0; hi < 256; hi++) {
202
+ for (let mid = 0; mid < 256; mid += 17) {
203
+ for (const lo of [0, 1, 254, 255]) {
204
+ const ip = hi * 2 ** 24 + mid * 2 ** 16 + lo;
205
+ if (isForbiddenEndpointIpv4(ip))
206
+ assert.equal(isNonPublicIpv4(ip), true, formatIpv4(ip));
207
+ }
208
+ }
209
+ }
210
+ // And the relaxation is exactly RFC1918 + CGNAT.
211
+ const relaxed = (ip) => isNonPublicIpv4(parseIpv4(ip)) && !isForbiddenEndpointIpv4(parseIpv4(ip));
212
+ for (const ip of ["10.9.9.9", "172.20.1.1", "192.168.9.9", "100.100.100.100"])
213
+ assert.equal(relaxed(ip), true, ip);
214
+ for (const ip of ["8.8.8.8", "127.0.0.1", "169.254.169.254"])
215
+ assert.equal(relaxed(ip), false, ip);
216
+ });
217
+ test("parseEndpoint without allowPrivate is unchanged: every private literal is refused", () => {
218
+ for (const ip of ["10.0.0.1", "172.16.0.1", "172.31.0.1", "192.168.1.50", "100.64.0.1", ...FORBIDDEN_IPS]) {
219
+ assert.ok("error" in parseEndpoint(`${ip}:51820`), ip);
220
+ assert.ok("error" in parseEndpoint(`${ip}:51820`, {}), ip);
221
+ assert.ok("error" in parseEndpoint(`${ip}:51820`, { allowPrivate: false }), ip);
222
+ }
223
+ });
224
+ test("parseEndpoint with allowPrivate accepts RFC1918 and CGNAT literals", () => {
225
+ for (const ip of ["10.0.0.1", "10.255.255.255", "172.16.0.1", "172.31.255.255", "192.168.1.50", "100.64.0.1", "100.127.255.255"]) {
226
+ assert.deepEqual(parseEndpoint(`${ip}:51820`, { allowPrivate: true }), { host: ip, port: 51820 }, ip);
227
+ }
228
+ // Public literals and DNS names stay fine too.
229
+ assert.deepEqual(parseEndpoint("203.0.113.9:443", { allowPrivate: true }), { host: "203.0.113.9", port: 443 });
230
+ assert.deepEqual(parseEndpoint("Gw.Lab.Example.com:51820", { allowPrivate: true }), { host: "gw.lab.example.com", port: 51820 });
231
+ });
232
+ test("parseEndpoint with allowPrivate still refuses loopback, link-local/metadata, multicast/reserved and 0/8, and says which", () => {
233
+ for (const ip of FORBIDDEN_IPS) {
234
+ const r = parseEndpoint(`${ip}:51820`, { allowPrivate: true });
235
+ assert.ok("error" in r, ip);
236
+ assert.match(r.error, /loopback, link-local.*multicast.*0\.0\.0\.0\/8/, ip);
237
+ assert.match(r.error, /even when private endpoints are allowed/, ip);
238
+ }
239
+ // The default-mode wording is untouched.
240
+ const plain = parseEndpoint("192.168.1.50:51820");
241
+ assert.match(plain.error, /publicly reachable/);
242
+ });
243
+ test("parseEndpoint with allowPrivate still refuses every adversarial spelling of a forbidden address", () => {
244
+ for (const bad of [
245
+ // inet_aton shorthand / hex / octal / decimal: resolvers read these as loopback or metadata.
246
+ "0x7f.0.0.1:51820",
247
+ "0xa9.0xfe.0xa9.0xfe:51820",
248
+ "0x0a.1:5",
249
+ "0177.0.0.1:51820",
250
+ "2130706433:51820",
251
+ "0xa9fea9fe:51820",
252
+ "127.1:51820",
253
+ "169.254.43518:51820",
254
+ "01.2.3.4:51820",
255
+ "127.000.000.001:51820",
256
+ // IPv6 forms, including mapped loopback/metadata.
257
+ "[::1]:51820",
258
+ "::1:51820",
259
+ "[::ffff:127.0.0.1]:51820",
260
+ "[::ffff:169.254.169.254]:51820",
261
+ "[::ffff:192.168.1.50]:51820",
262
+ // localhost spellings.
263
+ "localhost:51820",
264
+ "LOCALHOST:51820",
265
+ "a.localhost:51820",
266
+ // Not a host at all.
267
+ "192.168.1.50",
268
+ "192.168.1.50:0",
269
+ "192.168.1.50:65536",
270
+ " 192.168.1.50:51820",
271
+ "192.168.1.50 :51820",
272
+ "192.168.1.50.:51820",
273
+ "192.168.1.256:51820",
274
+ "999.1.1.1:51820",
275
+ ]) {
276
+ assert.ok("error" in parseEndpoint(bad, { allowPrivate: true }), bad);
277
+ }
278
+ });
279
+ test("validateVpnTunnelConfig threads allowPrivate to the endpoint check only", () => {
280
+ const priv = valid({ gatewayEndpoint: "192.168.1.50:51820" });
281
+ assert.equal(validateVpnTunnelConfig(priv).length, 1);
282
+ assert.match(validateVpnTunnelConfig(priv)[0], /publicly reachable/);
283
+ assert.deepEqual(validateVpnTunnelConfig(priv, { allowPrivate: true }), []);
284
+ assert.deepEqual(validateVpnTunnelConfig(valid({ gatewayEndpoint: "100.64.1.1:51820" }), { allowPrivate: true }), []);
285
+ for (const ip of ["169.254.169.254", "127.0.0.1", "0.0.0.0", "224.0.0.1", "255.255.255.255"]) {
286
+ const errs = validateVpnTunnelConfig(valid({ gatewayEndpoint: `${ip}:51820` }), { allowPrivate: true });
287
+ assert.equal(errs.length, 1, ip);
288
+ assert.match(errs[0], /even when private endpoints are allowed/, ip);
289
+ }
290
+ // Nothing else is relaxed: a bad key, LAN and tunnel are still reported alongside.
291
+ const errs = validateVpnTunnelConfig(valid({ gatewayEndpoint: "192.168.1.50:51820", gatewayPublicKey: "nope", lanCidrs: ["0.0.0.0/0"] }), { allowPrivate: true });
292
+ assert.ok(errs.some((e) => /gatewayPublicKey/.test(e)));
293
+ assert.ok(errs.some((e) => /too broad/.test(e)));
294
+ assert.ok(!errs.some((e) => /gatewayEndpoint/.test(e)));
295
+ });
296
+ //# sourceMappingURL=vpn-network.test.js.map