@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
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,
|
|
@@ -10,7 +23,7 @@ import {
|
|
|
10
23
|
network,
|
|
11
24
|
split,
|
|
12
25
|
wildcard
|
|
13
|
-
} from "../chunks/index-
|
|
26
|
+
} from "../chunks/index-xm9nma2t.js";
|
|
14
27
|
import {
|
|
15
28
|
__require,
|
|
16
29
|
__toESM
|
|
@@ -18,8 +31,8 @@ import {
|
|
|
18
31
|
|
|
19
32
|
// src/cli/index.ts
|
|
20
33
|
import { createPrivateKey, createPublicKey } from "node:crypto";
|
|
21
|
-
import { existsSync as
|
|
22
|
-
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";
|
|
23
36
|
|
|
24
37
|
// src/core/secrets.ts
|
|
25
38
|
import { spawnSync } from "node:child_process";
|
|
@@ -207,44 +220,6 @@ function upload(client, path, content) {
|
|
|
207
220
|
});
|
|
208
221
|
}
|
|
209
222
|
|
|
210
|
-
// src/adapters/devices/raisecom-ros/models.ts
|
|
211
|
-
var models = {
|
|
212
|
-
"RAX721-C-6C48": {
|
|
213
|
-
ports: { "100g": 6, "25g": 24, "10g": 24 },
|
|
214
|
-
spelling: { "100g": "hundredgige 1/1/{n}", "25g": "twenty-fivegige 1/2/{n}", "10g": "tengigabitethernet 1/3/{n}" }
|
|
215
|
-
}
|
|
216
|
-
};
|
|
217
|
-
|
|
218
|
-
// src/adapters/devices/routeros/models.ts
|
|
219
|
-
var models2 = {
|
|
220
|
-
"CCR2216-1G-12XS-2XQ": {
|
|
221
|
-
ports: { "100g": 2, "25g": 12, "1g": 1 },
|
|
222
|
-
spelling: { "100g": "qsfp28-{n}-1", "25g": "sfp28-{n}", "1g": "ether{n}" }
|
|
223
|
-
}
|
|
224
|
-
};
|
|
225
|
-
|
|
226
|
-
// src/adapters/devices/vrp/models.ts
|
|
227
|
-
var models3 = {
|
|
228
|
-
"CE6855-48S6Q-HI": { ports: { "10g": 48, "40g": 6 }, spelling: { "10g": "10GE1/0/{n}", "40g": "40GE1/0/{n}" } }
|
|
229
|
-
};
|
|
230
|
-
|
|
231
|
-
// src/adapters/devices/catalog.ts
|
|
232
|
-
var catalog = { routeros: models2, vrp: models3, "raisecom-ros": models };
|
|
233
|
-
function portsOf(platform, model) {
|
|
234
|
-
const spec = catalog[platform]?.[model];
|
|
235
|
-
if (!spec)
|
|
236
|
-
throw new Error(`${platform} has no model ${model}`);
|
|
237
|
-
return Object.entries(spec.ports).flatMap(([speed, count]) => Array.from({ length: count }, (_, i) => `${speed}-${i + 1}`));
|
|
238
|
-
}
|
|
239
|
-
function spell(platform, model, port) {
|
|
240
|
-
const spec = catalog[platform]?.[model];
|
|
241
|
-
const parts = /^(\d+[gm])-(\d+)$/.exec(port);
|
|
242
|
-
const template = parts && spec?.spelling[parts[1]];
|
|
243
|
-
if (!template)
|
|
244
|
-
throw new Error(`${model} has no port ${port}`);
|
|
245
|
-
return template.replace("{n}", parts[2]);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
223
|
// src/adapters/devices/derive.ts
|
|
249
224
|
function lagIds(device) {
|
|
250
225
|
const numbers = new Map;
|
|
@@ -262,6 +237,36 @@ function ipv4Networks(device, iface) {
|
|
|
262
237
|
const addresses = (settings?.addresses ?? []).map((entry) => typeof entry === "string" ? entry : entry.address);
|
|
263
238
|
return addresses.filter((address) => familyOf(address) === "ipv4").map((address) => network(address));
|
|
264
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
|
+
}
|
|
265
270
|
|
|
266
271
|
// src/adapters/devices/lines.ts
|
|
267
272
|
var indentOf = (raw) => raw.length - raw.trimStart().length;
|
|
@@ -307,12 +312,12 @@ function head(line) {
|
|
|
307
312
|
}
|
|
308
313
|
function compare(desired, current, dialect) {
|
|
309
314
|
const canon = dialect.canon ?? ((line) => line);
|
|
310
|
-
const
|
|
315
|
+
const keyOf2 = (entry) => [...entry.path, canon(entry.line)].join("\x00");
|
|
311
316
|
const pathKey = (path) => path.join("\x00");
|
|
312
317
|
const wanted = entries(desired, dialect);
|
|
313
318
|
const existing = entries(current, dialect);
|
|
314
|
-
const wantedKeys = new Set(wanted.map(
|
|
315
|
-
const existingKeys = new Set(existing.map(
|
|
319
|
+
const wantedKeys = new Set(wanted.map(keyOf2));
|
|
320
|
+
const existingKeys = new Set(existing.map(keyOf2));
|
|
316
321
|
const wantedBlocks = new Set(wanted.map((entry) => pathKey([...entry.path, entry.line])));
|
|
317
322
|
const groups = new Map;
|
|
318
323
|
const groupFor = (path) => {
|
|
@@ -322,7 +327,7 @@ function compare(desired, current, dialect) {
|
|
|
322
327
|
return groups.get(key);
|
|
323
328
|
};
|
|
324
329
|
for (const entry of wanted) {
|
|
325
|
-
if (existingKeys.has(
|
|
330
|
+
if (existingKeys.has(keyOf2(entry)))
|
|
326
331
|
continue;
|
|
327
332
|
const ownBlock = pathKey([...entry.path, entry.line]);
|
|
328
333
|
const opensBlock = wanted.some((other) => {
|
|
@@ -334,7 +339,7 @@ function compare(desired, current, dialect) {
|
|
|
334
339
|
groupFor(entry.path).add.push(entry.line);
|
|
335
340
|
}
|
|
336
341
|
for (const entry of [...existing].reverse()) {
|
|
337
|
-
if (wantedKeys.has(
|
|
342
|
+
if (wantedKeys.has(keyOf2(entry)))
|
|
338
343
|
continue;
|
|
339
344
|
const insideRemovedBlock = entry.path.some((_, depth) => {
|
|
340
345
|
const enclosing = pathKey(entry.path.slice(0, depth + 1));
|
|
@@ -344,7 +349,7 @@ function compare(desired, current, dialect) {
|
|
|
344
349
|
continue;
|
|
345
350
|
const replaced = wanted.some((other) => {
|
|
346
351
|
const sameBlock = pathKey(other.path) === pathKey(entry.path);
|
|
347
|
-
const isNew = !existingKeys.has(
|
|
352
|
+
const isNew = !existingKeys.has(keyOf2(other));
|
|
348
353
|
return sameBlock && isNew && head(other.line) === head(entry.line);
|
|
349
354
|
});
|
|
350
355
|
if (!replaced)
|
|
@@ -715,8 +720,15 @@ function unsupported(device) {
|
|
|
715
720
|
found.push(`interfaces.${name}, a ${iface.type}`);
|
|
716
721
|
continue;
|
|
717
722
|
}
|
|
718
|
-
|
|
719
|
-
|
|
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
|
+
}
|
|
720
732
|
for (const group of iface.vrrp ?? []) {
|
|
721
733
|
if (familyOf(group.address) === "ipv6")
|
|
722
734
|
found.push(`interfaces.${name}.vrrp for IPv6`);
|
|
@@ -739,8 +751,15 @@ function unsupported(device) {
|
|
|
739
751
|
}
|
|
740
752
|
}
|
|
741
753
|
for (const route of routing?.static ?? []) {
|
|
742
|
-
if (!route.via || route.vrf)
|
|
754
|
+
if (!route.via || route.vrf || familyOf(route.prefix) === "ipv6") {
|
|
743
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`);
|
|
744
763
|
}
|
|
745
764
|
const UNSUPPORTED_SECTIONS = [
|
|
746
765
|
"firewall",
|
|
@@ -767,6 +786,10 @@ function unsupported(device) {
|
|
|
767
786
|
if (!SNMP_AUTH[user.auth])
|
|
768
787
|
found.push(`management.snmp.users.${name}.auth ${user.auth}, the platform authenticates with SHA1 or MD5`);
|
|
769
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");
|
|
770
793
|
if (device.lldp && device.lldp !== true)
|
|
771
794
|
found.push("lldp.interfaces, LLDP is per device");
|
|
772
795
|
if (device.stp?.mode && device.stp.mode !== "mstp")
|
|
@@ -779,6 +802,7 @@ function unsupported(device) {
|
|
|
779
802
|
if (system.dns || system.logging || system.release_channel || system.banner || system.ntp?.serve) {
|
|
780
803
|
found.push("system.dns, logging, release_channel, banner and ntp.serve");
|
|
781
804
|
}
|
|
805
|
+
found.push(...switchUnsupported(device));
|
|
782
806
|
return found;
|
|
783
807
|
}
|
|
784
808
|
var REFUSED = /^(%|Error|Incomplete|Unrecognized)|can not|cannot |is in use|Wrong parameter|invalid|unsuccessful/im;
|
|
@@ -2587,6 +2611,14 @@ function unsupported2(device) {
|
|
|
2587
2611
|
found.push(`ports.${name}.storm_control with a different percent per kind, the platform takes one`);
|
|
2588
2612
|
}
|
|
2589
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
|
+
}
|
|
2590
2622
|
const bgp = device.routing?.bgp;
|
|
2591
2623
|
for (const [name, neighbor] of Object.entries(bgp?.neighbors ?? {})) {
|
|
2592
2624
|
const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
|
|
@@ -2599,6 +2631,8 @@ function unsupported2(device) {
|
|
|
2599
2631
|
found.push("management.console, the console uses the device's users");
|
|
2600
2632
|
if (management?.privilege_password)
|
|
2601
2633
|
found.push("management.privilege_password, the platform has no privileged mode");
|
|
2634
|
+
if (management?.snmp?.port)
|
|
2635
|
+
found.push("management.snmp.port");
|
|
2602
2636
|
if (management?.ssh?.auth_timeout || management?.ssh?.auth_retries) {
|
|
2603
2637
|
found.push("management.ssh.auth_timeout and auth_retries");
|
|
2604
2638
|
}
|
|
@@ -3224,10 +3258,16 @@ function unsupported3(device) {
|
|
|
3224
3258
|
found.push(`interfaces.${name}, a ${iface.type}`);
|
|
3225
3259
|
if (iface.type === "lag" && iface.addresses?.length)
|
|
3226
3260
|
found.push(`interfaces.${name}.addresses, LAGs are switched`);
|
|
3261
|
+
if (iface.mtu !== undefined)
|
|
3262
|
+
found.push(`interfaces.${name}.mtu`);
|
|
3227
3263
|
for (const group of iface.vrrp ?? [])
|
|
3228
3264
|
if (familyOf(group.address) === "ipv6")
|
|
3229
3265
|
found.push(`interfaces.${name}.vrrp for IPv6`);
|
|
3230
3266
|
}
|
|
3267
|
+
for (const [name, vrf] of Object.entries(device.vrfs ?? {})) {
|
|
3268
|
+
if (vrf.description)
|
|
3269
|
+
found.push(`vrfs.${name}.description`);
|
|
3270
|
+
}
|
|
3231
3271
|
const routing = device.routing;
|
|
3232
3272
|
if (routing?.bgp)
|
|
3233
3273
|
found.push("routing.bgp");
|
|
@@ -3258,6 +3298,9 @@ function unsupported3(device) {
|
|
|
3258
3298
|
if (system2.dns || system2.logging || system2.release_channel || system2.banner) {
|
|
3259
3299
|
found.push("system.dns, logging, release_channel and banner");
|
|
3260
3300
|
}
|
|
3301
|
+
if (system2.transceiver_monitoring !== undefined)
|
|
3302
|
+
found.push("system.transceiver_monitoring");
|
|
3303
|
+
found.push(...switchUnsupported(device));
|
|
3261
3304
|
const flow = device.flow_export;
|
|
3262
3305
|
if (flow && flow.protocol !== "sflow")
|
|
3263
3306
|
found.push(`flow_export.protocol ${flow.protocol}, the platform exports sFlow`);
|
|
@@ -3437,10 +3480,10 @@ async function sendLibreNms(network2, additions) {
|
|
|
3437
3480
|
|
|
3438
3481
|
// src/adapters/registries/peeringdb.ts
|
|
3439
3482
|
var API = "https://www.peeringdb.com/api";
|
|
3440
|
-
var
|
|
3483
|
+
var addressesOf2 = (iface) => (iface.addresses ?? []).map((entry) => typeof entry === "string" ? entry : entry.address);
|
|
3441
3484
|
function peersWithRouteServer(device, iface) {
|
|
3442
3485
|
const bgp = device.routing?.bgp;
|
|
3443
|
-
const subnets =
|
|
3486
|
+
const subnets = addressesOf2(iface);
|
|
3444
3487
|
return Object.values(bgp?.neighbors ?? {}).some((neighbor) => {
|
|
3445
3488
|
const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
|
|
3446
3489
|
const role = neighbor.role ?? group?.role;
|
|
@@ -3454,7 +3497,7 @@ function wanted(network2) {
|
|
|
3454
3497
|
const exchange = iface.exchange;
|
|
3455
3498
|
if (!exchange?.peeringdb_ixlan)
|
|
3456
3499
|
continue;
|
|
3457
|
-
const published =
|
|
3500
|
+
const published = addressesOf2(iface).map(host).filter(isGlobal);
|
|
3458
3501
|
records.push({
|
|
3459
3502
|
name: exchange.name,
|
|
3460
3503
|
ixlan_id: exchange.peeringdb_ixlan,
|
|
@@ -3572,6 +3615,243 @@ function snmpExporterAuths(network2) {
|
|
|
3572
3615
|
|
|
3573
3616
|
// src/core/validate.ts
|
|
3574
3617
|
import { X509Certificate as X509Certificate2 } from "node:crypto";
|
|
3618
|
+
|
|
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);
|
|
3627
|
+
}
|
|
3628
|
+
return [
|
|
3629
|
+
...checkLinks(devices),
|
|
3630
|
+
...checkAddresses(holders2),
|
|
3631
|
+
...checkSessions(devices, owners),
|
|
3632
|
+
...checkVrrp(holders2),
|
|
3633
|
+
...checkTunnels(devices, owners)
|
|
3634
|
+
];
|
|
3635
|
+
}
|
|
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
|
+
};
|
|
3643
|
+
}
|
|
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]] : []));
|
|
3647
|
+
}
|
|
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 };
|
|
3653
|
+
}
|
|
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;
|
|
3673
|
+
}
|
|
3674
|
+
function checkLinks(devices) {
|
|
3675
|
+
const findings = [];
|
|
3676
|
+
const byName = new Map(devices.map((device) => [device.name, device]));
|
|
3677
|
+
for (const device of devices) {
|
|
3678
|
+
for (const [portName, port] of Object.entries(device.ports ?? {})) {
|
|
3679
|
+
const link = port?.link;
|
|
3680
|
+
if (!port || !link)
|
|
3681
|
+
continue;
|
|
3682
|
+
const report = (message, level = "error") => findings.push({
|
|
3683
|
+
level,
|
|
3684
|
+
device: device.name,
|
|
3685
|
+
message: `port ${portName} link: ${message}`
|
|
3686
|
+
});
|
|
3687
|
+
const far = byName.get(link.device);
|
|
3688
|
+
if (!far) {
|
|
3689
|
+
report(`device ${link.device} is not in the network`);
|
|
3690
|
+
continue;
|
|
3691
|
+
}
|
|
3692
|
+
const farPort = far.ports?.[link.port];
|
|
3693
|
+
if (!farPort) {
|
|
3694
|
+
report(`${link.device} does not declare port ${link.port}`);
|
|
3695
|
+
continue;
|
|
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
|
+
}
|
|
3724
|
+
}
|
|
3725
|
+
}
|
|
3726
|
+
return findings;
|
|
3727
|
+
}
|
|
3728
|
+
function checkAddresses(holders2) {
|
|
3729
|
+
const findings = [];
|
|
3730
|
+
const first = new Map;
|
|
3731
|
+
const isLoopback = (holder) => holder.device.interfaces?.[holder.name]?.type === "loopback";
|
|
3732
|
+
for (const holder of holders2) {
|
|
3733
|
+
const key = keyOf(host(holder.address));
|
|
3734
|
+
const earlier = first.get(key);
|
|
3735
|
+
if (!earlier) {
|
|
3736
|
+
first.set(key, holder);
|
|
3737
|
+
continue;
|
|
3738
|
+
}
|
|
3739
|
+
if (earlier.device === holder.device)
|
|
3740
|
+
continue;
|
|
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
|
+
});
|
|
3747
|
+
}
|
|
3748
|
+
return findings;
|
|
3749
|
+
}
|
|
3750
|
+
function checkSessions(devices, owners) {
|
|
3751
|
+
const findings = [];
|
|
3752
|
+
const sessionsOf = (device) => {
|
|
3753
|
+
const bgp = device.routing?.bgp;
|
|
3754
|
+
return Object.entries(bgp?.neighbors ?? {}).map(([name, neighbor]) => {
|
|
3755
|
+
const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
|
|
3756
|
+
return {
|
|
3757
|
+
name,
|
|
3758
|
+
address: neighbor.address,
|
|
3759
|
+
remoteAs: neighbor.remote_as ?? group?.remote_as,
|
|
3760
|
+
localAddress: neighbor.local_address ?? group?.local_address
|
|
3761
|
+
};
|
|
3762
|
+
});
|
|
3763
|
+
};
|
|
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
|
+
}
|
|
3786
|
+
}
|
|
3787
|
+
}
|
|
3788
|
+
return findings;
|
|
3789
|
+
}
|
|
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 }]);
|
|
3801
|
+
}
|
|
3802
|
+
}
|
|
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}`
|
|
3821
|
+
});
|
|
3822
|
+
}
|
|
3823
|
+
}
|
|
3824
|
+
}
|
|
3825
|
+
return findings;
|
|
3826
|
+
}
|
|
3827
|
+
function checkTunnels(devices, owners) {
|
|
3828
|
+
const findings = [];
|
|
3829
|
+
const ends = devices.flatMap((device) => Object.entries(device.interfaces ?? {}).flatMap(([name, iface]) => iface.type === "gre" || iface.type === "vxlan" ? [{ device, name, iface }] : []));
|
|
3830
|
+
const same = (a, b) => keyOf(a) !== undefined && keyOf(a) === keyOf(b);
|
|
3831
|
+
for (const end of ends) {
|
|
3832
|
+
const far = owners.get(keyOf(end.iface.remote) ?? "")?.device;
|
|
3833
|
+
if (!far || far === end.device)
|
|
3834
|
+
continue;
|
|
3835
|
+
const report = (message) => findings.push({ level: "error", device: end.device.name, message: `interface ${end.name}: ${message}` });
|
|
3836
|
+
const kind = end.iface.type === "gre" ? "GRE" : "VXLAN";
|
|
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}`);
|
|
3840
|
+
continue;
|
|
3841
|
+
}
|
|
3842
|
+
if (end.iface.type !== "vxlan" || mirror.iface.type !== "vxlan")
|
|
3843
|
+
continue;
|
|
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}`);
|
|
3850
|
+
}
|
|
3851
|
+
return findings;
|
|
3852
|
+
}
|
|
3853
|
+
|
|
3854
|
+
// src/core/validate.ts
|
|
3575
3855
|
function validate(network2) {
|
|
3576
3856
|
const findings = [];
|
|
3577
3857
|
const seen = new Set;
|
|
@@ -3590,7 +3870,7 @@ function validate(network2) {
|
|
|
3590
3870
|
for (const field of adapter.unsupported(device))
|
|
3591
3871
|
report(`${device.platform} cannot express ${field}`);
|
|
3592
3872
|
}
|
|
3593
|
-
findings.push(...
|
|
3873
|
+
findings.push(...checkAcross(network2.devices));
|
|
3594
3874
|
for (const check of network2.checks ?? [])
|
|
3595
3875
|
findings.push(...check(network2.devices));
|
|
3596
3876
|
return findings;
|
|
@@ -3635,7 +3915,7 @@ function checkDevice(device, report) {
|
|
|
3635
3915
|
if (!device.users?.[device.connection.user])
|
|
3636
3916
|
report(`connection user '${device.connection.user}' is not one of its users`);
|
|
3637
3917
|
checkVlans(device, report);
|
|
3638
|
-
|
|
3918
|
+
checkAddresses2(device, report, cidr, ip);
|
|
3639
3919
|
checkPorts(device, names, report);
|
|
3640
3920
|
checkInterfaces(device, names, report, ip);
|
|
3641
3921
|
checkRouting(device, names, report, cidr, ip);
|
|
@@ -3654,7 +3934,7 @@ function checkVlans(device, report) {
|
|
|
3654
3934
|
ids.set(vlan.id, name);
|
|
3655
3935
|
}
|
|
3656
3936
|
}
|
|
3657
|
-
function
|
|
3937
|
+
function checkAddresses2(device, report, cidr, ip) {
|
|
3658
3938
|
const holderOf = new Map;
|
|
3659
3939
|
const holders2 = [
|
|
3660
3940
|
...Object.entries(device.ports ?? {}).map(([name, settings]) => ({ where: `port ${name}`, settings })),
|
|
@@ -3867,47 +4147,186 @@ function checkCertificates(device, names, report) {
|
|
|
3867
4147
|
}
|
|
3868
4148
|
}
|
|
3869
4149
|
}
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
4150
|
+
|
|
4151
|
+
// src/cli/graph.ts
|
|
4152
|
+
function markOf(graph, node) {
|
|
4153
|
+
if (node.kind !== "device")
|
|
4154
|
+
return;
|
|
4155
|
+
const findings = graph.findings.filter((finding) => finding.device && deviceId(finding.device) === node.id);
|
|
4156
|
+
if (findings.some((finding) => finding.level === "error"))
|
|
4157
|
+
return "error";
|
|
4158
|
+
const drift = graph.drift[node.label]?.status;
|
|
4159
|
+
if (findings.length || drift === "differs" || drift === "cannot plan")
|
|
4160
|
+
return "warning";
|
|
4161
|
+
return;
|
|
4162
|
+
}
|
|
4163
|
+
function mermaid(graph) {
|
|
4164
|
+
const ids = new Map(graph.nodes.map((node, index) => [node.id, `n${index}`]));
|
|
4165
|
+
const text = (...lines2) => `"${lines2.map((line2) => line2.replace(/"/g, "#quot;").replace(/</g, "#lt;").replace(/>/g, "#gt;")).join("<br/>")}"`;
|
|
4166
|
+
const shapes = {
|
|
4167
|
+
device: (label) => `[${label}]`,
|
|
4168
|
+
segment: (label) => `([${label}])`,
|
|
4169
|
+
outside: (label) => `{{${label}}}`
|
|
4170
|
+
};
|
|
4171
|
+
const lines = ["graph LR"];
|
|
4172
|
+
for (const node of graph.nodes) {
|
|
4173
|
+
const label = node.kind === "device" ? text(node.label, node.detail[0] ?? "") : text(node.label);
|
|
4174
|
+
lines.push(` ${ids.get(node.id)}${shapes[node.kind](label)}`);
|
|
4175
|
+
}
|
|
4176
|
+
for (const edge of graph.edges) {
|
|
4177
|
+
const arrow = edge.oneSided ? "-.-" : "---";
|
|
4178
|
+
lines.push(` ${ids.get(edge.from)} ${arrow}|${text(edge.label)}| ${ids.get(edge.to)}`);
|
|
4179
|
+
}
|
|
4180
|
+
lines.push(" classDef error stroke:#d1242f,stroke-width:3px", " classDef warning stroke:#bf8700,stroke-width:3px", " classDef outside stroke-dasharray:4 3");
|
|
4181
|
+
for (const node of graph.nodes) {
|
|
4182
|
+
const mark = markOf(graph, node) ?? (node.kind === "outside" ? "outside" : undefined);
|
|
4183
|
+
if (mark)
|
|
4184
|
+
lines.push(` class ${ids.get(node.id)} ${mark}`);
|
|
4185
|
+
}
|
|
4186
|
+
return lines.join(`
|
|
4187
|
+
`) + `
|
|
4188
|
+
`;
|
|
4189
|
+
}
|
|
4190
|
+
function dot(graph) {
|
|
4191
|
+
const text = (value2) => `"${value2.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
4192
|
+
const colors = { error: "#d1242f", warning: "#bf8700" };
|
|
4193
|
+
const shapes = {
|
|
4194
|
+
device: "shape=box, style=rounded",
|
|
4195
|
+
segment: "shape=ellipse",
|
|
4196
|
+
outside: "shape=box, style=dashed"
|
|
3876
4197
|
};
|
|
4198
|
+
const lines = [
|
|
4199
|
+
"graph network {",
|
|
4200
|
+
" rankdir=LR;",
|
|
4201
|
+
' node [fontname="Helvetica"];',
|
|
4202
|
+
' edge [fontname="Helvetica", fontsize=10];'
|
|
4203
|
+
];
|
|
4204
|
+
for (const node of graph.nodes) {
|
|
4205
|
+
const label = node.kind === "device" ? `${node.label}
|
|
4206
|
+
${node.detail[0] ?? ""}` : node.label;
|
|
4207
|
+
const mark = markOf(graph, node);
|
|
4208
|
+
const color = mark ? `, color=${text(colors[mark])}, penwidth=2` : "";
|
|
4209
|
+
lines.push(` ${text(node.id)} [label=${text(label)}, ${shapes[node.kind]}${color}];`);
|
|
4210
|
+
}
|
|
4211
|
+
for (const edge of graph.edges) {
|
|
4212
|
+
const style = edge.oneSided ? ", style=dashed" : "";
|
|
4213
|
+
lines.push(` ${text(edge.from)} -- ${text(edge.to)} [label=${text(edge.label)}${style}];`);
|
|
4214
|
+
}
|
|
4215
|
+
lines.push("}");
|
|
4216
|
+
return lines.join(`
|
|
4217
|
+
`) + `
|
|
4218
|
+
`;
|
|
3877
4219
|
}
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
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}`;
|
|
3890
4307
|
});
|
|
3891
|
-
|
|
3892
|
-
if (!far) {
|
|
3893
|
-
report(`device ${link.device} is not in the network`);
|
|
3894
|
-
continue;
|
|
3895
|
-
}
|
|
3896
|
-
const farPort = far.ports?.[link.port];
|
|
3897
|
-
if (!farPort) {
|
|
3898
|
-
report(`${link.device} does not declare port ${link.port}`);
|
|
4308
|
+
if (after === before)
|
|
3899
4309
|
continue;
|
|
3900
|
-
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
const here = JSON.stringify(carried(device, port));
|
|
3905
|
-
const there = JSON.stringify(carried(far, farPort));
|
|
3906
|
-
if (here !== there)
|
|
3907
|
-
report(`carries ${here}, and ${link.device} ${link.port} carries ${there}`);
|
|
4310
|
+
if (changed)
|
|
4311
|
+
changed.after = after;
|
|
4312
|
+
else
|
|
4313
|
+
result.push({ file: source, line: line2, before: original, after });
|
|
3908
4314
|
}
|
|
3909
4315
|
}
|
|
3910
|
-
return
|
|
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));
|
|
3911
4330
|
}
|
|
3912
4331
|
|
|
3913
4332
|
// src/cli/index.ts
|
|
@@ -3930,6 +4349,22 @@ var HELP = `usage: circuit <command> [device...] [options]
|
|
|
3930
4349
|
prometheus every device with SNMP, as Prometheus targets for snmp_exporter
|
|
3931
4350
|
--auths snmp_exporter's auths for them instead, with secrets as \${NAME}
|
|
3932
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
|
|
4358
|
+
graph [device...] the network as a graph, from the config alone, without contacting a device
|
|
4359
|
+
--layer=l1 l1, l2, l3, bgp, ospf or tunnels, several with commas, or all
|
|
4360
|
+
--format=mermaid mermaid, dot or json
|
|
4361
|
+
studio Circuit Studio: the network in the browser, drawn again whenever the config changes
|
|
4362
|
+
--port=4600 the port it listens on
|
|
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
|
|
3933
4368
|
|
|
3934
4369
|
--config <path> the network's config file, circuit.config.ts in the working directory by default`;
|
|
3935
4370
|
var DEFAULT_CONFIG = "circuit.config.ts";
|
|
@@ -3946,12 +4381,12 @@ if (!command2 || command2 === "help" || command2 === "--help" || command2 === "-
|
|
|
3946
4381
|
var configAt = args.indexOf("--config");
|
|
3947
4382
|
var configArgument = configAt >= 0 ? args[configAt + 1] : args.find((arg) => arg.startsWith("--config="))?.slice("--config=".length);
|
|
3948
4383
|
var rest = args.filter((arg, index) => index !== configAt && (configAt < 0 || index !== configAt + 1) && !arg.startsWith("--config="));
|
|
3949
|
-
var configPath =
|
|
3950
|
-
if (!
|
|
4384
|
+
var configPath = resolve3(process.cwd(), configArgument ?? DEFAULT_CONFIG);
|
|
4385
|
+
if (!existsSync3(configPath)) {
|
|
3951
4386
|
console.error(configArgument ? `no config at ${configPath}` : `no ${DEFAULT_CONFIG} in ${process.cwd()}; name another with --config <path>`);
|
|
3952
4387
|
process.exit(1);
|
|
3953
4388
|
}
|
|
3954
|
-
var root2 =
|
|
4389
|
+
var root2 = dirname2(configPath);
|
|
3955
4390
|
useRoot(root2);
|
|
3956
4391
|
async function load(path) {
|
|
3957
4392
|
if (process.versions.bun)
|
|
@@ -3983,9 +4418,9 @@ function one() {
|
|
|
3983
4418
|
return device ?? fail("name a device");
|
|
3984
4419
|
}
|
|
3985
4420
|
var adapterOf = (device) => adapters[device.platform] ?? fail(`no adapter for ${device.platform}`);
|
|
3986
|
-
var generated =
|
|
3987
|
-
var stateDirectory =
|
|
3988
|
-
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);
|
|
3989
4424
|
var GENERATED_README = `# Written by Circuit
|
|
3990
4425
|
|
|
3991
4426
|
Do not edit anything here: an edit changes nothing on a device, and Circuit overwrites it. Change the config instead.
|
|
@@ -3994,8 +4429,8 @@ Do not edit anything here: an edit changes nothing on a device, and Circuit over
|
|
|
3994
4429
|
`;
|
|
3995
4430
|
function writeState(device, text) {
|
|
3996
4431
|
mkdirSync(stateDirectory, { recursive: true });
|
|
3997
|
-
|
|
3998
|
-
|
|
4432
|
+
writeFileSync2(join4(generated, "README.md"), GENERATED_README);
|
|
4433
|
+
writeFileSync2(statePath(device), text);
|
|
3999
4434
|
}
|
|
4000
4435
|
function guard(devices) {
|
|
4001
4436
|
const relevant = new Set(devices.map((device) => device.name));
|
|
@@ -4037,12 +4472,12 @@ function diffCommand() {
|
|
|
4037
4472
|
let differences = 0;
|
|
4038
4473
|
for (const device of devices) {
|
|
4039
4474
|
const path = statePath(device);
|
|
4040
|
-
if (!
|
|
4475
|
+
if (!existsSync3(path)) {
|
|
4041
4476
|
console.log(`${device.name}: no snapshot, run snapshot first`);
|
|
4042
4477
|
continue;
|
|
4043
4478
|
}
|
|
4044
4479
|
const adapter = adapterOf(device);
|
|
4045
|
-
const plan4 = adapter.plan(adapter.render(device),
|
|
4480
|
+
const plan4 = adapter.plan(adapter.render(device), readFileSync4(path, "utf8"), options);
|
|
4046
4481
|
differences += show(device, plan4);
|
|
4047
4482
|
}
|
|
4048
4483
|
process.exit(differences ? 1 : 0);
|
|
@@ -4054,13 +4489,13 @@ async function snapshotCommand() {
|
|
|
4054
4489
|
console.log(`${device.name}: ${text.split(`
|
|
4055
4490
|
`).length} lines`);
|
|
4056
4491
|
}
|
|
4057
|
-
if (names.length || !
|
|
4492
|
+
if (names.length || !existsSync3(stateDirectory))
|
|
4058
4493
|
return;
|
|
4059
|
-
const current = new Set(network2.devices.map((device) =>
|
|
4060
|
-
for (const file of
|
|
4494
|
+
const current = new Set(network2.devices.map((device) => basename2(statePath(device))));
|
|
4495
|
+
for (const file of readdirSync2(stateDirectory)) {
|
|
4061
4496
|
if (current.has(file))
|
|
4062
4497
|
continue;
|
|
4063
|
-
rmSync(
|
|
4498
|
+
rmSync(join4(stateDirectory, file));
|
|
4064
4499
|
console.log(`${file}: removed, no such device in the config`);
|
|
4065
4500
|
}
|
|
4066
4501
|
}
|
|
@@ -4223,6 +4658,162 @@ function secretsCommand() {
|
|
|
4223
4658
|
${references.length} secret(s), ${missing} missing.`);
|
|
4224
4659
|
process.exit(missing ? 1 : 0);
|
|
4225
4660
|
}
|
|
4661
|
+
var flagValue = (name) => flags.find((flag) => flag.startsWith(`--${name}=`))?.slice(name.length + 3);
|
|
4662
|
+
function driftOf(device) {
|
|
4663
|
+
const path = statePath(device);
|
|
4664
|
+
if (!existsSync3(path))
|
|
4665
|
+
return { status: "no snapshot", lines: [] };
|
|
4666
|
+
try {
|
|
4667
|
+
const adapter = adapterOf(device);
|
|
4668
|
+
const plan4 = adapter.plan(adapter.render(device), readFileSync4(path, "utf8"), options);
|
|
4669
|
+
const lines = [
|
|
4670
|
+
...plan4.steps.flatMap((step) => [step.title, ...step.show.map((line2) => ` ${line2}`)]),
|
|
4671
|
+
...plan4.problems.map((problem) => `PROBLEM ${problem}`)
|
|
4672
|
+
];
|
|
4673
|
+
return { status: lines.length ? "differs" : "matches", lines };
|
|
4674
|
+
} catch (error) {
|
|
4675
|
+
return { status: "cannot plan", lines: [error.message] };
|
|
4676
|
+
}
|
|
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
|
+
}
|
|
4687
|
+
function graphCommand() {
|
|
4688
|
+
const requested = (flagValue("layer") ?? "l1").split(",");
|
|
4689
|
+
const layers = requested.includes("all") ? [...LAYERS] : requested;
|
|
4690
|
+
const unknown = layers.filter((layer) => !LAYERS.includes(layer));
|
|
4691
|
+
if (unknown.length)
|
|
4692
|
+
fail(`no layer ${unknown.join(", ")}; known: ${LAYERS.join(", ")}, all`);
|
|
4693
|
+
const format = flagValue("format") ?? "mermaid";
|
|
4694
|
+
const devices = selected(true);
|
|
4695
|
+
const annotated = annotatedGraph(layers, names.length ? devices.map((device) => device.name) : []);
|
|
4696
|
+
if (format === "mermaid")
|
|
4697
|
+
process.stdout.write(mermaid(annotated));
|
|
4698
|
+
else if (format === "dot")
|
|
4699
|
+
process.stdout.write(dot(annotated));
|
|
4700
|
+
else if (format === "json")
|
|
4701
|
+
console.log(JSON.stringify(annotated, null, 4));
|
|
4702
|
+
else
|
|
4703
|
+
fail(`no format ${format}; known: mermaid, dot, json`);
|
|
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
|
+
}
|
|
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
|
+
}
|
|
4806
|
+
const port = Number(flagValue("port") ?? 4600);
|
|
4807
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
4808
|
+
fail("--port takes a number from 1 to 65535");
|
|
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 });
|
|
4816
|
+
}
|
|
4226
4817
|
var commands = {
|
|
4227
4818
|
validate: validateCommand,
|
|
4228
4819
|
build: buildCommand,
|
|
@@ -4235,7 +4826,11 @@ var commands = {
|
|
|
4235
4826
|
peeringdb: peeringdbCommand,
|
|
4236
4827
|
librenms: librenmsCommand,
|
|
4237
4828
|
prometheus: prometheusCommand,
|
|
4238
|
-
secrets: secretsCommand
|
|
4829
|
+
secrets: secretsCommand,
|
|
4830
|
+
rename: renameCommand,
|
|
4831
|
+
addresses: addressesCommand,
|
|
4832
|
+
graph: graphCommand,
|
|
4833
|
+
studio: studioCommand
|
|
4239
4834
|
};
|
|
4240
4835
|
var run = commands[command2];
|
|
4241
4836
|
if (run)
|