@takodotid/circuit 0.4.3

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.
Files changed (38) hide show
  1. package/LICENSE +106 -0
  2. package/README.md +195 -0
  3. package/package.json +83 -0
  4. package/src/adapters/devices/catalog.ts +38 -0
  5. package/src/adapters/devices/derive.ts +27 -0
  6. package/src/adapters/devices/index.ts +7 -0
  7. package/src/adapters/devices/lines.ts +212 -0
  8. package/src/adapters/devices/raisecom-ros/TRAPS.md +22 -0
  9. package/src/adapters/devices/raisecom-ros/index.ts +603 -0
  10. package/src/adapters/devices/raisecom-ros/models.ts +7 -0
  11. package/src/adapters/devices/routeros/TRAPS.md +33 -0
  12. package/src/adapters/devices/routeros/export.ts +319 -0
  13. package/src/adapters/devices/routeros/index.ts +261 -0
  14. package/src/adapters/devices/routeros/models.ts +8 -0
  15. package/src/adapters/devices/routeros/plan.ts +370 -0
  16. package/src/adapters/devices/routeros/render.ts +1431 -0
  17. package/src/adapters/devices/types.ts +31 -0
  18. package/src/adapters/devices/vrp/TRAPS.md +22 -0
  19. package/src/adapters/devices/vrp/index.ts +742 -0
  20. package/src/adapters/devices/vrp/models.ts +4 -0
  21. package/src/adapters/registries/index.ts +19 -0
  22. package/src/adapters/registries/peeringdb.ts +169 -0
  23. package/src/cli/index.ts +295 -0
  24. package/src/core/addr.ts +145 -0
  25. package/src/core/define.ts +94 -0
  26. package/src/core/secrets.ts +68 -0
  27. package/src/core/time.ts +15 -0
  28. package/src/core/validate.ts +380 -0
  29. package/src/index.ts +6 -0
  30. package/src/schema/common.ts +32 -0
  31. package/src/schema/device.ts +95 -0
  32. package/src/schema/firewall.ts +128 -0
  33. package/src/schema/index.ts +7 -0
  34. package/src/schema/interfaces.ts +192 -0
  35. package/src/schema/policy.ts +88 -0
  36. package/src/schema/routing.ts +120 -0
  37. package/src/schema/system.ts +191 -0
  38. package/src/transport/ssh.ts +125 -0
