@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.
Files changed (49) hide show
  1. package/README.md +2 -2
  2. package/dist/chunks/index-jtzfw679.js +936 -0
  3. package/dist/chunks/{index-dczpxzzm.js → index-xm9nma2t.js} +20 -1
  4. package/dist/chunks/server-5shmsmse.js +246 -0
  5. package/dist/cli/index.js +544 -487
  6. package/dist/index.js +1 -1
  7. package/dist/presets/index.js +1 -1
  8. package/dist/types/adapters/devices/derive.d.ts +2 -0
  9. package/dist/types/core/across.d.ts +4 -0
  10. package/dist/types/core/addr.d.ts +10 -0
  11. package/dist/types/core/addresses.d.ts +46 -0
  12. package/dist/types/core/files.d.ts +6 -0
  13. package/dist/types/core/graph.d.ts +31 -1
  14. package/dist/types/core/schema-docs.d.ts +16 -0
  15. package/package.json +1 -1
  16. package/src/adapters/devices/derive.ts +32 -0
  17. package/src/adapters/devices/raisecom-ros/index.ts +23 -3
  18. package/src/adapters/devices/routeros/index.ts +10 -0
  19. package/src/adapters/devices/vrp/index.ts +10 -1
  20. package/src/cli/graph.ts +1 -15
  21. package/src/cli/index.ts +154 -20
  22. package/src/cli/rename.ts +129 -0
  23. package/src/core/across.ts +322 -0
  24. package/src/core/addr.ts +29 -0
  25. package/src/core/addresses.ts +316 -0
  26. package/src/core/files.ts +30 -0
  27. package/src/core/graph.ts +27 -6
  28. package/src/core/schema-docs.ts +166 -0
  29. package/src/core/validate.ts +2 -52
  30. package/src/studio/data.ts +255 -0
  31. package/src/studio/git.ts +119 -0
  32. package/src/studio/public/app.js +147 -0
  33. package/src/studio/public/compare.js +243 -0
  34. package/src/studio/public/diff.js +97 -0
  35. package/src/studio/public/dom.js +108 -0
  36. package/src/studio/public/evaluate.js +648 -0
  37. package/src/studio/public/export.js +122 -0
  38. package/src/studio/public/favicon.svg +6 -0
  39. package/src/studio/public/graph.js +914 -0
  40. package/src/studio/public/index.html +67 -0
  41. package/src/studio/public/net.js +128 -0
  42. package/src/studio/public/style.css +693 -0
  43. package/src/studio/public/tables.js +1223 -0
  44. package/src/studio/public/tools.js +578 -0
  45. package/src/studio/server.ts +218 -0
  46. package/templates/project/AGENTS.md +2 -0
  47. package/dist/chunks/studio-femhydvd.js +0 -764
  48. package/src/cli/studio-page.ts +0 -876
  49. 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-dczpxzzm.js";
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 existsSync2, mkdirSync, readdirSync, readFileSync as readFileSync2, rmSync, writeFileSync } from "node:fs";
23
- import { basename, dirname, join as join2, resolve as resolve2 } from "node:path";
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 keyOf = (entry) => [...entry.path, canon(entry.line)].join("\x00");
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(keyOf));
316
- const existingKeys = new Set(existing.map(keyOf));
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(keyOf(entry)))
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(keyOf(entry)))
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(keyOf(other));
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, parse2, format, removal) {
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 ? parse2(old) : [];
365
- const after = fresh ? parse2(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
- if (iface.vrf)
720
- found.push(`interfaces.${name}.vrf`);
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 parse2(text) {
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 = parse2(desired);
1418
- const have = parse2(current);
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 addressesOf = (iface) => (iface.addresses ?? []).map((entry) => typeof entry === "string" ? entry : entry.address);
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 = addressesOf(iface);
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 = addressesOf(iface).map(host).filter(isGlobal);
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/graph.ts
3575
- var LAYERS = ["l1", "l2", "l3", "bgp", "ospf", "tunnels"];
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
- class Builder {
3588
- nodes = new Map;
3589
- edges = [];
3590
- node(id, kind, label, detail = []) {
3591
- const existing = this.nodes.get(id);
3592
- if (!existing)
3593
- this.nodes.set(id, { id, kind, label, detail: [...detail] });
3594
- else
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 routedOf(device) {
3606
- const ports = Object.entries(device.ports ?? {}).flatMap(([name, port]) => port ? [[name, port]] : []);
3607
- const interfaces = Object.entries(device.interfaces ?? {});
3608
- return [...ports, ...interfaces];
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 holdersOf(devices) {
3611
- return devices.flatMap((device) => routedOf(device).flatMap(([name, settings]) => (settings.addresses ?? []).flatMap((entry) => {
3612
- const address = typeof entry === "string" ? entry : entry.address;
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 carriedBy(device, settings) {
3628
- const vlan = (name, untagged) => {
3629
- const id = name ? device.vlans?.[name]?.id : undefined;
3630
- return name && id !== undefined ? [{ name, id, untagged }] : [];
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
- function vlanLabel(vlans) {
3636
- if (vlans.length === 1)
3637
- return `${vlans[0].name}${vlans[0].untagged ? ", untagged" : ""}`;
3638
- if (vlans.length <= 3)
3639
- return vlans.map((vlan) => vlan.name).join(", ");
3640
- return `${vlans.length} VLANs`;
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 cables(devices, graph) {
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 here = `${device.name} ${portName}`;
3652
- const there = `${link.device} ${link.port}`;
3653
- if (drawn.has(`${there}|${here}`))
3654
- continue;
3655
- drawn.add(`${here}|${there}`);
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
- const farPort = far?.ports?.[link.port];
3658
- const linksBack = farPort?.link?.device === device.name && farPort.link.port === portName;
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 isBundle = Boolean(lag && farLag && linksBack);
3672
- const cable = graph.edge({
3673
- layer: "l1",
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
- graph.edge({
3688
- layer: "l2",
3689
- from: deviceId(device.name),
3690
- to,
3691
- label: vlanLabel(vlans),
3692
- detail: [
3693
- `${device.name} ${lag ?? portName}`,
3694
- ...vlans.map((vlan) => `${vlan.name}, VLAN ${vlan.id}${vlan.untagged ? ", untagged" : ""}`)
3695
- ],
3696
- vlans,
3697
- ...linksBack ? {} : { oneSided: true }
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 segments(holders2, owners, graph) {
3703
- const bySegment = new Map;
3704
- const pairs = new Set;
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
- if (holder.peer) {
3707
- const far = owners.get(keyOf(holder.peer) ?? "");
3708
- if (!far || far.device === holder.device)
3709
- continue;
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 (isHostRoute(holder.address))
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
- const id = graph.node(`segment:${segment}`, "segment", segment, [...memberLines, ...vrrpLines3]);
3745
- for (const member of members) {
3746
- graph.edge({
3747
- layer: "l3",
3748
- from: deviceId(member.device.name),
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 sessions(devices, owners, graph) {
3757
- const all = devices.flatMap((device) => {
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
- role: neighbor.role ?? group?.role,
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 ownerOf = (session) => owners.get(keyOf(session.address) ?? "")?.device;
3785
- const paired = new Set;
3786
- for (const session of all) {
3787
- if (paired.has(session))
3788
- continue;
3789
- const far = ownerOf(session);
3790
- if (far && far !== session.device) {
3791
- const back = all.find((other) => other.device === far && !paired.has(other) && ownerOf(other) === session.device);
3792
- if (back)
3793
- paired.add(back);
3794
- graph.edge({
3795
- layer: "bgp",
3796
- from: deviceId(session.device.name),
3797
- to: deviceId(far.name),
3798
- label: session.remoteAs === session.ownAs ? "iBGP" : "eBGP",
3799
- detail: [describe2(session), ...back ? [describe2(back)] : [`${far.name} has no session back`]],
3800
- ...back ? {} : { oneSided: true }
3801
- });
3802
- continue;
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 adjacencies(devices, graph) {
3817
- const byKey = new Map;
3818
- for (const device of devices) {
3819
- for (const [area, { interfaces }] of Object.entries(device.routing?.ospf?.areas ?? {})) {
3820
- for (const [name, settings] of Object.entries(interfaces)) {
3821
- if (settings?.passive)
3822
- continue;
3823
- const routed = device.ports?.[name] ?? device.interfaces?.[name];
3824
- const cost = settings?.cost ? `, cost ${settings.cost}` : "";
3825
- for (const entry of routed?.addresses ?? []) {
3826
- const address = typeof entry === "string" ? entry : entry.address;
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 [key, members] of byKey) {
3837
- const [area] = key.split(" ");
3838
- for (const [index, a] of members.entries()) {
3839
- for (const b of members.slice(index + 1)) {
3840
- if (a.device === b.device)
3841
- continue;
3842
- graph.edge({
3843
- layer: "ospf",
3844
- from: deviceId(a.device.name),
3845
- to: deviceId(b.device.name),
3846
- label: `area ${area}`,
3847
- detail: [a.line, b.line]
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
- var endpointHost = (endpoint) => endpoint.replace(/:\d+$/, "").replace(/^\[(.*)\]$/, "$1");
3854
- function tunnels2(devices, owners, graph) {
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
- if (paired.has(end))
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) => !paired.has(other) && mirrors(end, other));
3868
- if (mirror) {
3869
- paired.add(mirror);
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
- const owner = owners.get(keyOf(end.iface.remote) ?? "")?.device;
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
- const outside = graph.node(`address:${end.iface.remote}`, "outside", end.iface.remote, end.iface.description ? [end.iface.description] : []);
3892
- graph.edge({
3893
- layer: "tunnels",
3894
- from: deviceId(end.device.name),
3895
- to: outside,
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
- cables(devices, graph);
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(...checkLinks(network2.devices));
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
- checkAddresses(device, report, cidr, ip);
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 checkAddresses(device, report, cidr, ip) {
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 graph in the browser, drawn again whenever the config changes
4419
- --port=4600 the port it listens on, on this machine only
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 = resolve2(process.cwd(), configArgument ?? DEFAULT_CONFIG);
4438
- if (!existsSync2(configPath)) {
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 = dirname(configPath);
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 = join2(root2, ".circuit");
4475
- var stateDirectory = join2(generated, "state");
4476
- var statePath = (device) => join2(stateDirectory, device.name + adapterOf(device).extension);
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
- writeFileSync(join2(generated, "README.md"), GENERATED_README);
4486
- writeFileSync(statePath(device), text);
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 (!existsSync2(path)) {
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), readFileSync2(path, "utf8"), options);
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 || !existsSync2(stateDirectory))
4492
+ if (names.length || !existsSync3(stateDirectory))
4546
4493
  return;
4547
- const current = new Set(network2.devices.map((device) => basename(statePath(device))));
4548
- for (const file of readdirSync(stateDirectory)) {
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(join2(stateDirectory, file));
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 (!existsSync2(path))
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), readFileSync2(path, "utf8"), options);
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 graph = only(graphOf(network2.devices), layers, names.length ? devices.map((device) => device.name) : []);
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/studio-femhydvd.js");
4760
- await studio({ configPath, root: root2, port, open: !flags.includes("--no-open") });
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
  };