@takodotid/circuit 0.18.5 → 0.20.0
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 +18 -0
- package/dist/chunks/index-jtzfw679.js +936 -0
- package/dist/chunks/{index-ex1wcst7.js → index-xm9nma2t.js} +20 -1
- package/dist/chunks/server-5shmsmse.js +246 -0
- package/dist/cli/index.js +701 -106
- package/dist/index.js +1 -1
- package/dist/presets/index.js +1 -1
- package/dist/types/adapters/devices/derive.d.ts +2 -0
- package/dist/types/core/across.d.ts +4 -0
- package/dist/types/core/addr.d.ts +10 -0
- package/dist/types/core/addresses.d.ts +46 -0
- package/dist/types/core/files.d.ts +6 -0
- package/dist/types/core/graph.d.ts +74 -0
- package/dist/types/core/schema-docs.d.ts +16 -0
- package/package.json +1 -1
- package/src/adapters/devices/derive.ts +32 -0
- package/src/adapters/devices/raisecom-ros/index.ts +23 -3
- package/src/adapters/devices/routeros/index.ts +10 -0
- package/src/adapters/devices/vrp/index.ts +10 -1
- package/src/cli/graph.ts +89 -0
- package/src/cli/index.ts +205 -2
- package/src/cli/rename.ts +129 -0
- package/src/core/across.ts +322 -0
- package/src/core/addr.ts +29 -0
- package/src/core/addresses.ts +316 -0
- package/src/core/files.ts +30 -0
- package/src/core/graph.ts +634 -0
- package/src/core/schema-docs.ts +166 -0
- package/src/core/validate.ts +2 -52
- package/src/studio/data.ts +255 -0
- package/src/studio/git.ts +119 -0
- package/src/studio/public/app.js +147 -0
- package/src/studio/public/compare.js +243 -0
- package/src/studio/public/diff.js +97 -0
- package/src/studio/public/dom.js +108 -0
- package/src/studio/public/evaluate.js +648 -0
- package/src/studio/public/export.js +122 -0
- package/src/studio/public/favicon.svg +6 -0
- package/src/studio/public/graph.js +914 -0
- package/src/studio/public/index.html +67 -0
- package/src/studio/public/net.js +128 -0
- package/src/studio/public/style.css +693 -0
- package/src/studio/public/tables.js +1223 -0
- package/src/studio/public/tools.js +578 -0
- package/src/studio/server.ts +218 -0
- package/templates/project/AGENTS.md +12 -9
- package/templates/project/README.md +1 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// Rules that span devices: what one device declares about another has to agree with what the other declares.
|
|
2
|
+
|
|
3
|
+
import type { Device, Gre, Port, Routed, Switched, Vxlan } from "../schema";
|
|
4
|
+
import { contains, familyOf, host, isCidr, network as prefixOf } from "./addr";
|
|
5
|
+
import type { Finding } from "./define";
|
|
6
|
+
import { type Holder, holdersOf, keyOf } from "./graph";
|
|
7
|
+
|
|
8
|
+
/** Every rule across devices. */
|
|
9
|
+
export function checkAcross(devices: readonly Device[]): Finding[] {
|
|
10
|
+
const holders = holdersOf(devices);
|
|
11
|
+
|
|
12
|
+
// Who holds each address. The first holder wins; a second is reported by checkAddresses.
|
|
13
|
+
const owners = new Map<string, Holder>();
|
|
14
|
+
for (const holder of holders) {
|
|
15
|
+
const key = keyOf(host(holder.address))!;
|
|
16
|
+
if (!owners.has(key)) owners.set(key, holder);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return [
|
|
20
|
+
...checkLinks(devices),
|
|
21
|
+
...checkAddresses(holders),
|
|
22
|
+
...checkSessions(devices, owners),
|
|
23
|
+
...checkVrrp(holders),
|
|
24
|
+
...checkTunnels(devices, owners),
|
|
25
|
+
];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The VLAN ids a port carries, by how it carries them, so two ends can be compared across devices. */
|
|
29
|
+
function carried(device: Device, port: Port<string, string, string, string>) {
|
|
30
|
+
const id = (vlan: string | undefined) => (vlan ? device.vlans?.[vlan]?.id : undefined);
|
|
31
|
+
return {
|
|
32
|
+
access: id(port.access_vlan),
|
|
33
|
+
native: id(port.native_vlan),
|
|
34
|
+
trunk: (port.trunk_vlans ?? []).map(id).sort((a, b) => (a ?? 0) - (b ?? 0)),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** VLAN names by id, for the VLANs a port or LAG carries. */
|
|
39
|
+
function vlanNames(device: Device, settings: Switched<string>): Map<number, string> {
|
|
40
|
+
const names = [settings.access_vlan, settings.native_vlan, ...(settings.trunk_vlans ?? [])].filter((name) => name !== undefined);
|
|
41
|
+
|
|
42
|
+
return new Map(names.flatMap((name) => (device.vlans?.[name] ? [[device.vlans[name].id, name] as const] : [])));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What a port's settings are kept on: the LAG when the port is a member of one, otherwise the port. */
|
|
46
|
+
function carrierOf(device: Device, portName: string, port: Port<string, string, string, string>) {
|
|
47
|
+
const lag = port.lag ? device.interfaces?.[port.lag] : undefined;
|
|
48
|
+
if (port.lag && lag?.type === "lag") return { name: port.lag, settings: lag };
|
|
49
|
+
|
|
50
|
+
return { name: portName, settings: port };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The plain addresses of one family on an interface, leaving out point-to-point ones. */
|
|
54
|
+
const plainAddresses = (settings: Routed<string>, family: string) =>
|
|
55
|
+
(settings.addresses ?? []).filter((entry): entry is string => typeof entry === "string" && isCidr(entry) && familyOf(entry) === family);
|
|
56
|
+
|
|
57
|
+
/** Why the two ends of a cable cannot reach each other by address, or undefined when they can. */
|
|
58
|
+
function addressDisagreement(here: Routed<string>, there: Routed<string>, far: Device, farName: string): string | undefined {
|
|
59
|
+
for (const family of ["ipv4", "ipv6"]) {
|
|
60
|
+
const ours = plainAddresses(here, family);
|
|
61
|
+
const theirs = plainAddresses(there, family);
|
|
62
|
+
if (!ours.length || !theirs.length) continue;
|
|
63
|
+
|
|
64
|
+
// A prefix includes its length, so 10.0.0.0/24 and 10.0.0.0/25 are not one.
|
|
65
|
+
const theirPrefixes = new Set(theirs.map(prefixOf));
|
|
66
|
+
if (!ours.some((address) => theirPrefixes.has(prefixOf(address)))) {
|
|
67
|
+
return `has ${ours.join(", ")}, and ${far.name} ${farName} has ${theirs.join(", ")}: not one prefix of one length`;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A point-to-point address names the far end's own address as its peer.
|
|
72
|
+
const farHosts = new Set((there.addresses ?? []).map((entry) => keyOf(host(typeof entry === "string" ? entry : entry.address))));
|
|
73
|
+
|
|
74
|
+
for (const entry of here.addresses ?? []) {
|
|
75
|
+
if (typeof entry === "string" || !keyOf(entry.peer) || farHosts.has(keyOf(entry.peer))) continue;
|
|
76
|
+
return `${entry.address} names peer ${entry.peer}, which ${far.name} ${farName} does not hold`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A link names a device and port that exist, the far end links back, and both ends carry the same VLANs the same way, with the same MTU, speed and addresses. */
|
|
83
|
+
function checkLinks(devices: readonly Device[]): Finding[] {
|
|
84
|
+
const findings: Finding[] = [];
|
|
85
|
+
const byName = new Map(devices.map((device) => [device.name, device]));
|
|
86
|
+
|
|
87
|
+
for (const device of devices) {
|
|
88
|
+
for (const [portName, port] of Object.entries(device.ports ?? {})) {
|
|
89
|
+
const link = port?.link;
|
|
90
|
+
if (!port || !link) continue;
|
|
91
|
+
|
|
92
|
+
const report = (message: string, level: Finding["level"] = "error") =>
|
|
93
|
+
findings.push({
|
|
94
|
+
level,
|
|
95
|
+
device: device.name,
|
|
96
|
+
message: `port ${portName} link: ${message}`,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const far = byName.get(link.device);
|
|
100
|
+
if (!far) {
|
|
101
|
+
report(`device ${link.device} is not in the network`);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const farPort = far.ports?.[link.port];
|
|
106
|
+
if (!farPort) {
|
|
107
|
+
report(`${link.device} does not declare port ${link.port}`);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const linksBack = farPort.link?.device === device.name && farPort.link.port === portName;
|
|
112
|
+
if (!linksBack) {
|
|
113
|
+
report(`${link.device} ${link.port} does not link back to it`);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const here = JSON.stringify(carried(device, port));
|
|
118
|
+
const there = JSON.stringify(carried(far, farPort));
|
|
119
|
+
if (here !== there) report(`carries ${here}, and ${link.device} ${link.port} carries ${there}`);
|
|
120
|
+
|
|
121
|
+
if (port.speed && farPort.speed && port.speed !== farPort.speed) {
|
|
122
|
+
report(`speed ${port.speed}, and ${link.device} ${link.port} is ${farPort.speed}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const near = carrierOf(device, portName, port);
|
|
126
|
+
const away = carrierOf(far, link.port, farPort);
|
|
127
|
+
|
|
128
|
+
const mtu = near.settings.mtu ?? 1500;
|
|
129
|
+
const farMtu = away.settings.mtu ?? 1500;
|
|
130
|
+
if (mtu !== farMtu) report(`MTU ${mtu} on ${near.name}, and ${link.device} ${away.name} has ${farMtu}`);
|
|
131
|
+
|
|
132
|
+
const disagreement = addressDisagreement(near.settings, away.settings, far, away.name);
|
|
133
|
+
if (disagreement) report(`${near.name} ${disagreement}`);
|
|
134
|
+
|
|
135
|
+
// One VLAN under two names works, but reads as two VLANs.
|
|
136
|
+
const farNames = vlanNames(far, away.settings);
|
|
137
|
+
for (const [id, name] of vlanNames(device, near.settings)) {
|
|
138
|
+
const farName = farNames.get(id);
|
|
139
|
+
if (farName && farName !== name) report(`VLAN ${id} is ${name} here and ${farName} on ${link.device}`, "warning");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return findings;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** No address is held by two devices. Two loopbacks may hold one on purpose, for anycast, so that is a warning. */
|
|
148
|
+
function checkAddresses(holders: Holder[]): Finding[] {
|
|
149
|
+
const findings: Finding[] = [];
|
|
150
|
+
const first = new Map<string, Holder>();
|
|
151
|
+
|
|
152
|
+
const isLoopback = (holder: Holder) => holder.device.interfaces?.[holder.name]?.type === "loopback";
|
|
153
|
+
|
|
154
|
+
for (const holder of holders) {
|
|
155
|
+
const key = keyOf(host(holder.address))!;
|
|
156
|
+
const earlier = first.get(key);
|
|
157
|
+
|
|
158
|
+
if (!earlier) {
|
|
159
|
+
first.set(key, holder);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Two on one device are reported by the device's own checks.
|
|
164
|
+
if (earlier.device === holder.device) continue;
|
|
165
|
+
|
|
166
|
+
const anycast = isLoopback(earlier) && isLoopback(holder);
|
|
167
|
+
|
|
168
|
+
findings.push({
|
|
169
|
+
level: anycast ? "warning" : "error",
|
|
170
|
+
device: holder.device.name,
|
|
171
|
+
message: `${host(holder.address)} on ${holder.name} is also on ${earlier.device.name} ${earlier.name}${anycast ? ", which is right only for anycast" : ""}`,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return findings;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A BGP session to an address another of our devices holds needs that device's session back, in the AS it expects, from the address it names. */
|
|
179
|
+
function checkSessions(devices: readonly Device[], owners: Map<string, Holder>): Finding[] {
|
|
180
|
+
const findings: Finding[] = [];
|
|
181
|
+
|
|
182
|
+
const sessionsOf = (device: Device) => {
|
|
183
|
+
const bgp = device.routing?.bgp;
|
|
184
|
+
|
|
185
|
+
return Object.entries(bgp?.neighbors ?? {}).map(([name, neighbor]) => {
|
|
186
|
+
const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
name,
|
|
190
|
+
address: neighbor.address,
|
|
191
|
+
remoteAs: neighbor.remote_as ?? group?.remote_as,
|
|
192
|
+
localAddress: neighbor.local_address ?? group?.local_address,
|
|
193
|
+
};
|
|
194
|
+
});
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
for (const device of devices) {
|
|
198
|
+
for (const session of sessionsOf(device)) {
|
|
199
|
+
const far = owners.get(keyOf(session.address) ?? "")?.device;
|
|
200
|
+
if (!far || far === device) continue;
|
|
201
|
+
|
|
202
|
+
const report = (message: string) =>
|
|
203
|
+
findings.push({ level: "error", device: device.name, message: `neighbor ${session.name}: ${message}` });
|
|
204
|
+
|
|
205
|
+
const farBgp = far.routing?.bgp;
|
|
206
|
+
if (!farBgp) {
|
|
207
|
+
report(`${session.address} is ${far.name}'s, which runs no BGP`);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (session.remoteAs !== undefined && session.remoteAs !== farBgp.asn) {
|
|
212
|
+
report(`expects AS${session.remoteAs}, and ${far.name} is AS${farBgp.asn}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Two routers often hold one session per family, and a session of the other family does not answer this one.
|
|
216
|
+
const back = sessionsOf(far).find(
|
|
217
|
+
(other) =>
|
|
218
|
+
owners.get(keyOf(other.address) ?? "")?.device === device && familyOf(other.address) === familyOf(session.address)
|
|
219
|
+
);
|
|
220
|
+
if (!back) {
|
|
221
|
+
report(`${far.name} has no session back to ${device.name}`);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// The far end only accepts the session from the address it names.
|
|
226
|
+
if (session.localAddress && keyOf(session.localAddress) !== keyOf(back.address)) {
|
|
227
|
+
report(`starts from ${session.localAddress}, and ${far.name}'s session ${back.name} expects ${back.address}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return findings;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** On one segment, a VRRP id has one address and an address has one id, on every device. */
|
|
236
|
+
function checkVrrp(holders: Holder[]): Finding[] {
|
|
237
|
+
const findings: Finding[] = [];
|
|
238
|
+
const bySegment = new Map<string, { holder: Holder; id: number; address: string }[]>();
|
|
239
|
+
|
|
240
|
+
for (const holder of holders) {
|
|
241
|
+
const segment = prefixOf(holder.address);
|
|
242
|
+
|
|
243
|
+
for (const group of holder.vrrp) {
|
|
244
|
+
if (!keyOf(group.address) || familyOf(group.address) !== familyOf(holder.address)) continue;
|
|
245
|
+
if (!contains(segment, group.address)) continue;
|
|
246
|
+
|
|
247
|
+
bySegment.set(segment, [...(bySegment.get(segment) ?? []), { holder, id: group.id, address: group.address }]);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
for (const members of bySegment.values()) {
|
|
252
|
+
for (const member of members) {
|
|
253
|
+
const where = `${member.holder.device.name} ${member.holder.name}`;
|
|
254
|
+
const others = members.filter((other) => other.holder.device !== member.holder.device);
|
|
255
|
+
|
|
256
|
+
const sameId = others.find((other) => other.id === member.id && keyOf(other.address) !== keyOf(member.address));
|
|
257
|
+
if (sameId) {
|
|
258
|
+
findings.push({
|
|
259
|
+
level: "error",
|
|
260
|
+
device: member.holder.device.name,
|
|
261
|
+
message: `VRRP ${member.id} on ${member.holder.name} is ${member.address}, and on ${sameId.holder.device.name} ${sameId.holder.name} it is ${sameId.address}`,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const sameAddress = others.find((other) => keyOf(other.address) === keyOf(member.address) && other.id !== member.id);
|
|
266
|
+
if (sameAddress) {
|
|
267
|
+
findings.push({
|
|
268
|
+
level: "error",
|
|
269
|
+
device: member.holder.device.name,
|
|
270
|
+
message: `VRRP address ${member.address} is id ${member.id} on ${where}, and id ${sameAddress.id} on ${sameAddress.holder.device.name} ${sameAddress.holder.name}`,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return findings;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** A GRE or VXLAN tunnel to another of our devices has a tunnel back there, with the endpoints mirrored and, for VXLAN, the same VNI and port. */
|
|
280
|
+
function checkTunnels(devices: readonly Device[], owners: Map<string, Holder>): Finding[] {
|
|
281
|
+
const findings: Finding[] = [];
|
|
282
|
+
|
|
283
|
+
const ends = devices.flatMap((device) =>
|
|
284
|
+
Object.entries(device.interfaces ?? {}).flatMap(([name, iface]) =>
|
|
285
|
+
iface.type === "gre" || iface.type === "vxlan" ? [{ device, name, iface: iface as Gre<string> | Vxlan<string> }] : []
|
|
286
|
+
)
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const same = (a: string, b: string) => keyOf(a) !== undefined && keyOf(a) === keyOf(b);
|
|
290
|
+
|
|
291
|
+
for (const end of ends) {
|
|
292
|
+
const far = owners.get(keyOf(end.iface.remote) ?? "")?.device;
|
|
293
|
+
if (!far || far === end.device) continue;
|
|
294
|
+
|
|
295
|
+
const report = (message: string) =>
|
|
296
|
+
findings.push({ level: "error", device: end.device.name, message: `interface ${end.name}: ${message}` });
|
|
297
|
+
const kind = end.iface.type === "gre" ? "GRE" : "VXLAN";
|
|
298
|
+
|
|
299
|
+
const mirror = ends.find(
|
|
300
|
+
(other) =>
|
|
301
|
+
other.device === far &&
|
|
302
|
+
other.iface.type === end.iface.type &&
|
|
303
|
+
same(other.iface.local, end.iface.remote) &&
|
|
304
|
+
same(other.iface.remote, end.iface.local)
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
if (!mirror) {
|
|
308
|
+
report(`${end.iface.remote} is ${far.name}'s, which has no ${kind} tunnel from there back to ${end.iface.local}`);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (end.iface.type !== "vxlan" || mirror.iface.type !== "vxlan") continue;
|
|
313
|
+
|
|
314
|
+
if (end.iface.vni !== mirror.iface.vni) report(`VNI ${end.iface.vni}, and ${far.name} ${mirror.name} has ${mirror.iface.vni}`);
|
|
315
|
+
|
|
316
|
+
const port = end.iface.port ?? 4789;
|
|
317
|
+
const farPort = mirror.iface.port ?? 4789;
|
|
318
|
+
if (port !== farPort) report(`UDP port ${port}, and ${far.name} ${mirror.name} has ${farPort}`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return findings;
|
|
322
|
+
}
|
package/src/core/addr.ts
CHANGED
|
@@ -113,6 +113,35 @@ export function contains(outer: string, inner: string): boolean {
|
|
|
113
113
|
return (parse(innerAddress!)! & outerMask) === (parse(outerParts.addr)! & outerMask);
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/** A number back to the address it stands for. */
|
|
117
|
+
export const formatAddress = (value: bigint, family: Family) => format(value, family);
|
|
118
|
+
|
|
119
|
+
/** The first and last address of a prefix, as numbers. */
|
|
120
|
+
export function rangeOf(cidr: string): { first: bigint; last: bigint; family: Family } {
|
|
121
|
+
const { addr, len, family } = split(cidr);
|
|
122
|
+
const hostBits = mask(len, family) ^ ((1n << BigInt(bitsOf(family))) - 1n);
|
|
123
|
+
const first = parse(addr)! & mask(len, family);
|
|
124
|
+
|
|
125
|
+
return { first, last: first | hostBits, family };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The fewest prefixes that cover every address from `first` to `last` and nothing else. */
|
|
129
|
+
export function prefixesBetween(first: bigint, last: bigint, family: Family): string[] {
|
|
130
|
+
const bits = bitsOf(family);
|
|
131
|
+
const prefixes: string[] = [];
|
|
132
|
+
|
|
133
|
+
for (let start = first; start <= last;) {
|
|
134
|
+
// The largest block that starts here on its own boundary and does not pass `last`.
|
|
135
|
+
let size = 0;
|
|
136
|
+
while (size < bits && start % (1n << BigInt(size + 1)) === 0n && start + (1n << BigInt(size + 1)) - 1n <= last) size++;
|
|
137
|
+
|
|
138
|
+
prefixes.push(`${format(start, family)}/${bits - size}`);
|
|
139
|
+
start += 1n << BigInt(size);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return prefixes;
|
|
143
|
+
}
|
|
144
|
+
|
|
116
145
|
/** `/25` to `255.255.255.128`. */
|
|
117
146
|
export const netmask = (length: number) => format(mask(length, "ipv4"), "ipv4");
|
|
118
147
|
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// Every address the config uses, by prefix, and what is still free: what `circuit addresses` prints. It reads the config only, so an address a device gave out by itself, such as a DHCP lease, is not known.
|
|
2
|
+
|
|
3
|
+
import type { Device, Family } from "../schema";
|
|
4
|
+
import {
|
|
5
|
+
contains,
|
|
6
|
+
familyOf,
|
|
7
|
+
formatAddress,
|
|
8
|
+
host,
|
|
9
|
+
isAddress,
|
|
10
|
+
isCidr,
|
|
11
|
+
network as prefixOf,
|
|
12
|
+
parse,
|
|
13
|
+
prefixesBetween,
|
|
14
|
+
rangeOf,
|
|
15
|
+
split,
|
|
16
|
+
} from "./addr";
|
|
17
|
+
import { holdersOf } from "./graph";
|
|
18
|
+
|
|
19
|
+
/** One address or range in a segment, and what holds it. */
|
|
20
|
+
export type Use = {
|
|
21
|
+
/** An address, or `first-last` for a range. */
|
|
22
|
+
address: string;
|
|
23
|
+
what: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** A prefix our devices have addresses in. */
|
|
27
|
+
export type Segment = {
|
|
28
|
+
prefix: string;
|
|
29
|
+
uses: Use[];
|
|
30
|
+
/** How many addresses no one uses, as a number written out, since an IPv6 count does not fit a JavaScript number. */
|
|
31
|
+
free: string;
|
|
32
|
+
/** The lowest free address, when there is one. */
|
|
33
|
+
next?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** A prefix in use without being a segment: a loopback's address, or a block routed elsewhere. */
|
|
37
|
+
export type Block = {
|
|
38
|
+
prefix: string;
|
|
39
|
+
what: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Space the network owns: a prefix it originates in BGP, or one asked for. Everything in use inside it, and what is free. */
|
|
43
|
+
export type Space = {
|
|
44
|
+
prefix: string;
|
|
45
|
+
/** Why it is listed, such as `originated by edge-01`. */
|
|
46
|
+
owner: string;
|
|
47
|
+
segments: Segment[];
|
|
48
|
+
blocks: Block[];
|
|
49
|
+
/** The fewest prefixes that cover what nothing uses. */
|
|
50
|
+
free: string[];
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type AddressReport = {
|
|
54
|
+
spaces: Space[];
|
|
55
|
+
/** What is in use outside every space. Left out when a prefix is asked for. */
|
|
56
|
+
elsewhere: { segments: Segment[]; blocks: Block[] };
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** A use as a range of numbers, for counting. */
|
|
60
|
+
type Taken = { first: bigint; last: bigint };
|
|
61
|
+
|
|
62
|
+
const bits = (family: Family) => (family === "ipv4" ? 32 : 128);
|
|
63
|
+
const isHostRoute = (cidr: string) => split(cidr).len === bits(split(cidr).family);
|
|
64
|
+
const numberOf = (address: string) => parse(address)!;
|
|
65
|
+
|
|
66
|
+
/** Addresses a segment can give out: IPv4 leaves out the network and broadcast addresses below /31, and IPv6 the subnet-router anycast address. */
|
|
67
|
+
function usable(prefix: string): Taken {
|
|
68
|
+
const { first, last, family } = rangeOf(prefix);
|
|
69
|
+
const { len } = split(prefix);
|
|
70
|
+
|
|
71
|
+
if (family === "ipv4" && len <= 30) return { first: first + 1n, last: last - 1n };
|
|
72
|
+
if (family === "ipv6" && len < 127) return { first: first + 1n, last };
|
|
73
|
+
|
|
74
|
+
return { first, last };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Ranges merged, so overlapping ones count once. */
|
|
78
|
+
function merged(ranges: Taken[]): Taken[] {
|
|
79
|
+
const sorted = [...ranges].sort((a, b) => (a.first < b.first ? -1 : a.first > b.first ? 1 : 0));
|
|
80
|
+
const result: Taken[] = [];
|
|
81
|
+
|
|
82
|
+
for (const range of sorted) {
|
|
83
|
+
const previous = result.at(-1);
|
|
84
|
+
if (previous && range.first <= previous.last + 1n) previous.last = range.last > previous.last ? range.last : previous.last;
|
|
85
|
+
else result.push({ ...range });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** What is inside `within` and not in any of the ranges. */
|
|
92
|
+
function gaps(within: Taken, taken: Taken[]): Taken[] {
|
|
93
|
+
const result: Taken[] = [];
|
|
94
|
+
let cursor = within.first;
|
|
95
|
+
|
|
96
|
+
for (const range of merged(taken)) {
|
|
97
|
+
if (range.last < within.first || range.first > within.last) continue;
|
|
98
|
+
if (range.first > cursor) result.push({ first: cursor, last: range.first - 1n });
|
|
99
|
+
if (range.last + 1n > cursor) cursor = range.last + 1n;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (cursor <= within.last) result.push({ first: cursor, last: within.last });
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** `12` or, for a count too long to read, `about 2^64`, to the nearest power of two. */
|
|
107
|
+
function count(value: bigint): string {
|
|
108
|
+
if (value <= 1n << 20n) return String(value);
|
|
109
|
+
|
|
110
|
+
const power = value.toString(2).length - 1;
|
|
111
|
+
const nearer = value - (1n << BigInt(power)) >= 1n << BigInt(power - 1) ? power + 1 : power;
|
|
112
|
+
return `about 2^${nearer}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const byAddress = (a: Use, b: Use) => {
|
|
116
|
+
const first = (use: Use) => numberOf(use.address.split("-")[0]!);
|
|
117
|
+
return first(a) < first(b) ? -1 : first(a) > first(b) ? 1 : 0;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** IPv4 before IPv6, then by first address. */
|
|
121
|
+
function comparePrefixes(a: string, b: string): number {
|
|
122
|
+
const [ra, rb] = [rangeOf(a), rangeOf(b)];
|
|
123
|
+
if (ra.family !== rb.family) return ra.family === "ipv4" ? -1 : 1;
|
|
124
|
+
return ra.first < rb.first ? -1 : ra.first > rb.first ? 1 : 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const byPrefix = (a: { prefix: string }, b: { prefix: string }) => comparePrefixes(a.prefix, b.prefix);
|
|
128
|
+
|
|
129
|
+
/** Every address in use, by segment, under the space the network owns. With `within`, only what falls inside that prefix, as the one space. */
|
|
130
|
+
export function addressesOf(devices: readonly Device[], within?: string): AddressReport {
|
|
131
|
+
const segments = new Map<string, { uses: Use[]; taken: Taken[] }>();
|
|
132
|
+
const blocks: Block[] = [];
|
|
133
|
+
|
|
134
|
+
const segmentOf = (address: string) => [...segments.keys()].find((prefix) => contains(prefix, address));
|
|
135
|
+
|
|
136
|
+
// Segments come from the addresses on interfaces; a host route, such as a loopback's, is a block of its own.
|
|
137
|
+
for (const holder of holdersOf(devices)) {
|
|
138
|
+
const vrf = holder.device.ports?.[holder.name]?.vrf ?? holder.device.interfaces?.[holder.name]?.vrf;
|
|
139
|
+
const what = `${holder.device.name} ${holder.name}${vrf ? `, VRF ${vrf}` : ""}`;
|
|
140
|
+
|
|
141
|
+
if (isHostRoute(holder.address) || holder.peer) {
|
|
142
|
+
blocks.push({ prefix: `${host(holder.address)}/${bits(familyOf(holder.address))}`, what });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const prefix = prefixOf(holder.address);
|
|
147
|
+
const segment = segments.get(prefix) ?? { uses: [], taken: [] };
|
|
148
|
+
const value = numberOf(host(holder.address));
|
|
149
|
+
|
|
150
|
+
segment.uses.push({ address: host(holder.address), what });
|
|
151
|
+
segment.taken.push({ first: value, last: value });
|
|
152
|
+
segments.set(prefix, segment);
|
|
153
|
+
|
|
154
|
+
for (const group of holder.vrrp) {
|
|
155
|
+
if (!isAddress(group.address) || !contains(prefix, group.address)) continue;
|
|
156
|
+
if (segment.uses.some((use) => use.address === group.address)) continue;
|
|
157
|
+
|
|
158
|
+
const holders = devices.filter((device) =>
|
|
159
|
+
Object.values({ ...device.ports, ...device.interfaces }).some((settings) =>
|
|
160
|
+
settings?.vrrp?.some((other) => other.id === group.id && other.address === group.address)
|
|
161
|
+
)
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
segment.uses.push({ address: group.address, what: `VRRP ${group.id} on ${holders.map((device) => device.name).join(", ")}` });
|
|
165
|
+
segment.taken.push({ first: numberOf(group.address), last: numberOf(group.address) });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// A use inside a segment is an address taken; outside every segment, a block of its own. An address one of our interfaces holds is listed once, as that interface.
|
|
170
|
+
const take = (address: string, what: string, first = address, last = address) => {
|
|
171
|
+
const prefix = segmentOf(first);
|
|
172
|
+
if (!prefix) return false;
|
|
173
|
+
|
|
174
|
+
const segment = segments.get(prefix)!;
|
|
175
|
+
if (segment.uses.some((use) => use.address === address)) return true;
|
|
176
|
+
|
|
177
|
+
segment.uses.push({ address, what });
|
|
178
|
+
segment.taken.push({ first: numberOf(first), last: numberOf(last) });
|
|
179
|
+
return true;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
for (const device of devices) {
|
|
183
|
+
for (const [name, server] of Object.entries(device.dhcp ?? {})) {
|
|
184
|
+
const [start, end] = server.pool;
|
|
185
|
+
if (isAddress(start) && isAddress(end)) take(`${start}-${end}`, `DHCP pool ${name} on ${device.name}`, start, end);
|
|
186
|
+
|
|
187
|
+
for (const reservation of server.reservations ?? []) {
|
|
188
|
+
if (isAddress(reservation.address))
|
|
189
|
+
take(reservation.address, `reserved for ${reservation.description ?? reservation.mac} by ${device.name}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
for (const route of device.routing?.static ?? []) {
|
|
194
|
+
if (route.via && isAddress(route.via)) take(route.via, `next hop for ${route.prefix} on ${device.name}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for (const [name, neighbor] of Object.entries(device.routing?.bgp?.neighbors ?? {})) {
|
|
198
|
+
if (isAddress(neighbor.address)) take(neighbor.address, `BGP neighbor ${name} of ${device.name}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
202
|
+
if (iface.type !== "wireguard") continue;
|
|
203
|
+
|
|
204
|
+
for (const peer of iface.peers ?? []) {
|
|
205
|
+
for (const allowed of peer.allowed_addresses) {
|
|
206
|
+
if (!isCidr(allowed)) continue;
|
|
207
|
+
|
|
208
|
+
const what = `WireGuard peer ${peer.name} on ${device.name} ${name}`;
|
|
209
|
+
if (isHostRoute(allowed) && take(host(allowed), what)) continue;
|
|
210
|
+
if (!segmentOf(host(allowed))) blocks.push({ prefix: prefixOf(allowed), what });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Space the network owns: what it originates in BGP, or the prefix asked for.
|
|
217
|
+
const owners = new Map<string, string[]>();
|
|
218
|
+
for (const device of devices) {
|
|
219
|
+
for (const prefix of device.routing?.bgp?.networks ?? []) {
|
|
220
|
+
if (!isCidr(prefix)) continue;
|
|
221
|
+
owners.set(prefixOf(prefix), [...(owners.get(prefixOf(prefix)) ?? []), device.name]);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// A static route to a prefix no segment is in hands that block to whoever is at the next hop. A route covering segments or owned space, such as an aggregate, is not a use.
|
|
226
|
+
for (const device of devices) {
|
|
227
|
+
for (const route of device.routing?.static ?? []) {
|
|
228
|
+
if (!isCidr(route.prefix) || split(route.prefix).len === 0) continue;
|
|
229
|
+
|
|
230
|
+
const overlapsSegment = [...segments.keys()].some((prefix) => contains(route.prefix, prefix) || contains(prefix, route.prefix));
|
|
231
|
+
const coversOwned = [...owners.keys()].some((owned) => contains(route.prefix, owned));
|
|
232
|
+
if (overlapsSegment || coversOwned) continue;
|
|
233
|
+
|
|
234
|
+
const how = route.blackhole ? "discarded" : route.via ? `routed via ${route.via}` : `routed out ${route.interface}`;
|
|
235
|
+
blocks.push({ prefix: prefixOf(route.prefix), what: `${how} by ${device.name}` });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const asked = within ? prefixOf(within) : undefined;
|
|
240
|
+
const spacePrefixes = asked ? [asked] : [...owners.keys()];
|
|
241
|
+
|
|
242
|
+
// A segment or block belongs to the smallest space it is inside.
|
|
243
|
+
const spaceOf = (prefix: string) =>
|
|
244
|
+
spacePrefixes.filter((space) => contains(space, prefix)).sort((a, b) => split(b).len - split(a).len)[0];
|
|
245
|
+
|
|
246
|
+
const toSegment = (prefix: string): Segment => {
|
|
247
|
+
const { uses, taken } = segments.get(prefix)!;
|
|
248
|
+
const free = gaps(usable(prefix), taken);
|
|
249
|
+
const total = free.reduce((sum, range) => sum + range.last - range.first + 1n, 0n);
|
|
250
|
+
const family = familyOf(prefix);
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
prefix,
|
|
254
|
+
uses: uses.sort(byAddress),
|
|
255
|
+
free: count(total),
|
|
256
|
+
...(free[0] ? { next: formatAddress(free[0].first, family) } : {}),
|
|
257
|
+
};
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const uniqueBlocks = blocks.filter(
|
|
261
|
+
(block, index) => blocks.findIndex((other) => other.prefix === block.prefix && other.what === block.what) === index
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const spaces: Space[] = spacePrefixes.sort(comparePrefixes).map((space) => {
|
|
265
|
+
const inside = (prefix: string) => spaceOf(prefix) === space;
|
|
266
|
+
const spaceSegments = [...segments.keys()].filter(inside).sort(comparePrefixes);
|
|
267
|
+
const spaceBlocks = uniqueBlocks.filter((block) => inside(block.prefix)).sort(byPrefix);
|
|
268
|
+
|
|
269
|
+
// Smaller spaces inside this one are in use too.
|
|
270
|
+
const nested = spacePrefixes.filter((other) => other !== space && contains(space, other));
|
|
271
|
+
const taken = [...spaceSegments, ...spaceBlocks.map((block) => block.prefix), ...nested].map((prefix) => rangeOf(prefix));
|
|
272
|
+
const family = familyOf(space);
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
prefix: space,
|
|
276
|
+
owner: asked && !owners.has(space) ? "asked for" : `originated by ${(owners.get(space) ?? []).join(", ")}`,
|
|
277
|
+
segments: spaceSegments.map(toSegment),
|
|
278
|
+
blocks: spaceBlocks,
|
|
279
|
+
free: gaps(rangeOf(space), taken).flatMap((range) => prefixesBetween(range.first, range.last, family)),
|
|
280
|
+
};
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const outside = (prefix: string) => !spaceOf(prefix);
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
spaces,
|
|
287
|
+
elsewhere: asked
|
|
288
|
+
? { segments: [], blocks: [] }
|
|
289
|
+
: {
|
|
290
|
+
segments: [...segments.keys()].filter(outside).sort(comparePrefixes).map(toSegment),
|
|
291
|
+
blocks: uniqueBlocks.filter((block) => outside(block.prefix)).sort(byPrefix),
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Free prefixes of one length inside the free space, up to `limit`, and how many there are in all. */
|
|
297
|
+
export function freeOfLength(free: readonly string[], length: number, limit: number): { prefixes: string[]; total: bigint } {
|
|
298
|
+
const prefixes: string[] = [];
|
|
299
|
+
let total = 0n;
|
|
300
|
+
|
|
301
|
+
for (const prefix of free) {
|
|
302
|
+
const { len, family } = split(prefix);
|
|
303
|
+
if (len > length || length > bits(family)) continue;
|
|
304
|
+
|
|
305
|
+
const { first } = rangeOf(prefix);
|
|
306
|
+
const step = 1n << BigInt(bits(family) - length);
|
|
307
|
+
const fits = 1n << BigInt(length - len);
|
|
308
|
+
total += fits;
|
|
309
|
+
|
|
310
|
+
for (let index = 0n; index < fits && prefixes.length < limit; index++) {
|
|
311
|
+
prefixes.push(`${formatAddress(first + index * step, family)}/${length}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return { prefixes, total };
|
|
316
|
+
}
|