@takodotid/circuit 0.19.0 → 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 +2 -2
- package/dist/chunks/index-jtzfw679.js +936 -0
- package/dist/chunks/{index-dczpxzzm.js → index-xm9nma2t.js} +20 -1
- package/dist/chunks/server-5shmsmse.js +246 -0
- package/dist/cli/index.js +544 -487
- 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 +31 -1
- 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 +1 -15
- package/src/cli/index.ts +154 -20
- 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 +27 -6
- 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 +2 -0
- package/dist/chunks/studio-femhydvd.js +0 -764
- package/src/cli/studio-page.ts +0 -876
- package/src/cli/studio.ts +0 -126
package/dist/cli/index.js
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
LAYERS,
|
|
4
|
+
addressesOf,
|
|
5
|
+
deviceId,
|
|
6
|
+
freeOfLength,
|
|
7
|
+
graphOf,
|
|
8
|
+
holdersOf,
|
|
9
|
+
keyOf,
|
|
10
|
+
only,
|
|
11
|
+
portsOf,
|
|
12
|
+
spell,
|
|
13
|
+
studioData
|
|
14
|
+
} from "../chunks/index-jtzfw679.js";
|
|
2
15
|
import {
|
|
3
16
|
contains,
|
|
4
17
|
familyOf,
|
|
@@ -8,10 +21,9 @@ import {
|
|
|
8
21
|
isGlobal,
|
|
9
22
|
netmask,
|
|
10
23
|
network,
|
|
11
|
-
parse,
|
|
12
24
|
split,
|
|
13
25
|
wildcard
|
|
14
|
-
} from "../chunks/index-
|
|
26
|
+
} from "../chunks/index-xm9nma2t.js";
|
|
15
27
|
import {
|
|
16
28
|
__require,
|
|
17
29
|
__toESM
|
|
@@ -19,8 +31,8 @@ import {
|
|
|
19
31
|
|
|
20
32
|
// src/cli/index.ts
|
|
21
33
|
import { createPrivateKey, createPublicKey } from "node:crypto";
|
|
22
|
-
import { existsSync as
|
|
23
|
-
import { basename, dirname, join as
|
|
34
|
+
import { existsSync as existsSync3, mkdirSync, readdirSync as readdirSync2, readFileSync as readFileSync4, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
35
|
+
import { basename as basename2, dirname as dirname2, join as join4, relative as relative2, resolve as resolve3, sep } from "node:path";
|
|
24
36
|
|
|
25
37
|
// src/core/secrets.ts
|
|
26
38
|
import { spawnSync } from "node:child_process";
|
|
@@ -208,44 +220,6 @@ function upload(client, path, content) {
|
|
|
208
220
|
});
|
|
209
221
|
}
|
|
210
222
|
|
|
211
|
-
// src/adapters/devices/raisecom-ros/models.ts
|
|
212
|
-
var models = {
|
|
213
|
-
"RAX721-C-6C48": {
|
|
214
|
-
ports: { "100g": 6, "25g": 24, "10g": 24 },
|
|
215
|
-
spelling: { "100g": "hundredgige 1/1/{n}", "25g": "twenty-fivegige 1/2/{n}", "10g": "tengigabitethernet 1/3/{n}" }
|
|
216
|
-
}
|
|
217
|
-
};
|
|
218
|
-
|
|
219
|
-
// src/adapters/devices/routeros/models.ts
|
|
220
|
-
var models2 = {
|
|
221
|
-
"CCR2216-1G-12XS-2XQ": {
|
|
222
|
-
ports: { "100g": 2, "25g": 12, "1g": 1 },
|
|
223
|
-
spelling: { "100g": "qsfp28-{n}-1", "25g": "sfp28-{n}", "1g": "ether{n}" }
|
|
224
|
-
}
|
|
225
|
-
};
|
|
226
|
-
|
|
227
|
-
// src/adapters/devices/vrp/models.ts
|
|
228
|
-
var models3 = {
|
|
229
|
-
"CE6855-48S6Q-HI": { ports: { "10g": 48, "40g": 6 }, spelling: { "10g": "10GE1/0/{n}", "40g": "40GE1/0/{n}" } }
|
|
230
|
-
};
|
|
231
|
-
|
|
232
|
-
// src/adapters/devices/catalog.ts
|
|
233
|
-
var catalog = { routeros: models2, vrp: models3, "raisecom-ros": models };
|
|
234
|
-
function portsOf(platform, model) {
|
|
235
|
-
const spec = catalog[platform]?.[model];
|
|
236
|
-
if (!spec)
|
|
237
|
-
throw new Error(`${platform} has no model ${model}`);
|
|
238
|
-
return Object.entries(spec.ports).flatMap(([speed, count]) => Array.from({ length: count }, (_, i) => `${speed}-${i + 1}`));
|
|
239
|
-
}
|
|
240
|
-
function spell(platform, model, port) {
|
|
241
|
-
const spec = catalog[platform]?.[model];
|
|
242
|
-
const parts = /^(\d+[gm])-(\d+)$/.exec(port);
|
|
243
|
-
const template = parts && spec?.spelling[parts[1]];
|
|
244
|
-
if (!template)
|
|
245
|
-
throw new Error(`${model} has no port ${port}`);
|
|
246
|
-
return template.replace("{n}", parts[2]);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
223
|
// src/adapters/devices/derive.ts
|
|
250
224
|
function lagIds(device) {
|
|
251
225
|
const numbers = new Map;
|
|
@@ -263,6 +237,36 @@ function ipv4Networks(device, iface) {
|
|
|
263
237
|
const addresses = (settings?.addresses ?? []).map((entry) => typeof entry === "string" ? entry : entry.address);
|
|
264
238
|
return addresses.filter((address) => familyOf(address) === "ipv4").map((address) => network(address));
|
|
265
239
|
}
|
|
240
|
+
var SWITCH_SERVICES = ["ssh", "telnet", "snmp"];
|
|
241
|
+
function switchUnsupported(device) {
|
|
242
|
+
const found = [];
|
|
243
|
+
for (const service of SWITCH_SERVICES) {
|
|
244
|
+
const settings = device.management?.[service];
|
|
245
|
+
if (settings?.allow)
|
|
246
|
+
found.push(`management.${service}.allow, every service takes management.allow`);
|
|
247
|
+
if (settings?.port)
|
|
248
|
+
found.push(`management.${service}.port`);
|
|
249
|
+
}
|
|
250
|
+
for (const [name, user] of Object.entries(device.users ?? {})) {
|
|
251
|
+
if (user.description)
|
|
252
|
+
found.push(`users.${name}.description`);
|
|
253
|
+
if (user.ssh_keys?.length)
|
|
254
|
+
found.push(`users.${name}.ssh_keys`);
|
|
255
|
+
}
|
|
256
|
+
if (device.system?.ip)
|
|
257
|
+
found.push("system.ip");
|
|
258
|
+
for (const [name, port] of Object.entries(device.ports ?? {})) {
|
|
259
|
+
if (port?.hardware_offload !== undefined)
|
|
260
|
+
found.push(`ports.${name}.hardware_offload`);
|
|
261
|
+
}
|
|
262
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
263
|
+
if (iface.addresses?.some((entry) => typeof entry !== "string"))
|
|
264
|
+
found.push(`interfaces.${name}.addresses with a peer`);
|
|
265
|
+
if (iface.hardware_offload !== undefined)
|
|
266
|
+
found.push(`interfaces.${name}.hardware_offload`);
|
|
267
|
+
}
|
|
268
|
+
return found;
|
|
269
|
+
}
|
|
266
270
|
|
|
267
271
|
// src/adapters/devices/lines.ts
|
|
268
272
|
var indentOf = (raw) => raw.length - raw.trimStart().length;
|
|
@@ -308,12 +312,12 @@ function head(line) {
|
|
|
308
312
|
}
|
|
309
313
|
function compare(desired, current, dialect) {
|
|
310
314
|
const canon = dialect.canon ?? ((line) => line);
|
|
311
|
-
const
|
|
315
|
+
const keyOf2 = (entry) => [...entry.path, canon(entry.line)].join("\x00");
|
|
312
316
|
const pathKey = (path) => path.join("\x00");
|
|
313
317
|
const wanted = entries(desired, dialect);
|
|
314
318
|
const existing = entries(current, dialect);
|
|
315
|
-
const wantedKeys = new Set(wanted.map(
|
|
316
|
-
const existingKeys = new Set(existing.map(
|
|
319
|
+
const wantedKeys = new Set(wanted.map(keyOf2));
|
|
320
|
+
const existingKeys = new Set(existing.map(keyOf2));
|
|
317
321
|
const wantedBlocks = new Set(wanted.map((entry) => pathKey([...entry.path, entry.line])));
|
|
318
322
|
const groups = new Map;
|
|
319
323
|
const groupFor = (path) => {
|
|
@@ -323,7 +327,7 @@ function compare(desired, current, dialect) {
|
|
|
323
327
|
return groups.get(key);
|
|
324
328
|
};
|
|
325
329
|
for (const entry of wanted) {
|
|
326
|
-
if (existingKeys.has(
|
|
330
|
+
if (existingKeys.has(keyOf2(entry)))
|
|
327
331
|
continue;
|
|
328
332
|
const ownBlock = pathKey([...entry.path, entry.line]);
|
|
329
333
|
const opensBlock = wanted.some((other) => {
|
|
@@ -335,7 +339,7 @@ function compare(desired, current, dialect) {
|
|
|
335
339
|
groupFor(entry.path).add.push(entry.line);
|
|
336
340
|
}
|
|
337
341
|
for (const entry of [...existing].reverse()) {
|
|
338
|
-
if (wantedKeys.has(
|
|
342
|
+
if (wantedKeys.has(keyOf2(entry)))
|
|
339
343
|
continue;
|
|
340
344
|
const insideRemovedBlock = entry.path.some((_, depth) => {
|
|
341
345
|
const enclosing = pathKey(entry.path.slice(0, depth + 1));
|
|
@@ -345,7 +349,7 @@ function compare(desired, current, dialect) {
|
|
|
345
349
|
continue;
|
|
346
350
|
const replaced = wanted.some((other) => {
|
|
347
351
|
const sameBlock = pathKey(other.path) === pathKey(entry.path);
|
|
348
|
-
const isNew = !existingKeys.has(
|
|
352
|
+
const isNew = !existingKeys.has(keyOf2(other));
|
|
349
353
|
return sameBlock && isNew && head(other.line) === head(entry.line);
|
|
350
354
|
});
|
|
351
355
|
if (!replaced)
|
|
@@ -353,7 +357,7 @@ function compare(desired, current, dialect) {
|
|
|
353
357
|
}
|
|
354
358
|
return [...groups.values()].filter((group) => group.add.length || group.remove.length);
|
|
355
359
|
}
|
|
356
|
-
function vlanListChange(groups, prefix,
|
|
360
|
+
function vlanListChange(groups, prefix, parse, format, removal) {
|
|
357
361
|
const top = groups.find((group) => group.path.length === 0);
|
|
358
362
|
if (!top)
|
|
359
363
|
return;
|
|
@@ -361,8 +365,8 @@ function vlanListChange(groups, prefix, parse2, format, removal) {
|
|
|
361
365
|
const fresh = top.add.find((line) => line.startsWith(prefix));
|
|
362
366
|
if (!old && !fresh)
|
|
363
367
|
return;
|
|
364
|
-
const before = old ?
|
|
365
|
-
const after = fresh ?
|
|
368
|
+
const before = old ? parse(old) : [];
|
|
369
|
+
const after = fresh ? parse(fresh) : [];
|
|
366
370
|
top.remove = top.remove.filter((line) => line !== old);
|
|
367
371
|
top.add = top.add.filter((line) => line !== fresh);
|
|
368
372
|
const added = after.filter((id) => !before.includes(id));
|
|
@@ -716,8 +720,15 @@ function unsupported(device) {
|
|
|
716
720
|
found.push(`interfaces.${name}, a ${iface.type}`);
|
|
717
721
|
continue;
|
|
718
722
|
}
|
|
719
|
-
|
|
720
|
-
|
|
723
|
+
for (const field of ["vrf", "description", "mtu", "ipv6_ra"]) {
|
|
724
|
+
if (iface[field] !== undefined)
|
|
725
|
+
found.push(`interfaces.${name}.${field}`);
|
|
726
|
+
}
|
|
727
|
+
for (const entry of iface.addresses ?? []) {
|
|
728
|
+
const address = typeof entry === "string" ? entry : entry.address;
|
|
729
|
+
if (familyOf(address) === "ipv6")
|
|
730
|
+
found.push(`interfaces.${name}.addresses ${address}, only IPv4 here`);
|
|
731
|
+
}
|
|
721
732
|
for (const group of iface.vrrp ?? []) {
|
|
722
733
|
if (familyOf(group.address) === "ipv6")
|
|
723
734
|
found.push(`interfaces.${name}.vrrp for IPv6`);
|
|
@@ -740,8 +751,15 @@ function unsupported(device) {
|
|
|
740
751
|
}
|
|
741
752
|
}
|
|
742
753
|
for (const route of routing?.static ?? []) {
|
|
743
|
-
if (!route.via || route.vrf)
|
|
754
|
+
if (!route.via || route.vrf || familyOf(route.prefix) === "ipv6") {
|
|
744
755
|
found.push(`routing.static ${route.prefix}, only an IPv4 next hop in the default table`);
|
|
756
|
+
}
|
|
757
|
+
if (route.description || route.distance)
|
|
758
|
+
found.push(`routing.static ${route.prefix} description and distance`);
|
|
759
|
+
}
|
|
760
|
+
for (const [name, vlan] of Object.entries(device.vlans ?? {})) {
|
|
761
|
+
if (vlan.description)
|
|
762
|
+
found.push(`vlans.${name}.description`);
|
|
745
763
|
}
|
|
746
764
|
const UNSUPPORTED_SECTIONS = [
|
|
747
765
|
"firewall",
|
|
@@ -768,6 +786,10 @@ function unsupported(device) {
|
|
|
768
786
|
if (!SNMP_AUTH[user.auth])
|
|
769
787
|
found.push(`management.snmp.users.${name}.auth ${user.auth}, the platform authenticates with SHA1 or MD5`);
|
|
770
788
|
}
|
|
789
|
+
if (management.snmp?.contact || management.snmp?.location)
|
|
790
|
+
found.push("management.snmp.contact and location");
|
|
791
|
+
if (management.ssh?.weak_crypto)
|
|
792
|
+
found.push("management.ssh.weak_crypto");
|
|
771
793
|
if (device.lldp && device.lldp !== true)
|
|
772
794
|
found.push("lldp.interfaces, LLDP is per device");
|
|
773
795
|
if (device.stp?.mode && device.stp.mode !== "mstp")
|
|
@@ -780,6 +802,7 @@ function unsupported(device) {
|
|
|
780
802
|
if (system.dns || system.logging || system.release_channel || system.banner || system.ntp?.serve) {
|
|
781
803
|
found.push("system.dns, logging, release_channel, banner and ntp.serve");
|
|
782
804
|
}
|
|
805
|
+
found.push(...switchUnsupported(device));
|
|
783
806
|
return found;
|
|
784
807
|
}
|
|
785
808
|
var REFUSED = /^(%|Error|Incomplete|Unrecognized)|can not|cannot |is in use|Wrong parameter|invalid|unsuccessful/im;
|
|
@@ -961,7 +984,7 @@ function parseLine(menu, line) {
|
|
|
961
984
|
}
|
|
962
985
|
return { menu, verb, selector, flags, attrs };
|
|
963
986
|
}
|
|
964
|
-
function
|
|
987
|
+
function parse(text) {
|
|
965
988
|
const commands = [];
|
|
966
989
|
const generated = new Set;
|
|
967
990
|
let menu = "";
|
|
@@ -1414,8 +1437,8 @@ function planOrdered(menu, wanted, existing) {
|
|
|
1414
1437
|
return result;
|
|
1415
1438
|
}
|
|
1416
1439
|
function plan2(desired, current, options) {
|
|
1417
|
-
const want =
|
|
1418
|
-
const have =
|
|
1440
|
+
const want = parse(desired);
|
|
1441
|
+
const have = parse(current);
|
|
1419
1442
|
const owned = (command) => !(command.menu.endsWith("address-list") && want.generated.has(command.attrs.list ?? ""));
|
|
1420
1443
|
const wantedByMenu = byMenu(want.commands.filter(owned));
|
|
1421
1444
|
const existingByMenu = byMenu(have.commands.filter(owned));
|
|
@@ -2588,6 +2611,14 @@ function unsupported2(device) {
|
|
|
2588
2611
|
found.push(`ports.${name}.storm_control with a different percent per kind, the platform takes one`);
|
|
2589
2612
|
}
|
|
2590
2613
|
}
|
|
2614
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
2615
|
+
if (iface.type !== "lag")
|
|
2616
|
+
continue;
|
|
2617
|
+
for (const field of ["storm_control", "stp"]) {
|
|
2618
|
+
if (iface[field] !== undefined)
|
|
2619
|
+
found.push(`interfaces.${name}.${field}, on a LAG`);
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2591
2622
|
const bgp = device.routing?.bgp;
|
|
2592
2623
|
for (const [name, neighbor] of Object.entries(bgp?.neighbors ?? {})) {
|
|
2593
2624
|
const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
|
|
@@ -2600,6 +2631,8 @@ function unsupported2(device) {
|
|
|
2600
2631
|
found.push("management.console, the console uses the device's users");
|
|
2601
2632
|
if (management?.privilege_password)
|
|
2602
2633
|
found.push("management.privilege_password, the platform has no privileged mode");
|
|
2634
|
+
if (management?.snmp?.port)
|
|
2635
|
+
found.push("management.snmp.port");
|
|
2603
2636
|
if (management?.ssh?.auth_timeout || management?.ssh?.auth_retries) {
|
|
2604
2637
|
found.push("management.ssh.auth_timeout and auth_retries");
|
|
2605
2638
|
}
|
|
@@ -3225,10 +3258,16 @@ function unsupported3(device) {
|
|
|
3225
3258
|
found.push(`interfaces.${name}, a ${iface.type}`);
|
|
3226
3259
|
if (iface.type === "lag" && iface.addresses?.length)
|
|
3227
3260
|
found.push(`interfaces.${name}.addresses, LAGs are switched`);
|
|
3261
|
+
if (iface.mtu !== undefined)
|
|
3262
|
+
found.push(`interfaces.${name}.mtu`);
|
|
3228
3263
|
for (const group of iface.vrrp ?? [])
|
|
3229
3264
|
if (familyOf(group.address) === "ipv6")
|
|
3230
3265
|
found.push(`interfaces.${name}.vrrp for IPv6`);
|
|
3231
3266
|
}
|
|
3267
|
+
for (const [name, vrf] of Object.entries(device.vrfs ?? {})) {
|
|
3268
|
+
if (vrf.description)
|
|
3269
|
+
found.push(`vrfs.${name}.description`);
|
|
3270
|
+
}
|
|
3232
3271
|
const routing = device.routing;
|
|
3233
3272
|
if (routing?.bgp)
|
|
3234
3273
|
found.push("routing.bgp");
|
|
@@ -3259,6 +3298,9 @@ function unsupported3(device) {
|
|
|
3259
3298
|
if (system2.dns || system2.logging || system2.release_channel || system2.banner) {
|
|
3260
3299
|
found.push("system.dns, logging, release_channel and banner");
|
|
3261
3300
|
}
|
|
3301
|
+
if (system2.transceiver_monitoring !== undefined)
|
|
3302
|
+
found.push("system.transceiver_monitoring");
|
|
3303
|
+
found.push(...switchUnsupported(device));
|
|
3262
3304
|
const flow = device.flow_export;
|
|
3263
3305
|
if (flow && flow.protocol !== "sflow")
|
|
3264
3306
|
found.push(`flow_export.protocol ${flow.protocol}, the platform exports sFlow`);
|
|
@@ -3438,10 +3480,10 @@ async function sendLibreNms(network2, additions) {
|
|
|
3438
3480
|
|
|
3439
3481
|
// src/adapters/registries/peeringdb.ts
|
|
3440
3482
|
var API = "https://www.peeringdb.com/api";
|
|
3441
|
-
var
|
|
3483
|
+
var addressesOf2 = (iface) => (iface.addresses ?? []).map((entry) => typeof entry === "string" ? entry : entry.address);
|
|
3442
3484
|
function peersWithRouteServer(device, iface) {
|
|
3443
3485
|
const bgp = device.routing?.bgp;
|
|
3444
|
-
const subnets =
|
|
3486
|
+
const subnets = addressesOf2(iface);
|
|
3445
3487
|
return Object.values(bgp?.neighbors ?? {}).some((neighbor) => {
|
|
3446
3488
|
const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
|
|
3447
3489
|
const role = neighbor.role ?? group?.role;
|
|
@@ -3455,7 +3497,7 @@ function wanted(network2) {
|
|
|
3455
3497
|
const exchange = iface.exchange;
|
|
3456
3498
|
if (!exchange?.peeringdb_ixlan)
|
|
3457
3499
|
continue;
|
|
3458
|
-
const published =
|
|
3500
|
+
const published = addressesOf2(iface).map(host).filter(isGlobal);
|
|
3459
3501
|
records.push({
|
|
3460
3502
|
name: exchange.name,
|
|
3461
3503
|
ixlan_id: exchange.peeringdb_ixlan,
|
|
@@ -3571,419 +3613,245 @@ function snmpExporterAuths(network2) {
|
|
|
3571
3613
|
`, notes };
|
|
3572
3614
|
}
|
|
3573
3615
|
|
|
3574
|
-
// src/core/
|
|
3575
|
-
|
|
3576
|
-
var deviceId = (name) => `device:${name}`;
|
|
3577
|
-
function keyOf(address) {
|
|
3578
|
-
if (!address || !isAddress(address))
|
|
3579
|
-
return;
|
|
3580
|
-
return `${familyOf(address)}:${parse(address)}`;
|
|
3581
|
-
}
|
|
3582
|
-
var isHostRoute = (cidr) => {
|
|
3583
|
-
const { len, family } = split(cidr);
|
|
3584
|
-
return len === (family === "ipv4" ? 32 : 128);
|
|
3585
|
-
};
|
|
3616
|
+
// src/core/validate.ts
|
|
3617
|
+
import { X509Certificate as X509Certificate2 } from "node:crypto";
|
|
3586
3618
|
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
for (const line2 of detail)
|
|
3596
|
-
if (!existing.detail.includes(line2))
|
|
3597
|
-
existing.detail.push(line2);
|
|
3598
|
-
return id;
|
|
3599
|
-
}
|
|
3600
|
-
edge(edge) {
|
|
3601
|
-
this.edges.push(edge);
|
|
3602
|
-
return edge;
|
|
3619
|
+
// src/core/across.ts
|
|
3620
|
+
function checkAcross(devices) {
|
|
3621
|
+
const holders2 = holdersOf(devices);
|
|
3622
|
+
const owners = new Map;
|
|
3623
|
+
for (const holder of holders2) {
|
|
3624
|
+
const key = keyOf(host(holder.address));
|
|
3625
|
+
if (!owners.has(key))
|
|
3626
|
+
owners.set(key, holder);
|
|
3603
3627
|
}
|
|
3628
|
+
return [
|
|
3629
|
+
...checkLinks(devices),
|
|
3630
|
+
...checkAddresses(holders2),
|
|
3631
|
+
...checkSessions(devices, owners),
|
|
3632
|
+
...checkVrrp(holders2),
|
|
3633
|
+
...checkTunnels(devices, owners)
|
|
3634
|
+
];
|
|
3604
3635
|
}
|
|
3605
|
-
function
|
|
3606
|
-
const
|
|
3607
|
-
|
|
3608
|
-
|
|
3636
|
+
function carried(device, port) {
|
|
3637
|
+
const id = (vlan) => vlan ? device.vlans?.[vlan]?.id : undefined;
|
|
3638
|
+
return {
|
|
3639
|
+
access: id(port.access_vlan),
|
|
3640
|
+
native: id(port.native_vlan),
|
|
3641
|
+
trunk: (port.trunk_vlans ?? []).map(id).sort((a, b) => (a ?? 0) - (b ?? 0))
|
|
3642
|
+
};
|
|
3609
3643
|
}
|
|
3610
|
-
function
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
if (!isCidr(address))
|
|
3614
|
-
return [];
|
|
3615
|
-
const peer = typeof entry === "string" ? undefined : entry.peer;
|
|
3616
|
-
return [{ device, name, address, peer, vrrp: settings.vrrp ?? [] }];
|
|
3617
|
-
})));
|
|
3618
|
-
}
|
|
3619
|
-
function describeDevice(device) {
|
|
3620
|
-
const addresses2 = routedOf(device).filter(([, settings]) => settings.addresses?.length).map(([name, settings]) => {
|
|
3621
|
-
const list = (settings.addresses ?? []).map((entry) => typeof entry === "string" ? entry : `${entry.address} to ${entry.peer}`);
|
|
3622
|
-
const vrf = settings.vrf ? `, VRF ${settings.vrf}` : "";
|
|
3623
|
-
return `${name}: ${list.join(", ")}${vrf}`;
|
|
3624
|
-
});
|
|
3625
|
-
return [`${device.platform} ${device.model}`, `reached at ${device.connection.host}`, ...addresses2];
|
|
3644
|
+
function vlanNames(device, settings) {
|
|
3645
|
+
const names = [settings.access_vlan, settings.native_vlan, ...settings.trunk_vlans ?? []].filter((name) => name !== undefined);
|
|
3646
|
+
return new Map(names.flatMap((name) => device.vlans?.[name] ? [[device.vlans[name].id, name]] : []));
|
|
3626
3647
|
}
|
|
3627
|
-
function
|
|
3628
|
-
const
|
|
3629
|
-
|
|
3630
|
-
return
|
|
3631
|
-
};
|
|
3632
|
-
const trunk = (settings?.trunk_vlans ?? []).filter((name) => name !== settings?.native_vlan);
|
|
3633
|
-
return [...vlan(settings?.access_vlan, true), ...vlan(settings?.native_vlan, true), ...trunk.flatMap((name) => vlan(name, false))].sort((a, b) => a.id - b.id);
|
|
3648
|
+
function carrierOf(device, portName, port) {
|
|
3649
|
+
const lag = port.lag ? device.interfaces?.[port.lag] : undefined;
|
|
3650
|
+
if (port.lag && lag?.type === "lag")
|
|
3651
|
+
return { name: port.lag, settings: lag };
|
|
3652
|
+
return { name: portName, settings: port };
|
|
3634
3653
|
}
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3654
|
+
var plainAddresses = (settings, family) => (settings.addresses ?? []).filter((entry) => typeof entry === "string" && isCidr(entry) && familyOf(entry) === family);
|
|
3655
|
+
function addressDisagreement(here, there, far, farName) {
|
|
3656
|
+
for (const family of ["ipv4", "ipv6"]) {
|
|
3657
|
+
const ours = plainAddresses(here, family);
|
|
3658
|
+
const theirs = plainAddresses(there, family);
|
|
3659
|
+
if (!ours.length || !theirs.length)
|
|
3660
|
+
continue;
|
|
3661
|
+
const theirPrefixes = new Set(theirs.map(network));
|
|
3662
|
+
if (!ours.some((address) => theirPrefixes.has(network(address)))) {
|
|
3663
|
+
return `has ${ours.join(", ")}, and ${far.name} ${farName} has ${theirs.join(", ")}: not one prefix of one length`;
|
|
3664
|
+
}
|
|
3665
|
+
}
|
|
3666
|
+
const farHosts = new Set((there.addresses ?? []).map((entry) => keyOf(host(typeof entry === "string" ? entry : entry.address))));
|
|
3667
|
+
for (const entry of here.addresses ?? []) {
|
|
3668
|
+
if (typeof entry === "string" || !keyOf(entry.peer) || farHosts.has(keyOf(entry.peer)))
|
|
3669
|
+
continue;
|
|
3670
|
+
return `${entry.address} names peer ${entry.peer}, which ${far.name} ${farName} does not hold`;
|
|
3671
|
+
}
|
|
3672
|
+
return;
|
|
3641
3673
|
}
|
|
3642
|
-
function
|
|
3674
|
+
function checkLinks(devices) {
|
|
3675
|
+
const findings = [];
|
|
3643
3676
|
const byName = new Map(devices.map((device) => [device.name, device]));
|
|
3644
|
-
const drawn = new Set;
|
|
3645
|
-
const bundles = new Map;
|
|
3646
3677
|
for (const device of devices) {
|
|
3647
3678
|
for (const [portName, port] of Object.entries(device.ports ?? {})) {
|
|
3648
3679
|
const link = port?.link;
|
|
3649
3680
|
if (!port || !link)
|
|
3650
3681
|
continue;
|
|
3651
|
-
const
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3682
|
+
const report = (message, level = "error") => findings.push({
|
|
3683
|
+
level,
|
|
3684
|
+
device: device.name,
|
|
3685
|
+
message: `port ${portName} link: ${message}`
|
|
3686
|
+
});
|
|
3656
3687
|
const far = byName.get(link.device);
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
const to = far ? deviceId(far.name) : graph.node(`missing:${link.device}`, "outside", link.device, ["named by a link, and not in the network"]);
|
|
3660
|
-
const cableLine = `${here} to ${there}`;
|
|
3661
|
-
const lag = port.lag;
|
|
3662
|
-
const farLag = farPort?.lag;
|
|
3663
|
-
const bundleKey = `${device.name} ${lag}|${link.device} ${farLag}`;
|
|
3664
|
-
const bundle = lag && farLag && linksBack ? bundles.get(bundleKey) : undefined;
|
|
3665
|
-
if (bundle) {
|
|
3666
|
-
bundle.members++;
|
|
3667
|
-
bundle.cable.detail.push(cableLine);
|
|
3668
|
-
bundle.cable.label = `${lag} to ${farLag}, ${bundle.members} cables`;
|
|
3688
|
+
if (!far) {
|
|
3689
|
+
report(`device ${link.device} is not in the network`);
|
|
3669
3690
|
continue;
|
|
3670
3691
|
}
|
|
3671
|
-
const
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
from: deviceId(device.name),
|
|
3675
|
-
to,
|
|
3676
|
-
label: isBundle ? `${lag} to ${farLag}` : `${portName} to ${link.port}`,
|
|
3677
|
-
detail: [cableLine],
|
|
3678
|
-
...linksBack ? {} : { oneSided: true }
|
|
3679
|
-
});
|
|
3680
|
-
if (isBundle)
|
|
3681
|
-
bundles.set(bundleKey, { cable, members: 1 });
|
|
3682
|
-
const lagSettings = lag ? device.interfaces?.[lag] : undefined;
|
|
3683
|
-
const carrier = lagSettings?.type === "lag" ? lagSettings : lag ? undefined : port;
|
|
3684
|
-
const vlans = carriedBy(device, carrier);
|
|
3685
|
-
if (!vlans.length)
|
|
3692
|
+
const farPort = far.ports?.[link.port];
|
|
3693
|
+
if (!farPort) {
|
|
3694
|
+
report(`${link.device} does not declare port ${link.port}`);
|
|
3686
3695
|
continue;
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
to
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3696
|
+
}
|
|
3697
|
+
const linksBack = farPort.link?.device === device.name && farPort.link.port === portName;
|
|
3698
|
+
if (!linksBack) {
|
|
3699
|
+
report(`${link.device} ${link.port} does not link back to it`);
|
|
3700
|
+
continue;
|
|
3701
|
+
}
|
|
3702
|
+
const here = JSON.stringify(carried(device, port));
|
|
3703
|
+
const there = JSON.stringify(carried(far, farPort));
|
|
3704
|
+
if (here !== there)
|
|
3705
|
+
report(`carries ${here}, and ${link.device} ${link.port} carries ${there}`);
|
|
3706
|
+
if (port.speed && farPort.speed && port.speed !== farPort.speed) {
|
|
3707
|
+
report(`speed ${port.speed}, and ${link.device} ${link.port} is ${farPort.speed}`);
|
|
3708
|
+
}
|
|
3709
|
+
const near = carrierOf(device, portName, port);
|
|
3710
|
+
const away = carrierOf(far, link.port, farPort);
|
|
3711
|
+
const mtu = near.settings.mtu ?? 1500;
|
|
3712
|
+
const farMtu = away.settings.mtu ?? 1500;
|
|
3713
|
+
if (mtu !== farMtu)
|
|
3714
|
+
report(`MTU ${mtu} on ${near.name}, and ${link.device} ${away.name} has ${farMtu}`);
|
|
3715
|
+
const disagreement = addressDisagreement(near.settings, away.settings, far, away.name);
|
|
3716
|
+
if (disagreement)
|
|
3717
|
+
report(`${near.name} ${disagreement}`);
|
|
3718
|
+
const farNames = vlanNames(far, away.settings);
|
|
3719
|
+
for (const [id, name] of vlanNames(device, near.settings)) {
|
|
3720
|
+
const farName = farNames.get(id);
|
|
3721
|
+
if (farName && farName !== name)
|
|
3722
|
+
report(`VLAN ${id} is ${name} here and ${farName} on ${link.device}`, "warning");
|
|
3723
|
+
}
|
|
3699
3724
|
}
|
|
3700
3725
|
}
|
|
3726
|
+
return findings;
|
|
3701
3727
|
}
|
|
3702
|
-
function
|
|
3703
|
-
const
|
|
3704
|
-
const
|
|
3728
|
+
function checkAddresses(holders2) {
|
|
3729
|
+
const findings = [];
|
|
3730
|
+
const first = new Map;
|
|
3731
|
+
const isLoopback = (holder) => holder.device.interfaces?.[holder.name]?.type === "loopback";
|
|
3705
3732
|
for (const holder of holders2) {
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
const ends = [`${holder.device.name} ${holder.name}`, `${far.device.name} ${far.name}`].sort().join("|");
|
|
3711
|
-
if (pairs.has(ends))
|
|
3712
|
-
continue;
|
|
3713
|
-
pairs.add(ends);
|
|
3714
|
-
graph.edge({
|
|
3715
|
-
layer: "l3",
|
|
3716
|
-
from: deviceId(holder.device.name),
|
|
3717
|
-
to: deviceId(far.device.name),
|
|
3718
|
-
label: `${host(holder.address)} to ${host(far.address)}`,
|
|
3719
|
-
detail: [`${holder.device.name} ${holder.name} ${holder.address}`, `${far.device.name} ${far.name} ${far.address}`]
|
|
3720
|
-
});
|
|
3733
|
+
const key = keyOf(host(holder.address));
|
|
3734
|
+
const earlier = first.get(key);
|
|
3735
|
+
if (!earlier) {
|
|
3736
|
+
first.set(key, holder);
|
|
3721
3737
|
continue;
|
|
3722
3738
|
}
|
|
3723
|
-
if (
|
|
3724
|
-
continue;
|
|
3725
|
-
const segment = network(holder.address);
|
|
3726
|
-
bySegment.set(segment, [...bySegment.get(segment) ?? [], holder]);
|
|
3727
|
-
}
|
|
3728
|
-
for (const [segment, members] of bySegment) {
|
|
3729
|
-
if (new Set(members.map((member) => member.device.name)).size < 2)
|
|
3730
|
-
continue;
|
|
3731
|
-
const memberLines = members.map((member) => `${member.device.name} ${member.name} ${member.address}`);
|
|
3732
|
-
const vrrpLines3 = members.flatMap((member) => member.vrrp.map((group) => `VRRP ${group.id} ${group.address} on ${member.device.name}, priority ${group.priority ?? 100}`));
|
|
3733
|
-
if (members.length === 2) {
|
|
3734
|
-
const [a, b] = members;
|
|
3735
|
-
graph.edge({
|
|
3736
|
-
layer: "l3",
|
|
3737
|
-
from: deviceId(a.device.name),
|
|
3738
|
-
to: deviceId(b.device.name),
|
|
3739
|
-
label: segment,
|
|
3740
|
-
detail: [...memberLines, ...vrrpLines3]
|
|
3741
|
-
});
|
|
3739
|
+
if (earlier.device === holder.device)
|
|
3742
3740
|
continue;
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
to: id,
|
|
3750
|
-
label: `${member.name} ${host(member.address)}`,
|
|
3751
|
-
detail: [`${member.device.name} ${member.name} ${member.address}`]
|
|
3752
|
-
});
|
|
3753
|
-
}
|
|
3741
|
+
const anycast = isLoopback(earlier) && isLoopback(holder);
|
|
3742
|
+
findings.push({
|
|
3743
|
+
level: anycast ? "warning" : "error",
|
|
3744
|
+
device: holder.device.name,
|
|
3745
|
+
message: `${host(holder.address)} on ${holder.name} is also on ${earlier.device.name} ${earlier.name}${anycast ? ", which is right only for anycast" : ""}`
|
|
3746
|
+
});
|
|
3754
3747
|
}
|
|
3748
|
+
return findings;
|
|
3755
3749
|
}
|
|
3756
|
-
function
|
|
3757
|
-
const
|
|
3750
|
+
function checkSessions(devices, owners) {
|
|
3751
|
+
const findings = [];
|
|
3752
|
+
const sessionsOf = (device) => {
|
|
3758
3753
|
const bgp = device.routing?.bgp;
|
|
3759
3754
|
return Object.entries(bgp?.neighbors ?? {}).map(([name, neighbor]) => {
|
|
3760
3755
|
const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
|
|
3761
3756
|
return {
|
|
3762
|
-
device,
|
|
3763
3757
|
name,
|
|
3764
3758
|
address: neighbor.address,
|
|
3765
|
-
ownAs: bgp.asn,
|
|
3766
3759
|
remoteAs: neighbor.remote_as ?? group?.remote_as,
|
|
3767
|
-
|
|
3768
|
-
description: neighbor.description ?? group?.description,
|
|
3769
|
-
import: neighbor.import ?? group?.import,
|
|
3770
|
-
export: neighbor.export ?? group?.export
|
|
3760
|
+
localAddress: neighbor.local_address ?? group?.local_address
|
|
3771
3761
|
};
|
|
3772
3762
|
});
|
|
3773
|
-
});
|
|
3774
|
-
const describe2 = (session) => {
|
|
3775
|
-
const parts = [
|
|
3776
|
-
session.address,
|
|
3777
|
-
session.remoteAs ? `AS${session.remoteAs}` : "no remote AS",
|
|
3778
|
-
session.role,
|
|
3779
|
-
session.import ? `import ${session.import}` : "imports nothing",
|
|
3780
|
-
session.export ? `export ${session.export}` : "exports nothing"
|
|
3781
|
-
];
|
|
3782
|
-
return `${session.device.name} ${session.name}: ${parts.filter(Boolean).join(", ")}`;
|
|
3783
3763
|
};
|
|
3784
|
-
const
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3764
|
+
for (const device of devices) {
|
|
3765
|
+
for (const session of sessionsOf(device)) {
|
|
3766
|
+
const far = owners.get(keyOf(session.address) ?? "")?.device;
|
|
3767
|
+
if (!far || far === device)
|
|
3768
|
+
continue;
|
|
3769
|
+
const report = (message) => findings.push({ level: "error", device: device.name, message: `neighbor ${session.name}: ${message}` });
|
|
3770
|
+
const farBgp = far.routing?.bgp;
|
|
3771
|
+
if (!farBgp) {
|
|
3772
|
+
report(`${session.address} is ${far.name}'s, which runs no BGP`);
|
|
3773
|
+
continue;
|
|
3774
|
+
}
|
|
3775
|
+
if (session.remoteAs !== undefined && session.remoteAs !== farBgp.asn) {
|
|
3776
|
+
report(`expects AS${session.remoteAs}, and ${far.name} is AS${farBgp.asn}`);
|
|
3777
|
+
}
|
|
3778
|
+
const back = sessionsOf(far).find((other) => owners.get(keyOf(other.address) ?? "")?.device === device && familyOf(other.address) === familyOf(session.address));
|
|
3779
|
+
if (!back) {
|
|
3780
|
+
report(`${far.name} has no session back to ${device.name}`);
|
|
3781
|
+
continue;
|
|
3782
|
+
}
|
|
3783
|
+
if (session.localAddress && keyOf(session.localAddress) !== keyOf(back.address)) {
|
|
3784
|
+
report(`starts from ${session.localAddress}, and ${far.name}'s session ${back.name} expects ${back.address}`);
|
|
3785
|
+
}
|
|
3803
3786
|
}
|
|
3804
|
-
const outside = session.remoteAs ? graph.node(`as:${session.remoteAs}`, "outside", `AS${session.remoteAs}`, session.description ? [session.description] : []) : graph.node(`address:${session.address}`, "outside", session.address, ["a BGP neighbor without a remote AS"]);
|
|
3805
|
-
const internal = session.remoteAs === session.ownAs;
|
|
3806
|
-
graph.edge({
|
|
3807
|
-
layer: "bgp",
|
|
3808
|
-
from: deviceId(session.device.name),
|
|
3809
|
-
to: outside,
|
|
3810
|
-
label: [session.name, session.role].filter(Boolean).join(", "),
|
|
3811
|
-
detail: [describe2(session), ...internal ? ["internal, and no device in the network holds the address"] : []],
|
|
3812
|
-
...internal ? { oneSided: true } : {}
|
|
3813
|
-
});
|
|
3814
3787
|
}
|
|
3788
|
+
return findings;
|
|
3815
3789
|
}
|
|
3816
|
-
function
|
|
3817
|
-
const
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
if (!isCidr(address) || isHostRoute(address))
|
|
3828
|
-
continue;
|
|
3829
|
-
const key = `${area} ${network(address)}`;
|
|
3830
|
-
const member = { device, name, line: `${device.name} ${name} ${address}${cost}` };
|
|
3831
|
-
byKey.set(key, [...byKey.get(key) ?? [], member]);
|
|
3832
|
-
}
|
|
3833
|
-
}
|
|
3790
|
+
function checkVrrp(holders2) {
|
|
3791
|
+
const findings = [];
|
|
3792
|
+
const bySegment = new Map;
|
|
3793
|
+
for (const holder of holders2) {
|
|
3794
|
+
const segment = network(holder.address);
|
|
3795
|
+
for (const group of holder.vrrp) {
|
|
3796
|
+
if (!keyOf(group.address) || familyOf(group.address) !== familyOf(holder.address))
|
|
3797
|
+
continue;
|
|
3798
|
+
if (!contains(segment, group.address))
|
|
3799
|
+
continue;
|
|
3800
|
+
bySegment.set(segment, [...bySegment.get(segment) ?? [], { holder, id: group.id, address: group.address }]);
|
|
3834
3801
|
}
|
|
3835
3802
|
}
|
|
3836
|
-
for (const
|
|
3837
|
-
const
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3803
|
+
for (const members of bySegment.values()) {
|
|
3804
|
+
for (const member of members) {
|
|
3805
|
+
const where = `${member.holder.device.name} ${member.holder.name}`;
|
|
3806
|
+
const others = members.filter((other) => other.holder.device !== member.holder.device);
|
|
3807
|
+
const sameId = others.find((other) => other.id === member.id && keyOf(other.address) !== keyOf(member.address));
|
|
3808
|
+
if (sameId) {
|
|
3809
|
+
findings.push({
|
|
3810
|
+
level: "error",
|
|
3811
|
+
device: member.holder.device.name,
|
|
3812
|
+
message: `VRRP ${member.id} on ${member.holder.name} is ${member.address}, and on ${sameId.holder.device.name} ${sameId.holder.name} it is ${sameId.address}`
|
|
3813
|
+
});
|
|
3814
|
+
}
|
|
3815
|
+
const sameAddress = others.find((other) => keyOf(other.address) === keyOf(member.address) && other.id !== member.id);
|
|
3816
|
+
if (sameAddress) {
|
|
3817
|
+
findings.push({
|
|
3818
|
+
level: "error",
|
|
3819
|
+
device: member.holder.device.name,
|
|
3820
|
+
message: `VRRP address ${member.address} is id ${member.id} on ${where}, and id ${sameAddress.id} on ${sameAddress.holder.device.name} ${sameAddress.holder.name}`
|
|
3848
3821
|
});
|
|
3849
3822
|
}
|
|
3850
3823
|
}
|
|
3851
3824
|
}
|
|
3825
|
+
return findings;
|
|
3852
3826
|
}
|
|
3853
|
-
|
|
3854
|
-
|
|
3827
|
+
function checkTunnels(devices, owners) {
|
|
3828
|
+
const findings = [];
|
|
3855
3829
|
const ends = devices.flatMap((device) => Object.entries(device.interfaces ?? {}).flatMap(([name, iface]) => iface.type === "gre" || iface.type === "vxlan" ? [{ device, name, iface }] : []));
|
|
3856
3830
|
const same = (a, b) => keyOf(a) !== undefined && keyOf(a) === keyOf(b);
|
|
3857
|
-
const mirrors = (a, b) => a.device !== b.device && a.iface.type === b.iface.type && same(a.iface.local, b.iface.remote) && same(a.iface.remote, b.iface.local) && (a.iface.type !== "vxlan" || b.iface.type === "vxlan" && a.iface.vni === b.iface.vni);
|
|
3858
|
-
const describe2 = (end) => {
|
|
3859
|
-
const vni = end.iface.type === "vxlan" ? `, VNI ${end.iface.vni}` : "";
|
|
3860
|
-
return `${end.device.name} ${end.name}: ${end.iface.local} to ${end.iface.remote}${vni}`;
|
|
3861
|
-
};
|
|
3862
|
-
const paired = new Set;
|
|
3863
3831
|
for (const end of ends) {
|
|
3864
|
-
|
|
3832
|
+
const far = owners.get(keyOf(end.iface.remote) ?? "")?.device;
|
|
3833
|
+
if (!far || far === end.device)
|
|
3865
3834
|
continue;
|
|
3835
|
+
const report = (message) => findings.push({ level: "error", device: end.device.name, message: `interface ${end.name}: ${message}` });
|
|
3866
3836
|
const kind = end.iface.type === "gre" ? "GRE" : "VXLAN";
|
|
3867
|
-
const mirror = ends.find((other) =>
|
|
3868
|
-
if (mirror) {
|
|
3869
|
-
|
|
3870
|
-
graph.edge({
|
|
3871
|
-
layer: "tunnels",
|
|
3872
|
-
from: deviceId(end.device.name),
|
|
3873
|
-
to: deviceId(mirror.device.name),
|
|
3874
|
-
label: `${kind} ${end.name} to ${mirror.name}`,
|
|
3875
|
-
detail: [describe2(end), describe2(mirror)]
|
|
3876
|
-
});
|
|
3837
|
+
const mirror = ends.find((other) => other.device === far && other.iface.type === end.iface.type && same(other.iface.local, end.iface.remote) && same(other.iface.remote, end.iface.local));
|
|
3838
|
+
if (!mirror) {
|
|
3839
|
+
report(`${end.iface.remote} is ${far.name}'s, which has no ${kind} tunnel from there back to ${end.iface.local}`);
|
|
3877
3840
|
continue;
|
|
3878
3841
|
}
|
|
3879
|
-
|
|
3880
|
-
if (owner && owner !== end.device) {
|
|
3881
|
-
graph.edge({
|
|
3882
|
-
layer: "tunnels",
|
|
3883
|
-
from: deviceId(end.device.name),
|
|
3884
|
-
to: deviceId(owner.name),
|
|
3885
|
-
label: `${kind} ${end.name}`,
|
|
3886
|
-
detail: [describe2(end), `${owner.name} declares no tunnel back`],
|
|
3887
|
-
oneSided: true
|
|
3888
|
-
});
|
|
3842
|
+
if (end.iface.type !== "vxlan" || mirror.iface.type !== "vxlan")
|
|
3889
3843
|
continue;
|
|
3890
|
-
|
|
3891
|
-
|
|
3892
|
-
|
|
3893
|
-
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
label: `${kind} ${end.name}`,
|
|
3897
|
-
detail: [describe2(end)]
|
|
3898
|
-
});
|
|
3899
|
-
}
|
|
3900
|
-
wireguard2(devices, owners, graph);
|
|
3901
|
-
}
|
|
3902
|
-
function wireguard2(devices, owners, graph) {
|
|
3903
|
-
const interfaces = devices.flatMap((device) => Object.entries(device.interfaces ?? {}).flatMap(([name, iface]) => iface.type === "wireguard" ? [{ device, name, iface }] : []));
|
|
3904
|
-
const farEndOf = (device, peer) => interfaces.find(({ device: other, iface }) => {
|
|
3905
|
-
if (other === device)
|
|
3906
|
-
return false;
|
|
3907
|
-
const tunnelAddresses = (iface.addresses ?? []).map((entry) => host(typeof entry === "string" ? entry : entry.address));
|
|
3908
|
-
const sendsFrom = tunnelAddresses.some((address) => isAddress(address) && peer.allowed_addresses.some((allowed) => isCidr(allowed) && contains(allowed, address)));
|
|
3909
|
-
const endpoint = peer.endpoint ? endpointHost(peer.endpoint) : undefined;
|
|
3910
|
-
const isEndpoint = endpoint !== undefined && owners.get(keyOf(endpoint) ?? "")?.device === other;
|
|
3911
|
-
return sendsFrom || isEndpoint;
|
|
3912
|
-
});
|
|
3913
|
-
const drawn = new Set;
|
|
3914
|
-
for (const { device, name, iface } of interfaces) {
|
|
3915
|
-
const clients = [];
|
|
3916
|
-
for (const peer of iface.peers ?? []) {
|
|
3917
|
-
const far = farEndOf(device, peer);
|
|
3918
|
-
if (far) {
|
|
3919
|
-
const ends = [`${device.name} ${name}`, `${far.device.name} ${far.name}`].sort().join("|");
|
|
3920
|
-
if (drawn.has(ends))
|
|
3921
|
-
continue;
|
|
3922
|
-
drawn.add(ends);
|
|
3923
|
-
graph.edge({
|
|
3924
|
-
layer: "tunnels",
|
|
3925
|
-
from: deviceId(device.name),
|
|
3926
|
-
to: deviceId(far.device.name),
|
|
3927
|
-
label: `WireGuard ${name} to ${far.name}`,
|
|
3928
|
-
detail: [`${device.name} ${name} peer ${peer.name}`]
|
|
3929
|
-
});
|
|
3930
|
-
continue;
|
|
3931
|
-
}
|
|
3932
|
-
if (!peer.endpoint) {
|
|
3933
|
-
clients.push(peer);
|
|
3934
|
-
continue;
|
|
3935
|
-
}
|
|
3936
|
-
const endpoint = endpointHost(peer.endpoint);
|
|
3937
|
-
const outside2 = graph.node(`address:${endpoint}`, "outside", peer.name, [`WireGuard endpoint ${peer.endpoint}`]);
|
|
3938
|
-
graph.edge({
|
|
3939
|
-
layer: "tunnels",
|
|
3940
|
-
from: deviceId(device.name),
|
|
3941
|
-
to: outside2,
|
|
3942
|
-
label: `WireGuard ${name}`,
|
|
3943
|
-
detail: [`${device.name} ${name} peer ${peer.name}, allowed ${peer.allowed_addresses.join(", ")}`]
|
|
3944
|
-
});
|
|
3945
|
-
}
|
|
3946
|
-
if (!clients.length)
|
|
3947
|
-
continue;
|
|
3948
|
-
const outside = graph.node(`clients:${device.name}:${name}`, "outside", `${clients.length} ${clients.length === 1 ? "client" : "clients"}`, clients.map((peer) => `${peer.name}: ${peer.allowed_addresses.join(", ")}`));
|
|
3949
|
-
graph.edge({
|
|
3950
|
-
layer: "tunnels",
|
|
3951
|
-
from: deviceId(device.name),
|
|
3952
|
-
to: outside,
|
|
3953
|
-
label: `WireGuard ${name}`,
|
|
3954
|
-
detail: [`peers that dial in to ${device.name} ${name}`]
|
|
3955
|
-
});
|
|
3956
|
-
}
|
|
3957
|
-
}
|
|
3958
|
-
function graphOf(devices) {
|
|
3959
|
-
const graph = new Builder;
|
|
3960
|
-
for (const device of devices)
|
|
3961
|
-
graph.node(deviceId(device.name), "device", device.name, describeDevice(device));
|
|
3962
|
-
const holders2 = holdersOf(devices);
|
|
3963
|
-
const owners = new Map;
|
|
3964
|
-
for (const holder of holders2) {
|
|
3965
|
-
const key = keyOf(host(holder.address));
|
|
3966
|
-
if (!owners.has(key))
|
|
3967
|
-
owners.set(key, holder);
|
|
3844
|
+
if (end.iface.vni !== mirror.iface.vni)
|
|
3845
|
+
report(`VNI ${end.iface.vni}, and ${far.name} ${mirror.name} has ${mirror.iface.vni}`);
|
|
3846
|
+
const port = end.iface.port ?? 4789;
|
|
3847
|
+
const farPort = mirror.iface.port ?? 4789;
|
|
3848
|
+
if (port !== farPort)
|
|
3849
|
+
report(`UDP port ${port}, and ${far.name} ${mirror.name} has ${farPort}`);
|
|
3968
3850
|
}
|
|
3969
|
-
|
|
3970
|
-
segments(holders2, owners, graph);
|
|
3971
|
-
sessions(devices, owners, graph);
|
|
3972
|
-
adjacencies(devices, graph);
|
|
3973
|
-
tunnels2(devices, owners, graph);
|
|
3974
|
-
return { nodes: [...graph.nodes.values()], edges: graph.edges };
|
|
3975
|
-
}
|
|
3976
|
-
function only(graph, layers, deviceNames = []) {
|
|
3977
|
-
const named = new Set(deviceNames.map(deviceId));
|
|
3978
|
-
const touches = (edge) => !named.size || named.has(edge.from) || named.has(edge.to);
|
|
3979
|
-
const edges = graph.edges.filter((edge) => layers.includes(edge.layer) && touches(edge));
|
|
3980
|
-
const ends = new Set(edges.flatMap((edge) => [edge.from, edge.to]));
|
|
3981
|
-
const nodes = graph.nodes.filter((node) => (named.size ? named.has(node.id) : node.kind === "device") || ends.has(node.id));
|
|
3982
|
-
return { nodes, edges };
|
|
3851
|
+
return findings;
|
|
3983
3852
|
}
|
|
3984
3853
|
|
|
3985
3854
|
// src/core/validate.ts
|
|
3986
|
-
import { X509Certificate as X509Certificate2 } from "node:crypto";
|
|
3987
3855
|
function validate(network2) {
|
|
3988
3856
|
const findings = [];
|
|
3989
3857
|
const seen = new Set;
|
|
@@ -4002,7 +3870,7 @@ function validate(network2) {
|
|
|
4002
3870
|
for (const field of adapter.unsupported(device))
|
|
4003
3871
|
report(`${device.platform} cannot express ${field}`);
|
|
4004
3872
|
}
|
|
4005
|
-
findings.push(...
|
|
3873
|
+
findings.push(...checkAcross(network2.devices));
|
|
4006
3874
|
for (const check of network2.checks ?? [])
|
|
4007
3875
|
findings.push(...check(network2.devices));
|
|
4008
3876
|
return findings;
|
|
@@ -4047,7 +3915,7 @@ function checkDevice(device, report) {
|
|
|
4047
3915
|
if (!device.users?.[device.connection.user])
|
|
4048
3916
|
report(`connection user '${device.connection.user}' is not one of its users`);
|
|
4049
3917
|
checkVlans(device, report);
|
|
4050
|
-
|
|
3918
|
+
checkAddresses2(device, report, cidr, ip);
|
|
4051
3919
|
checkPorts(device, names, report);
|
|
4052
3920
|
checkInterfaces(device, names, report, ip);
|
|
4053
3921
|
checkRouting(device, names, report, cidr, ip);
|
|
@@ -4066,7 +3934,7 @@ function checkVlans(device, report) {
|
|
|
4066
3934
|
ids.set(vlan.id, name);
|
|
4067
3935
|
}
|
|
4068
3936
|
}
|
|
4069
|
-
function
|
|
3937
|
+
function checkAddresses2(device, report, cidr, ip) {
|
|
4070
3938
|
const holderOf = new Map;
|
|
4071
3939
|
const holders2 = [
|
|
4072
3940
|
...Object.entries(device.ports ?? {}).map(([name, settings]) => ({ where: `port ${name}`, settings })),
|
|
@@ -4279,48 +4147,6 @@ function checkCertificates(device, names, report) {
|
|
|
4279
4147
|
}
|
|
4280
4148
|
}
|
|
4281
4149
|
}
|
|
4282
|
-
function carried(device, port) {
|
|
4283
|
-
const id = (vlan) => vlan ? device.vlans?.[vlan]?.id : undefined;
|
|
4284
|
-
return {
|
|
4285
|
-
access: id(port.access_vlan),
|
|
4286
|
-
native: id(port.native_vlan),
|
|
4287
|
-
trunk: (port.trunk_vlans ?? []).map(id).sort((a, b) => (a ?? 0) - (b ?? 0))
|
|
4288
|
-
};
|
|
4289
|
-
}
|
|
4290
|
-
function checkLinks(devices) {
|
|
4291
|
-
const findings = [];
|
|
4292
|
-
const byName = new Map(devices.map((device) => [device.name, device]));
|
|
4293
|
-
for (const device of devices) {
|
|
4294
|
-
for (const [portName, port] of Object.entries(device.ports ?? {})) {
|
|
4295
|
-
const link = port?.link;
|
|
4296
|
-
if (!port || !link)
|
|
4297
|
-
continue;
|
|
4298
|
-
const report = (message) => findings.push({
|
|
4299
|
-
level: "error",
|
|
4300
|
-
device: device.name,
|
|
4301
|
-
message: `port ${portName} link: ${message}`
|
|
4302
|
-
});
|
|
4303
|
-
const far = byName.get(link.device);
|
|
4304
|
-
if (!far) {
|
|
4305
|
-
report(`device ${link.device} is not in the network`);
|
|
4306
|
-
continue;
|
|
4307
|
-
}
|
|
4308
|
-
const farPort = far.ports?.[link.port];
|
|
4309
|
-
if (!farPort) {
|
|
4310
|
-
report(`${link.device} does not declare port ${link.port}`);
|
|
4311
|
-
continue;
|
|
4312
|
-
}
|
|
4313
|
-
const linksBack = farPort.link?.device === device.name && farPort.link.port === portName;
|
|
4314
|
-
if (!linksBack)
|
|
4315
|
-
report(`${link.device} ${link.port} does not link back to it`);
|
|
4316
|
-
const here = JSON.stringify(carried(device, port));
|
|
4317
|
-
const there = JSON.stringify(carried(far, farPort));
|
|
4318
|
-
if (here !== there)
|
|
4319
|
-
report(`carries ${here}, and ${link.device} ${link.port} carries ${there}`);
|
|
4320
|
-
}
|
|
4321
|
-
}
|
|
4322
|
-
return findings;
|
|
4323
|
-
}
|
|
4324
4150
|
|
|
4325
4151
|
// src/cli/graph.ts
|
|
4326
4152
|
function markOf(graph, node) {
|
|
@@ -4392,6 +4218,117 @@ ${node.detail[0] ?? ""}` : node.label;
|
|
|
4392
4218
|
`;
|
|
4393
4219
|
}
|
|
4394
4220
|
|
|
4221
|
+
// src/cli/rename.ts
|
|
4222
|
+
import { existsSync as existsSync2, readFileSync as readFileSync3, renameSync, writeFileSync } from "node:fs";
|
|
4223
|
+
import { basename, dirname, extname, join as join3, resolve as resolve2 } from "node:path";
|
|
4224
|
+
|
|
4225
|
+
// src/core/files.ts
|
|
4226
|
+
import { readdirSync, readFileSync as readFileSync2 } from "node:fs";
|
|
4227
|
+
import { join as join2, relative } from "node:path";
|
|
4228
|
+
var SKIPPED = new Set(["node_modules", ".git", ".circuit", "dist"]);
|
|
4229
|
+
function projectSources(root2, directory = root2) {
|
|
4230
|
+
const entries2 = readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
4231
|
+
return entries2.flatMap((entry) => {
|
|
4232
|
+
if (entry.isDirectory())
|
|
4233
|
+
return SKIPPED.has(entry.name) ? [] : projectSources(root2, join2(directory, entry.name));
|
|
4234
|
+
return /\.(ts|mts|cts)$/.test(entry.name) ? [relative(root2, join2(directory, entry.name))] : [];
|
|
4235
|
+
});
|
|
4236
|
+
}
|
|
4237
|
+
var escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4238
|
+
function declaresDevice(text, name) {
|
|
4239
|
+
const declares = new RegExp(`\\bname\\s*:\\s*(["'\`])${escape(name)}\\1`);
|
|
4240
|
+
return text.includes("defineDevice") && declares.test(text);
|
|
4241
|
+
}
|
|
4242
|
+
function deviceFiles(root2, name, sources = projectSources(root2)) {
|
|
4243
|
+
return sources.filter((file) => declaresDevice(readFileSync2(join2(root2, file), "utf8"), name));
|
|
4244
|
+
}
|
|
4245
|
+
|
|
4246
|
+
// src/cli/rename.ts
|
|
4247
|
+
var escape2 = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4248
|
+
function planRename(root2, from, to, options) {
|
|
4249
|
+
const changes2 = [];
|
|
4250
|
+
const moves = [];
|
|
4251
|
+
const exact = new RegExp(`(["'\`])${escape2(from)}\\1`, "g");
|
|
4252
|
+
const anywhere = new RegExp(`(^|[^A-Za-z0-9_.-])${escape2(from)}(?![A-Za-z0-9_-])`);
|
|
4253
|
+
const sources = projectSources(root2);
|
|
4254
|
+
const declaring = new Set;
|
|
4255
|
+
for (const file of sources) {
|
|
4256
|
+
const text = readFileSync3(join3(root2, file), "utf8");
|
|
4257
|
+
const lines = text.split(`
|
|
4258
|
+
`);
|
|
4259
|
+
if (declaresDevice(text, from))
|
|
4260
|
+
declaring.add(file);
|
|
4261
|
+
for (const [index, before] of lines.entries()) {
|
|
4262
|
+
const after = before.replace(exact, (_match, quote3) => `${quote3}${to}${quote3}`);
|
|
4263
|
+
if (after !== before)
|
|
4264
|
+
changes2.push({ file, line: index + 1, before, after });
|
|
4265
|
+
}
|
|
4266
|
+
}
|
|
4267
|
+
if (!declaring.size) {
|
|
4268
|
+
throw new Error(`no file declares name: "${from}"; the name may be built from parts, such as a template string, so rename it by hand`);
|
|
4269
|
+
}
|
|
4270
|
+
for (const snapshot of options.snapshots) {
|
|
4271
|
+
moves.push({ from: snapshot, to: join3(dirname(snapshot), `${to}${extname(snapshot)}`) });
|
|
4272
|
+
}
|
|
4273
|
+
if (options.file) {
|
|
4274
|
+
if (declaring.size > 1)
|
|
4275
|
+
throw new Error(`${[...declaring].join(" and ")} both declare name: "${from}"; rename the file by hand`);
|
|
4276
|
+
const [file] = [...declaring];
|
|
4277
|
+
const target = join3(dirname(file), `${to}${extname(file)}`);
|
|
4278
|
+
if (existsSync2(join3(root2, target)))
|
|
4279
|
+
throw new Error(`${target} already exists`);
|
|
4280
|
+
moves.push({ from: file, to: target });
|
|
4281
|
+
changes2.push(...importChanges(root2, sources, file, target, changes2));
|
|
4282
|
+
}
|
|
4283
|
+
const mentions = sources.flatMap((file) => readFileSync3(join3(root2, file), "utf8").split(`
|
|
4284
|
+
`).flatMap((original, index) => {
|
|
4285
|
+
const text = changes2.find((change) => change.file === file && change.line === index + 1)?.after ?? original;
|
|
4286
|
+
return anywhere.test(text) ? [{ file, line: index + 1, text: text.trim() }] : [];
|
|
4287
|
+
}));
|
|
4288
|
+
return { changes: changes2, moves, mentions };
|
|
4289
|
+
}
|
|
4290
|
+
function importChanges(root2, sources, file, target, earlier) {
|
|
4291
|
+
const result = [];
|
|
4292
|
+
const withoutExtension = (path) => path.replace(/\.(ts|mts|cts|js|mjs|cjs)$/, "");
|
|
4293
|
+
const moved = withoutExtension(resolve2(root2, file));
|
|
4294
|
+
for (const source of sources) {
|
|
4295
|
+
const lines = readFileSync3(join3(root2, source), "utf8").split(`
|
|
4296
|
+
`);
|
|
4297
|
+
for (const [index, original] of lines.entries()) {
|
|
4298
|
+
const line2 = index + 1;
|
|
4299
|
+
const changed = earlier.find((change) => change.file === source && change.line === line2);
|
|
4300
|
+
const before = changed?.after ?? original;
|
|
4301
|
+
const after = before.replace(/(\bfrom\s+|\bimport\s*\(\s*)(["'])(\.{1,2}\/[^"']+)\2/g, (match, lead, quote3, specifier) => {
|
|
4302
|
+
if (withoutExtension(resolve2(root2, dirname(source), specifier)) !== moved)
|
|
4303
|
+
return match;
|
|
4304
|
+
const extension = specifier.match(/\.(ts|mts|cts|js|mjs|cjs)$/)?.[0] ?? "";
|
|
4305
|
+
const renamed = `${dirname(specifier)}/${basename(withoutExtension(target))}${extension}`;
|
|
4306
|
+
return `${lead}${quote3}${renamed}${quote3}`;
|
|
4307
|
+
});
|
|
4308
|
+
if (after === before)
|
|
4309
|
+
continue;
|
|
4310
|
+
if (changed)
|
|
4311
|
+
changed.after = after;
|
|
4312
|
+
else
|
|
4313
|
+
result.push({ file: source, line: line2, before: original, after });
|
|
4314
|
+
}
|
|
4315
|
+
}
|
|
4316
|
+
return result;
|
|
4317
|
+
}
|
|
4318
|
+
function applyRename(root2, plan4) {
|
|
4319
|
+
const files = new Set(plan4.changes.map((change) => change.file));
|
|
4320
|
+
for (const file of files) {
|
|
4321
|
+
const lines = readFileSync3(join3(root2, file), "utf8").split(`
|
|
4322
|
+
`);
|
|
4323
|
+
for (const change of plan4.changes.filter((each) => each.file === file))
|
|
4324
|
+
lines[change.line - 1] = change.after;
|
|
4325
|
+
writeFileSync(join3(root2, file), lines.join(`
|
|
4326
|
+
`));
|
|
4327
|
+
}
|
|
4328
|
+
for (const move of plan4.moves)
|
|
4329
|
+
renameSync(join3(root2, move.from), join3(root2, move.to));
|
|
4330
|
+
}
|
|
4331
|
+
|
|
4395
4332
|
// src/cli/index.ts
|
|
4396
4333
|
var HELP = `usage: circuit <command> [device...] [options]
|
|
4397
4334
|
|
|
@@ -4412,12 +4349,22 @@ var HELP = `usage: circuit <command> [device...] [options]
|
|
|
4412
4349
|
prometheus every device with SNMP, as Prometheus targets for snmp_exporter
|
|
4413
4350
|
--auths snmp_exporter's auths for them instead, with secrets as \${NAME}
|
|
4414
4351
|
secrets every secret the config uses, and whether each can be read
|
|
4352
|
+
rename <device> <new-name>
|
|
4353
|
+
give a device a new name in every file that names it, with --confirm
|
|
4354
|
+
--file also rename the file that declares it, to <new-name>.ts
|
|
4355
|
+
addresses [prefix] every address the config uses, by prefix, and what is still free
|
|
4356
|
+
--length=N free prefixes of this length, instead of the fewest that cover the free space
|
|
4357
|
+
--format=json as JSON
|
|
4415
4358
|
graph [device...] the network as a graph, from the config alone, without contacting a device
|
|
4416
4359
|
--layer=l1 l1, l2, l3, bgp, ospf or tunnels, several with commas, or all
|
|
4417
4360
|
--format=mermaid mermaid, dot or json
|
|
4418
|
-
studio the
|
|
4419
|
-
--port=4600 the port it listens on
|
|
4361
|
+
studio Circuit Studio: the network in the browser, drawn again whenever the config changes
|
|
4362
|
+
--port=4600 the port it listens on
|
|
4420
4363
|
--no-open do not open the browser
|
|
4364
|
+
--host=0.0.0.0 listen on a server's address instead, with --allow-host
|
|
4365
|
+
--allow-host=studio.example.com
|
|
4366
|
+
the names it is reached by, several with commas
|
|
4367
|
+
--json print what Studio shows, as JSON, instead
|
|
4421
4368
|
|
|
4422
4369
|
--config <path> the network's config file, circuit.config.ts in the working directory by default`;
|
|
4423
4370
|
var DEFAULT_CONFIG = "circuit.config.ts";
|
|
@@ -4434,12 +4381,12 @@ if (!command2 || command2 === "help" || command2 === "--help" || command2 === "-
|
|
|
4434
4381
|
var configAt = args.indexOf("--config");
|
|
4435
4382
|
var configArgument = configAt >= 0 ? args[configAt + 1] : args.find((arg) => arg.startsWith("--config="))?.slice("--config=".length);
|
|
4436
4383
|
var rest = args.filter((arg, index) => index !== configAt && (configAt < 0 || index !== configAt + 1) && !arg.startsWith("--config="));
|
|
4437
|
-
var configPath =
|
|
4438
|
-
if (!
|
|
4384
|
+
var configPath = resolve3(process.cwd(), configArgument ?? DEFAULT_CONFIG);
|
|
4385
|
+
if (!existsSync3(configPath)) {
|
|
4439
4386
|
console.error(configArgument ? `no config at ${configPath}` : `no ${DEFAULT_CONFIG} in ${process.cwd()}; name another with --config <path>`);
|
|
4440
4387
|
process.exit(1);
|
|
4441
4388
|
}
|
|
4442
|
-
var root2 =
|
|
4389
|
+
var root2 = dirname2(configPath);
|
|
4443
4390
|
useRoot(root2);
|
|
4444
4391
|
async function load(path) {
|
|
4445
4392
|
if (process.versions.bun)
|
|
@@ -4471,9 +4418,9 @@ function one() {
|
|
|
4471
4418
|
return device ?? fail("name a device");
|
|
4472
4419
|
}
|
|
4473
4420
|
var adapterOf = (device) => adapters[device.platform] ?? fail(`no adapter for ${device.platform}`);
|
|
4474
|
-
var generated =
|
|
4475
|
-
var stateDirectory =
|
|
4476
|
-
var statePath = (device) =>
|
|
4421
|
+
var generated = join4(root2, ".circuit");
|
|
4422
|
+
var stateDirectory = join4(generated, "state");
|
|
4423
|
+
var statePath = (device) => join4(stateDirectory, device.name + adapterOf(device).extension);
|
|
4477
4424
|
var GENERATED_README = `# Written by Circuit
|
|
4478
4425
|
|
|
4479
4426
|
Do not edit anything here: an edit changes nothing on a device, and Circuit overwrites it. Change the config instead.
|
|
@@ -4482,8 +4429,8 @@ Do not edit anything here: an edit changes nothing on a device, and Circuit over
|
|
|
4482
4429
|
`;
|
|
4483
4430
|
function writeState(device, text) {
|
|
4484
4431
|
mkdirSync(stateDirectory, { recursive: true });
|
|
4485
|
-
|
|
4486
|
-
|
|
4432
|
+
writeFileSync2(join4(generated, "README.md"), GENERATED_README);
|
|
4433
|
+
writeFileSync2(statePath(device), text);
|
|
4487
4434
|
}
|
|
4488
4435
|
function guard(devices) {
|
|
4489
4436
|
const relevant = new Set(devices.map((device) => device.name));
|
|
@@ -4525,12 +4472,12 @@ function diffCommand() {
|
|
|
4525
4472
|
let differences = 0;
|
|
4526
4473
|
for (const device of devices) {
|
|
4527
4474
|
const path = statePath(device);
|
|
4528
|
-
if (!
|
|
4475
|
+
if (!existsSync3(path)) {
|
|
4529
4476
|
console.log(`${device.name}: no snapshot, run snapshot first`);
|
|
4530
4477
|
continue;
|
|
4531
4478
|
}
|
|
4532
4479
|
const adapter = adapterOf(device);
|
|
4533
|
-
const plan4 = adapter.plan(adapter.render(device),
|
|
4480
|
+
const plan4 = adapter.plan(adapter.render(device), readFileSync4(path, "utf8"), options);
|
|
4534
4481
|
differences += show(device, plan4);
|
|
4535
4482
|
}
|
|
4536
4483
|
process.exit(differences ? 1 : 0);
|
|
@@ -4542,13 +4489,13 @@ async function snapshotCommand() {
|
|
|
4542
4489
|
console.log(`${device.name}: ${text.split(`
|
|
4543
4490
|
`).length} lines`);
|
|
4544
4491
|
}
|
|
4545
|
-
if (names.length || !
|
|
4492
|
+
if (names.length || !existsSync3(stateDirectory))
|
|
4546
4493
|
return;
|
|
4547
|
-
const current = new Set(network2.devices.map((device) =>
|
|
4548
|
-
for (const file of
|
|
4494
|
+
const current = new Set(network2.devices.map((device) => basename2(statePath(device))));
|
|
4495
|
+
for (const file of readdirSync2(stateDirectory)) {
|
|
4549
4496
|
if (current.has(file))
|
|
4550
4497
|
continue;
|
|
4551
|
-
rmSync(
|
|
4498
|
+
rmSync(join4(stateDirectory, file));
|
|
4552
4499
|
console.log(`${file}: removed, no such device in the config`);
|
|
4553
4500
|
}
|
|
4554
4501
|
}
|
|
@@ -4714,11 +4661,11 @@ ${references.length} secret(s), ${missing} missing.`);
|
|
|
4714
4661
|
var flagValue = (name) => flags.find((flag) => flag.startsWith(`--${name}=`))?.slice(name.length + 3);
|
|
4715
4662
|
function driftOf(device) {
|
|
4716
4663
|
const path = statePath(device);
|
|
4717
|
-
if (!
|
|
4664
|
+
if (!existsSync3(path))
|
|
4718
4665
|
return { status: "no snapshot", lines: [] };
|
|
4719
4666
|
try {
|
|
4720
4667
|
const adapter = adapterOf(device);
|
|
4721
|
-
const plan4 = adapter.plan(adapter.render(device),
|
|
4668
|
+
const plan4 = adapter.plan(adapter.render(device), readFileSync4(path, "utf8"), options);
|
|
4722
4669
|
const lines = [
|
|
4723
4670
|
...plan4.steps.flatMap((step) => [step.title, ...step.show.map((line2) => ` ${line2}`)]),
|
|
4724
4671
|
...plan4.problems.map((problem) => `PROBLEM ${problem}`)
|
|
@@ -4728,6 +4675,15 @@ function driftOf(device) {
|
|
|
4728
4675
|
return { status: "cannot plan", lines: [error.message] };
|
|
4729
4676
|
}
|
|
4730
4677
|
}
|
|
4678
|
+
function annotatedGraph(layers, deviceNames) {
|
|
4679
|
+
const graph = only(graphOf(network2.devices), layers, deviceNames);
|
|
4680
|
+
const shown = new Set(graph.nodes.map((node) => node.label));
|
|
4681
|
+
return {
|
|
4682
|
+
...graph,
|
|
4683
|
+
findings: validate(network2).filter((finding) => !finding.device || shown.has(finding.device)),
|
|
4684
|
+
drift: Object.fromEntries(network2.devices.filter((device) => shown.has(device.name)).map((device) => [device.name, driftOf(device)]))
|
|
4685
|
+
};
|
|
4686
|
+
}
|
|
4731
4687
|
function graphCommand() {
|
|
4732
4688
|
const requested = (flagValue("layer") ?? "l1").split(",");
|
|
4733
4689
|
const layers = requested.includes("all") ? [...LAYERS] : requested;
|
|
@@ -4736,13 +4692,7 @@ function graphCommand() {
|
|
|
4736
4692
|
fail(`no layer ${unknown.join(", ")}; known: ${LAYERS.join(", ")}, all`);
|
|
4737
4693
|
const format = flagValue("format") ?? "mermaid";
|
|
4738
4694
|
const devices = selected(true);
|
|
4739
|
-
const
|
|
4740
|
-
const shown = new Set(graph.nodes.map((node) => node.label));
|
|
4741
|
-
const annotated = {
|
|
4742
|
-
...graph,
|
|
4743
|
-
findings: validate(network2).filter((finding) => !finding.device || shown.has(finding.device)),
|
|
4744
|
-
drift: Object.fromEntries(network2.devices.filter((device) => shown.has(device.name)).map((device) => [device.name, driftOf(device)]))
|
|
4745
|
-
};
|
|
4695
|
+
const annotated = annotatedGraph(layers, names.length ? devices.map((device) => device.name) : []);
|
|
4746
4696
|
if (format === "mermaid")
|
|
4747
4697
|
process.stdout.write(mermaid(annotated));
|
|
4748
4698
|
else if (format === "dot")
|
|
@@ -4752,12 +4702,117 @@ function graphCommand() {
|
|
|
4752
4702
|
else
|
|
4753
4703
|
fail(`no format ${format}; known: mermaid, dot, json`);
|
|
4754
4704
|
}
|
|
4705
|
+
function printSegment(segment, indent) {
|
|
4706
|
+
const next = segment.next ? `, next ${segment.next}` : "";
|
|
4707
|
+
console.log(`${indent}${segment.prefix}, ${segment.free} free${next}`);
|
|
4708
|
+
const width = Math.max(...segment.uses.map((use) => use.address.length)) + 2;
|
|
4709
|
+
for (const use of segment.uses)
|
|
4710
|
+
console.log(`${indent} ${use.address.padEnd(width)}${use.what}`);
|
|
4711
|
+
}
|
|
4712
|
+
function addressesCommand() {
|
|
4713
|
+
const [within] = names;
|
|
4714
|
+
if (within && !isCidr(within))
|
|
4715
|
+
fail(`${within} is not a prefix, such as 10.0.0.0/16`);
|
|
4716
|
+
const lengthText = flagValue("length");
|
|
4717
|
+
const length = lengthText === undefined ? undefined : Number(lengthText);
|
|
4718
|
+
if (length !== undefined && !(Number.isInteger(length) && length >= 0 && length <= 128))
|
|
4719
|
+
fail("--length takes a prefix length, such as 24");
|
|
4720
|
+
const report = addressesOf(network2.devices, within);
|
|
4721
|
+
if (flagValue("format") === "json") {
|
|
4722
|
+
console.log(JSON.stringify(report, null, 4));
|
|
4723
|
+
return;
|
|
4724
|
+
}
|
|
4725
|
+
for (const space of report.spaces) {
|
|
4726
|
+
console.log(`${space.prefix}, ${space.owner}`);
|
|
4727
|
+
for (const segment of space.segments)
|
|
4728
|
+
printSegment(segment, " ");
|
|
4729
|
+
for (const block of space.blocks)
|
|
4730
|
+
console.log(` ${block.prefix} ${block.what}`);
|
|
4731
|
+
if (length === undefined) {
|
|
4732
|
+
console.log(` free: ${space.free.join(", ") || "none"}`);
|
|
4733
|
+
} else {
|
|
4734
|
+
const { prefixes, total } = freeOfLength(space.free, length, 16);
|
|
4735
|
+
const more = total > BigInt(prefixes.length) ? `, and ${total - BigInt(prefixes.length)} more` : "";
|
|
4736
|
+
console.log(` free /${length}: ${prefixes.join(", ") || "none"}${more}`);
|
|
4737
|
+
}
|
|
4738
|
+
console.log("");
|
|
4739
|
+
}
|
|
4740
|
+
const { segments, blocks } = report.elsewhere;
|
|
4741
|
+
if (segments.length || blocks.length) {
|
|
4742
|
+
console.log("Outside the space the network originates");
|
|
4743
|
+
for (const segment of segments)
|
|
4744
|
+
printSegment(segment, " ");
|
|
4745
|
+
for (const block of blocks)
|
|
4746
|
+
console.log(` ${block.prefix} ${block.what}`);
|
|
4747
|
+
}
|
|
4748
|
+
if (!report.spaces.length && !segments.length && !blocks.length)
|
|
4749
|
+
console.log("The config holds no addresses.");
|
|
4750
|
+
}
|
|
4751
|
+
function renameCommand() {
|
|
4752
|
+
const [from, to] = names;
|
|
4753
|
+
if (!from || !to)
|
|
4754
|
+
fail("usage: circuit rename <device> <new-name> [--file] [--confirm]");
|
|
4755
|
+
const device = network2.devices.find((candidate) => candidate.name === from);
|
|
4756
|
+
if (!device)
|
|
4757
|
+
fail(`no device ${from}; known: ${network2.devices.map((candidate) => candidate.name).join(", ")}`);
|
|
4758
|
+
if (network2.devices.some((candidate) => candidate.name === to))
|
|
4759
|
+
fail(`${to} is already a device`);
|
|
4760
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(to))
|
|
4761
|
+
fail(`${to} is not a hostname: letters, digits, dots, dashes and underscores`);
|
|
4762
|
+
const snapshot = statePath(device);
|
|
4763
|
+
const snapshots = existsSync3(snapshot) ? [relative2(root2, snapshot)] : [];
|
|
4764
|
+
let plan4;
|
|
4765
|
+
try {
|
|
4766
|
+
plan4 = planRename(root2, from, to, { file: flags.includes("--file"), snapshots });
|
|
4767
|
+
} catch (error) {
|
|
4768
|
+
fail(error.message);
|
|
4769
|
+
}
|
|
4770
|
+
for (const change of plan4.changes) {
|
|
4771
|
+
console.log(`${change.file}:${change.line}`);
|
|
4772
|
+
console.log(` - ${change.before.trim()}`);
|
|
4773
|
+
console.log(` + ${change.after.trim()}`);
|
|
4774
|
+
}
|
|
4775
|
+
for (const move of plan4.moves)
|
|
4776
|
+
console.log(`move ${move.from} to ${move.to}`);
|
|
4777
|
+
if (plan4.mentions.length) {
|
|
4778
|
+
console.log(`
|
|
4779
|
+
Still naming ${from}, left as they are: change them by hand if they mean the device.`);
|
|
4780
|
+
for (const mention of plan4.mentions)
|
|
4781
|
+
console.log(` ${mention.file}:${mention.line} ${mention.text.slice(0, 160)}`);
|
|
4782
|
+
}
|
|
4783
|
+
if (!confirmed) {
|
|
4784
|
+
console.log(`
|
|
4785
|
+
Nothing written. Re-run with --confirm.`);
|
|
4786
|
+
return;
|
|
4787
|
+
}
|
|
4788
|
+
applyRename(root2, plan4);
|
|
4789
|
+
console.log(`
|
|
4790
|
+
Renamed. Run circuit validate, then circuit apply ${to}: the device keeps its old hostname until then.`);
|
|
4791
|
+
}
|
|
4755
4792
|
async function studioCommand() {
|
|
4793
|
+
if (flags.includes("--json")) {
|
|
4794
|
+
const sources = projectSources(root2);
|
|
4795
|
+
const data = studioData(network2.devices, annotatedGraph(LAYERS, []), {
|
|
4796
|
+
render: (device) => adapterOf(device).render(device),
|
|
4797
|
+
snapshot: (device) => existsSync3(statePath(device)) ? readFileSync4(statePath(device), "utf8") : undefined,
|
|
4798
|
+
files: (device) => ({
|
|
4799
|
+
source: deviceFiles(root2, device.name, sources)[0]?.split(sep).join("/"),
|
|
4800
|
+
snapshot: existsSync3(statePath(device)) ? relative2(root2, statePath(device)).split(sep).join("/") : undefined
|
|
4801
|
+
})
|
|
4802
|
+
});
|
|
4803
|
+
console.log(JSON.stringify(data));
|
|
4804
|
+
return;
|
|
4805
|
+
}
|
|
4756
4806
|
const port = Number(flagValue("port") ?? 4600);
|
|
4757
4807
|
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
4758
4808
|
fail("--port takes a number from 1 to 65535");
|
|
4759
|
-
const { studio } = await import("../chunks/
|
|
4760
|
-
|
|
4809
|
+
const { isLoopback, studio } = await import("../chunks/server-5shmsmse.js");
|
|
4810
|
+
const host2 = flagValue("host") ?? "127.0.0.1";
|
|
4811
|
+
const allowHosts = (flagValue("allow-host") ?? "").split(",").filter(Boolean);
|
|
4812
|
+
if (!isLoopback(host2) && !allowHosts.length) {
|
|
4813
|
+
fail(`Studio on ${host2} needs --allow-host=<name>, the name people reach it by, such as studio.example.com`);
|
|
4814
|
+
}
|
|
4815
|
+
await studio({ configPath, root: root2, port, open: !flags.includes("--no-open"), host: host2, allowHosts });
|
|
4761
4816
|
}
|
|
4762
4817
|
var commands = {
|
|
4763
4818
|
validate: validateCommand,
|
|
@@ -4772,6 +4827,8 @@ var commands = {
|
|
|
4772
4827
|
librenms: librenmsCommand,
|
|
4773
4828
|
prometheus: prometheusCommand,
|
|
4774
4829
|
secrets: secretsCommand,
|
|
4830
|
+
rename: renameCommand,
|
|
4831
|
+
addresses: addressesCommand,
|
|
4775
4832
|
graph: graphCommand,
|
|
4776
4833
|
studio: studioCommand
|
|
4777
4834
|
};
|