@@ -0,0 +1,4 @@
1
+ // Port counts per speed class, and how the platform spells a port. `{n}` is the position within the class.
2
+ export const models = {
3
+ "CE6855-48S6Q-HI": { ports: { "10g": 48, "40g": 6 }, spelling: { "10g": "10GE1/0/{n}", "40g": "40GE1/0/{n}" } },
4
+ } as const;
@@ -0,0 +1,19 @@
1
+ // Registries the framework reads prefix sets from. PeeringDB, which it publishes to, is in ./peeringdb.ts.
2
+
3
+ import type { Family } from "../../schema";
4
+ import type { Registry } from "../../schema/policy";
5
+
6
+ /** Prefixes an origin AS announces, from RIPEstat. */
7
+ async function ripeStat(query: string, family: Family): Promise<string[]> {
8
+ if (!/^AS\d+$/i.test(query)) throw new Error(`ripe-stat takes an AS number, got '${query}'`);
9
+ const res = await fetch(`https://stat.ripe.net/data/announced-prefixes/data.json?resource=${query.toUpperCase()}`, {
10
+ signal: AbortSignal.timeout(90_000),
11
+ });
12
+ if (!res.ok) throw new Error(`ripe-stat answered ${res.status}`);
13
+ const data = (await res.json()) as { data: { prefixes: { prefix: string }[] } };
14
+ return [
15
+ ...new Set(data.data.prefixes.map((entry) => entry.prefix).filter((entry) => entry.includes(":") === (family === "ipv6"))),
16
+ ].sort();
17
+ }
18
+
19
+ export const registries: Record<Registry, (query: string, family: Family) => Promise<string[]>> = { "ripe-stat": ripeStat };
@@ -0,0 +1,169 @@
1
+ // Keeps a network's exchange presence at PeeringDB in step with the config. Each interface with an `exchange` naming a `peeringdb_ixlan` is one record.
2
+ //
3
+ // Only globally reachable addresses are published: an exchange reached over a tunnel often lands on private or shared space, and publishing it would point others at a dead end. A record the config does not hold is reported, never deleted, and `operational` is never changed, since leaving an exchange is a decision made outside the config.
4
+
5
+ import { contains, familyOf, host, isGlobal } from "../../core/addr";
6
+ import type { Network } from "../../core/define";
7
+ import { resolve } from "../../core/secrets";
8
+ import type { Device, Interface } from "../../schema";
9
+
10
+ const API = "https://www.peeringdb.com/api";
11
+
12
+ /** A network's presence at one exchange LAN, as PeeringDB holds it. */
13
+ type NetIxLan = {
14
+ id: number;
15
+ net_id: number;
16
+ ixlan_id: number;
17
+ name: string;
18
+ speed: number;
19
+ ipaddr4: string | null;
20
+ ipaddr6: string | null;
21
+ is_rs_peer: boolean;
22
+ operational: boolean;
23
+ };
24
+
25
+ /** One record as the config describes it. */
26
+ type Wanted = {
27
+ name: string;
28
+ ixlan_id: number;
29
+ speed: number;
30
+ ipaddr4: string | null;
31
+ ipaddr6: string | null;
32
+ is_rs_peer: boolean;
33
+ };
34
+
35
+ /** A change to send. `id` is set when the record exists and is updated in place. */
36
+ export type PeeringDbChange = {
37
+ summary: string;
38
+ id?: number;
39
+ body: Record<string, unknown>;
40
+ };
41
+
42
+ export type PeeringDbPlan = {
43
+ changes: PeeringDbChange[];
44
+ /** Records PeeringDB holds that the config does not. */
45
+ unknown: string[];
46
+ };
47
+
48
+ type AnyInterface = Interface<string, string>;
49
+
50
+ const addressesOf = (iface: AnyInterface) => (iface.addresses ?? []).map((entry) => (typeof entry === "string" ? entry : entry.address));
51
+
52
+ /** Whether the device peers with a route server on this interface's subnets. */
53
+ function peersWithRouteServer(device: Device, iface: AnyInterface): boolean {
54
+ const bgp = device.routing?.bgp;
55
+ const subnets = addressesOf(iface);
56
+
57
+ return Object.values(bgp?.neighbors ?? {}).some((neighbor) => {
58
+ const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
59
+ const role = neighbor.local_role ?? group?.local_role;
60
+ return role === "rs-client" && subnets.some((subnet) => contains(subnet, neighbor.address));
61
+ });
62
+ }
63
+
64
+ /** Every exchange presence the config declares with a PeeringDB exchange LAN. */
65
+ function wanted(network: Network): Wanted[] {
66
+ const records: Wanted[] = [];
67
+
68
+ for (const device of network.devices) {
69
+ for (const iface of Object.values(device.interfaces ?? {})) {
70
+ const exchange = iface.exchange;
71
+ if (!exchange?.peeringdb_ixlan) continue;
72
+
73
+ const published = addressesOf(iface).map(host).filter(isGlobal);
74
+
75
+ records.push({
76
+ name: exchange.name,
77
+ ixlan_id: exchange.peeringdb_ixlan,
78
+ speed: exchange.speed,
79
+ ipaddr4: published.find((address) => familyOf(address) === "ipv4") ?? null,
80
+ ipaddr6: published.find((address) => familyOf(address) === "ipv6") ?? null,
81
+ is_rs_peer: peersWithRouteServer(device, iface),
82
+ });
83
+ }
84
+ }
85
+
86
+ return records;
87
+ }
88
+
89
+ /** What would change at PeeringDB. Reads the public API; needs no key. */
90
+ export async function planPeeringDb(network: Network): Promise<PeeringDbPlan> {
91
+ if (!network.asn) throw new Error("the network declares no asn");
92
+
93
+ const response = await fetch(`${API}/netixlan?asn=${network.asn}`, {
94
+ headers: { Accept: "application/json" },
95
+ signal: AbortSignal.timeout(30_000),
96
+ });
97
+ if (!response.ok) throw new Error(`PeeringDB answered ${response.status}`);
98
+
99
+ const existing = ((await response.json()) as { data: NetIxLan[] }).data;
100
+ const desired = wanted(network);
101
+
102
+ // The record that owns every presence. Taken from an existing one, since the API has no other lookup by ASN here.
103
+ const netId = existing[0]?.net_id;
104
+ const changes: PeeringDbChange[] = [];
105
+
106
+ for (const record of desired) {
107
+ const current = existing.find((candidate) => candidate.ixlan_id === record.ixlan_id);
108
+
109
+ const body = {
110
+ net_id: netId,
111
+ ixlan_id: record.ixlan_id,
112
+ asn: network.asn,
113
+ speed: record.speed,
114
+ ipaddr4: record.ipaddr4,
115
+ ipaddr6: record.ipaddr6,
116
+ is_rs_peer: record.is_rs_peer,
117
+ operational: current?.operational ?? true,
118
+ };
119
+
120
+ if (!current) {
121
+ changes.push({ summary: `+ ${record.name}: ${record.ipaddr4}, ${record.ipaddr6}, ${record.speed} Mbit/s`, body });
122
+ continue;
123
+ }
124
+
125
+ const differs =
126
+ current.ipaddr4 !== record.ipaddr4 ||
127
+ current.ipaddr6 !== record.ipaddr6 ||
128
+ current.speed !== record.speed ||
129
+ current.is_rs_peer !== record.is_rs_peer;
130
+
131
+ if (differs) {
132
+ const before = `${current.ipaddr4}, ${current.ipaddr6}, ${current.speed} Mbit/s, route server ${current.is_rs_peer}`;
133
+ const after = `${record.ipaddr4}, ${record.ipaddr6}, ${record.speed} Mbit/s, route server ${record.is_rs_peer}`;
134
+ changes.push({ summary: `~ ${record.name}: ${before} to ${after}`, id: current.id, body });
135
+ }
136
+ }
137
+
138
+ const unknown = existing
139
+ .filter((record) => !desired.some((candidate) => candidate.ixlan_id === record.ixlan_id))
140
+ .map((record) => record.name);
141
+
142
+ if (changes.some((change) => !change.id) && !netId) {
143
+ throw new Error("PeeringDB holds no presence for this network yet, so its record id is unknown; add the first one by hand");
144
+ }
145
+
146
+ return { changes, unknown };
147
+ }
148
+
149
+ /** Send the changes, with the network's API key. */
150
+ export async function sendPeeringDb(network: Network, changes: PeeringDbChange[]): Promise<void> {
151
+ if (!network.peeringdb) throw new Error("the network declares no peeringdb api_key");
152
+ const apiKey = resolve(network.peeringdb.api_key);
153
+
154
+ for (const change of changes) {
155
+ const response = await fetch(`${API}/netixlan${change.id ? `/${change.id}` : ""}`, {
156
+ method: change.id ? "PUT" : "POST",
157
+ headers: { Authorization: `Api-Key ${apiKey}`, "Content-Type": "application/json" },
158
+ body: JSON.stringify(change.body),
159
+ signal: AbortSignal.timeout(30_000),
160
+ });
161
+
162
+ if (!response.ok) {
163
+ const reason = (await response.text()).slice(0, 300);
164
+ throw new Error(`${change.summary}: PeeringDB answered ${response.status} ${reason}`);
165
+ }
166
+
167
+ console.log(` sent ${change.summary}`);
168
+ }
169
+ }
@@ -0,0 +1,295 @@
1
+ #!/usr/bin/env bun
2
+ // The command line. Loads `circuit.config.ts` from the working directory.
3
+
4
+ import { createPrivateKey, createPublicKey } from "node:crypto";
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { join, resolve } from "node:path";
7
+ import { adapters } from "../adapters/devices";
8
+ import type { ApplyOptions, DeviceAdapter, Plan } from "../adapters/devices/types";
9
+ import { registries } from "../adapters/registries";
10
+ import { planPeeringDb, sendPeeringDb } from "../adapters/registries/peeringdb";
11
+ import { network as prefixOf } from "../core/addr";
12
+ import type { Network } from "../core/define";
13
+ import { resolve as reveal } from "../core/secrets";
14
+ import { validate } from "../core/validate";
15
+ import type { Device } from "../schema";
16
+
17
+ const HELP = `usage: circuit <command> [device...] [options]
18
+
19
+ validate check the config, then every rule in checks
20
+ build <device> print the configuration the device should run, without contacting it
21
+ diff [device...] what apply would send, planned against the last snapshot, offline
22
+ snapshot [device...] read what each device runs into the state directory
23
+ apply <device> read the device, plan against what it runs, and send with --confirm
24
+ --secrets also send every secret, to rotate one
25
+ --rollback=N minutes before a device that can restore itself does, default 10
26
+ refresh <device> fetch prefix sets that come from a registry onto the device, with --confirm
27
+ wireguard <device> <interface> <peer>
28
+ a client config for one peer, its private key left for the peer to fill
29
+ communities the network's BGP communities, one per line, for a looking glass or bgp.tools
30
+ peeringdb bring PeeringDB's exchange records in line with the config, with --confirm`;
31
+
32
+ const CONFIG_FILE = "circuit.config.ts";
33
+
34
+ const root = process.cwd();
35
+ const configPath = resolve(root, CONFIG_FILE);
36
+
37
+ if (!existsSync(configPath)) {
38
+ console.error(`no ${CONFIG_FILE} in ${root}`);
39
+ process.exit(1);
40
+ }
41
+
42
+ const network = (await import(configPath)).default as Network;
43
+
44
+ const [command, ...rest] = process.argv.slice(2);
45
+ const flags = rest.filter((arg) => arg.startsWith("--"));
46
+ const names = rest.filter((arg) => !arg.startsWith("--"));
47
+ const confirmed = flags.includes("--confirm");
48
+ const options: ApplyOptions = {
49
+ secrets: flags.includes("--secrets"),
50
+ rollback: Number(flags.find((flag) => flag.startsWith("--rollback="))?.split("=")[1] ?? 10),
51
+ };
52
+
53
+ function fail(message: string): never {
54
+ console.error(message);
55
+ process.exit(1);
56
+ }
57
+
58
+ /** The devices named on the command line, or every device when none is and `allByDefault`. */
59
+ function selected(allByDefault: boolean): Device[] {
60
+ if (!names.length && allByDefault) return [...network.devices];
61
+
62
+ const known = network.devices.map((device) => device.name).join(", ");
63
+ const byName = (name: string) => network.devices.find((device) => device.name === name);
64
+
65
+ return names.map((name) => byName(name) ?? fail(`no device ${name}; known: ${known}`));
66
+ }
67
+
68
+ function one(): Device {
69
+ const [device] = selected(false);
70
+ return device ?? fail("name a device");
71
+ }
72
+
73
+ const adapterOf = (device: Device): DeviceAdapter => adapters[device.platform] ?? fail(`no adapter for ${device.platform}`);
74
+ const stateDirectory = join(root, network.state);
75
+ const statePath = (device: Device) => join(stateDirectory, device.name + adapterOf(device).extension);
76
+
77
+ const STATE_README = `# Do not edit
78
+
79
+ Written by \`circuit snapshot\` and \`circuit apply\`: what each device ran when it was last read, with secrets removed.
80
+
81
+ An edit here changes nothing on a device and is overwritten on the next read. Change the config instead.
82
+ `;
83
+
84
+ /** Record what a device runs, beside a note that the directory is generated. */
85
+ function writeState(device: Device, text: string): void {
86
+ mkdirSync(stateDirectory, { recursive: true });
87
+ writeFileSync(join(stateDirectory, "README.md"), STATE_README);
88
+ writeFileSync(statePath(device), text);
89
+ }
90
+
91
+ /** Validate, print what was found, and stop on an error about one of these devices. */
92
+ function guard(devices: Device[]): void {
93
+ const relevant = new Set(devices.map((device) => device.name));
94
+ const findings = validate(network).filter((finding) => !finding.device || relevant.has(finding.device));
95
+
96
+ for (const finding of findings) {
97
+ const where = finding.device ? `${finding.device}: ` : "";
98
+ const level = finding.level === "error" ? "error" : "warn ";
99
+ console.log(` ${level} ${where}${finding.message}`);
100
+ }
101
+
102
+ const errors = findings.filter((finding) => finding.level === "error").length;
103
+ if (errors) fail(`\n${errors} error(s).`);
104
+ }
105
+
106
+ /** Print a plan. Returns how many commands and problems it holds. */
107
+ function show(device: Device, plan: Plan): number {
108
+ const commands = plan.steps.reduce((count, step) => count + step.send.length, 0);
109
+ const summary = commands ? `${commands} command(s) in ${plan.steps.length} step(s)` : "matches";
110
+ console.log(`${device.name}: ${summary}`);
111
+
112
+ for (const step of plan.steps) {
113
+ console.log(` ${step.title}`);
114
+ for (const line of step.show) console.log(` ${line.slice(0, 220)}`);
115
+ }
116
+
117
+ for (const problem of plan.problems) console.log(` PROBLEM ${problem}`);
118
+
119
+ return commands + plan.problems.length;
120
+ }
121
+
122
+ function validateCommand(): void {
123
+ guard([...network.devices]);
124
+ console.log(`${network.devices.length} device(s), no errors.`);
125
+ }
126
+
127
+ function buildCommand(): void {
128
+ const device = one();
129
+ process.stdout.write(adapterOf(device).render(device));
130
+ }
131
+
132
+ function diffCommand(): void {
133
+ const devices = selected(true);
134
+ guard(devices);
135
+
136
+ let differences = 0;
137
+
138
+ for (const device of devices) {
139
+ const path = statePath(device);
140
+ if (!existsSync(path)) {
141
+ console.log(`${device.name}: no snapshot, run snapshot first`);
142
+ continue;
143
+ }
144
+
145
+ const adapter = adapterOf(device);
146
+ const plan = adapter.plan(adapter.render(device), readFileSync(path, "utf8"), options);
147
+ differences += show(device, plan);
148
+ }
149
+
150
+ process.exit(differences ? 1 : 0);
151
+ }
152
+
153
+ async function snapshotCommand(): Promise<void> {
154
+ for (const device of selected(true)) {
155
+ const text = await adapterOf(device).read(device, network.devices);
156
+ writeState(device, text);
157
+ console.log(`${device.name}: ${text.split("\n").length} lines`);
158
+ }
159
+ }
160
+
161
+ async function applyCommand(): Promise<void> {
162
+ const device = one();
163
+ guard([device]);
164
+ const adapter = adapterOf(device);
165
+
166
+ // The plan is made against what the device runs now, never against an old snapshot.
167
+ const live = await adapter.read(device, network.devices);
168
+ writeState(device, live);
169
+
170
+ const plan = adapter.plan(adapter.render(device), live, options);
171
+ if (!show(device, plan)) return;
172
+
173
+ if (plan.problems.length) fail("\nNot sent: resolve the problems first.");
174
+
175
+ if (!confirmed) {
176
+ console.log("\nNothing sent. Re-run with --confirm.");
177
+ return;
178
+ }
179
+
180
+ await adapter.apply(device, network.devices, plan, options);
181
+
182
+ writeState(device, await adapter.read(device, network.devices));
183
+ console.log("snapshot updated");
184
+ }
185
+
186
+ async function refreshCommand(): Promise<void> {
187
+ const device = one();
188
+ const fill = adapterOf(device).fill ?? fail(`${device.platform} cannot hold a fetched prefix set`);
189
+
190
+ for (const [name, set] of Object.entries(device.prefix_sets ?? {})) {
191
+ if (!("source" in set)) continue;
192
+
193
+ const { registry, query, family } = set.source;
194
+ const prefixes = await registries[registry](query, family);
195
+
196
+ // An empty or absurd answer is a broken feed, not a policy.
197
+ if (prefixes.length < 1 || prefixes.length > 100_000) {
198
+ fail(`${name}: ${prefixes.length} prefixes from ${registry}, refusing`);
199
+ }
200
+
201
+ console.log(`${name}: ${prefixes.length} prefixes from ${registry} ${query}`);
202
+ if (confirmed) await fill(device, network.devices, name, family, prefixes);
203
+ }
204
+
205
+ if (!confirmed) console.log("\nNothing sent. Re-run with --confirm.");
206
+ }
207
+
208
+ /** A WireGuard public key from its private key, so the device's never has to be read off the box. */
209
+ function publicKeyOf(privateKey: string): string {
210
+ // The DER prefix of a raw X25519 private key in PKCS#8.
211
+ const pkcs8Prefix = Buffer.from("302e020100300506032b656e04220420", "hex");
212
+ const der = Buffer.concat([pkcs8Prefix, Buffer.from(privateKey, "base64")]);
213
+
214
+ const key = createPrivateKey({ key: der, format: "der", type: "pkcs8" });
215
+ const publicDer = createPublicKey(key).export({ format: "der", type: "spki" });
216
+
217
+ return publicDer.subarray(-32).toString("base64");
218
+ }
219
+
220
+ function wireguardCommand(): void {
221
+ const [deviceName, interfaceName, peerName] = names;
222
+ const device = network.devices.find((candidate) => candidate.name === deviceName) ?? fail(HELP);
223
+
224
+ const iface = device.interfaces?.[interfaceName ?? ""];
225
+ if (iface?.type !== "wireguard") fail(`${deviceName} has no WireGuard interface ${interfaceName}`);
226
+
227
+ const peers = iface.peers ?? [];
228
+ const peerNames = peers.map((candidate) => candidate.name).join(", ");
229
+ const peer = peers.find((candidate) => candidate.name === peerName) ?? fail(`no peer ${peerName} on ${interfaceName}: ${peerNames}`);
230
+
231
+ const tunnelNetworks = (iface.addresses ?? []).map((entry) => prefixOf(typeof entry === "string" ? entry : entry.address));
232
+
233
+ const lines = [
234
+ "[Interface]",
235
+ `# ${peerName}'s own private key. It never leaves their machine.`,
236
+ "PrivateKey = <FILL IN>",
237
+ `Address = ${peer.allowed_addresses.join(", ")}`,
238
+ "",
239
+ "[Peer]",
240
+ `PublicKey = ${publicKeyOf(reveal(iface.private_key))}`,
241
+ `Endpoint = ${iface.endpoint ?? "<FILL IN>"}:${iface.listen_port}`,
242
+ `AllowedIPs = ${(peer.client_allowed_addresses ?? tunnelNetworks).join(", ")}`,
243
+ "PersistentKeepalive = 25",
244
+ "",
245
+ ];
246
+ process.stdout.write(lines.join("\n"));
247
+ }
248
+
249
+ function communitiesCommand(): void {
250
+ const communities = network.communities ?? fail("the network declares no communities");
251
+
252
+ for (const { community, description } of communities) {
253
+ // The format splits each line at its first comma.
254
+ if (community.includes(",")) fail(`community ${community} holds a comma`);
255
+ console.log(`${community},${description}`);
256
+ }
257
+ }
258
+
259
+ async function peeringdbCommand(): Promise<void> {
260
+ const plan = await planPeeringDb(network);
261
+
262
+ for (const name of plan.unknown) {
263
+ console.log(` PeeringDB lists ${name}, which the config does not. Remove it by hand if the network left.`);
264
+ }
265
+
266
+ for (const change of plan.changes) console.log(` ${change.summary}`);
267
+
268
+ if (!plan.changes.length) {
269
+ console.log("PeeringDB matches the config.");
270
+ return;
271
+ }
272
+
273
+ if (!confirmed) {
274
+ console.log("\nNothing sent. Re-run with --confirm.");
275
+ return;
276
+ }
277
+
278
+ await sendPeeringDb(network, plan.changes);
279
+ }
280
+
281
+ const commands: Record<string, () => void | Promise<void>> = {
282
+ validate: validateCommand,
283
+ build: buildCommand,
284
+ diff: diffCommand,
285
+ snapshot: snapshotCommand,
286
+ apply: applyCommand,
287
+ refresh: refreshCommand,
288
+ wireguard: wireguardCommand,
289
+ communities: communitiesCommand,
290
+ peeringdb: peeringdbCommand,
291
+ };
292
+
293
+ const run = commands[command ?? ""];
294
+ if (run) await run();
295
+ else console.log(HELP);
@@ -0,0 +1,145 @@
1
+ // Address arithmetic for both families. Addresses are parsed to bigint so v4 and v6 share one code path.
2
+
3
+ import type { Family } from "../schema";
4
+
5
+ export const familyOf = (address: string): Family => (address.includes(":") ? "ipv6" : "ipv4");
6
+
7
+ const bitsOf = (family: Family) => (family === "ipv4" ? 32 : 128);
8
+
9
+ /** An address as a number, or null when it is not one. */
10
+ export function parse(address: string): bigint | null {
11
+ if (!address.includes(":")) {
12
+ const octets = address.split(".");
13
+ const valid = octets.length === 4 && octets.every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255);
14
+ if (!valid) return null;
15
+
16
+ return octets.reduce((value, octet) => (value << 8n) | BigInt(octet), 0n);
17
+ }
18
+
19
+ const [before, after, extra] = address.split("::");
20
+ if (extra !== undefined) return null;
21
+
22
+ const groupsOf = (text: string | undefined) => (text ? text.split(":") : []);
23
+ const leading = groupsOf(before);
24
+ const trailing = groupsOf(after);
25
+
26
+ // `::` stands for as many zero groups as make eight.
27
+ const zeros = after === undefined ? 0 : 8 - leading.length - trailing.length;
28
+ const groups = [...leading, ...Array(zeros).fill("0"), ...trailing];
29
+
30
+ const valid = groups.length === 8 && groups.every((group) => /^[0-9a-fA-F]{1,4}$/.test(group));
31
+ if (!valid) return null;
32
+
33
+ return groups.reduce((value, group) => (value << 16n) | BigInt(parseInt(group, 16)), 0n);
34
+ }
35
+
36
+ /** `10.0.0.1/24` to its parts. Throws when it is not an address with a length. */
37
+ export function split(cidr: string): { addr: string; len: number; family: Family } {
38
+ const [address, lengthText] = cidr.split("/");
39
+ const family = familyOf(address!);
40
+ const length = Number(lengthText);
41
+
42
+ const valid =
43
+ parse(address!) !== null && lengthText !== undefined && Number.isInteger(length) && length >= 0 && length <= bitsOf(family);
44
+ if (!valid) throw new Error(`'${cidr}' is not an address with a length`);
45
+
46
+ return { addr: address!, len: length, family };
47
+ }
48
+
49
+ export const isAddress = (text: string) => parse(text) !== null;
50
+
51
+ export function isCidr(text: string): boolean {
52
+ try {
53
+ split(text);
54
+ return true;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ const mask = (length: number, family: Family) => {
61
+ const all = (1n << BigInt(bitsOf(family))) - 1n;
62
+ const hostBits = (1n << BigInt(bitsOf(family) - length)) - 1n;
63
+ return all ^ hostBits;
64
+ };
65
+
66
+ function format(value: bigint, family: Family): string {
67
+ if (family === "ipv4") {
68
+ return [24n, 16n, 8n, 0n].map((shift) => String((value >> shift) & 255n)).join(".");
69
+ }
70
+
71
+ const groups = Array.from({ length: 8 }, (_, index) => ((value >> BigInt(112 - index * 16)) & 0xffffn).toString(16));
72
+
73
+ // Compress the longest run of zero groups, as RFC 5952 asks.
74
+ let longestStart = -1;
75
+ let longestLength = 0;
76
+
77
+ for (let start = 0; start < 8;) {
78
+ let end = start;
79
+ while (end < 8 && groups[end] === "0") end++;
80
+
81
+ const runLength = end - start;
82
+ if (runLength > longestLength && runLength > 1) {
83
+ longestStart = start;
84
+ longestLength = runLength;
85
+ }
86
+
87
+ start = end === start ? start + 1 : end;
88
+ }
89
+
90
+ if (longestStart < 0) return groups.join(":");
91
+
92
+ const head = groups.slice(0, longestStart).join(":");
93
+ const tail = groups.slice(longestStart + longestLength).join(":");
94
+ return `${head}::${tail}`;
95
+ }
96
+
97
+ /** `10.0.0.1/24` to `10.0.0.0/24`. */
98
+ export function network(cidr: string): string {
99
+ const { addr, len, family } = split(cidr);
100
+ return `${format(parse(addr)! & mask(len, family), family)}/${len}`;
101
+ }
102
+
103
+ /** Is the address or prefix `inner` inside `outer`? */
104
+ export function contains(outer: string, inner: string): boolean {
105
+ const outerParts = split(outer);
106
+ const [innerAddress, innerLengthText] = inner.split("/");
107
+ if (familyOf(innerAddress!) !== outerParts.family) return false;
108
+
109
+ const innerLength = innerLengthText === undefined ? bitsOf(outerParts.family) : Number(innerLengthText);
110
+ if (innerLength < outerParts.len) return false;
111
+
112
+ const outerMask = mask(outerParts.len, outerParts.family);
113
+ return (parse(innerAddress!)! & outerMask) === (parse(outerParts.addr)! & outerMask);
114
+ }
115
+
116
+ /** `/25` to `255.255.255.128`. */
117
+ export const netmask = (length: number) => format(mask(length, "ipv4"), "ipv4");
118
+
119
+ /** `/24` to `0.0.0.255`, the inverted mask some ACLs take. */
120
+ export const wildcard = (length: number) => format(mask(length, "ipv4") ^ 0xffffffffn, "ipv4");
121
+
122
+ /** The address with its length dropped. */
123
+ export const host = (cidr: string) => cidr.split("/")[0]!;
124
+
125
+ /** IPv4 space another network cannot reach: this host, private, shared, loopback, link-local and documentation, after RFC 6890. */
126
+ const NOT_GLOBAL_V4 = [
127
+ "0.0.0.0/8",
128
+ "10.0.0.0/8",
129
+ "100.64.0.0/10",
130
+ "127.0.0.0/8",
131
+ "169.254.0.0/16",
132
+ "172.16.0.0/12",
133
+ "192.0.2.0/24",
134
+ "192.168.0.0/16",
135
+ "198.18.0.0/15",
136
+ "198.51.100.0/24",
137
+ "203.0.113.0/24",
138
+ ];
139
+
140
+ /** Whether another network can reach the address. IPv6 is global only inside 2000::/3, less documentation space. */
141
+ export function isGlobal(address: string): boolean {
142
+ if (familyOf(address) === "ipv6") return contains("2000::/3", address) && !contains("2001:db8::/32", address);
143
+
144
+ return !NOT_GLOBAL_V4.some((prefix) => contains(prefix, address));
145
+ }