@takodotid/circuit 0.19.0 → 0.21.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 (54) hide show
  1. package/README.md +2 -2
  2. package/dist/chunks/index-n4zpd25n.js +936 -0
  3. package/dist/chunks/{index-dczpxzzm.js → index-v8zgr7wm.js} +33 -14
  4. package/dist/chunks/{new-7n8qvtjd.js → new-ny89s7y7.js} +4 -6
  5. package/dist/chunks/server-zapfzhv4.js +244 -0
  6. package/dist/cli/index.js +855 -803
  7. package/dist/index.js +20 -21
  8. package/dist/presets/index.js +20 -21
  9. package/dist/types/adapters/devices/derive.d.ts +2 -0
  10. package/dist/types/core/across.d.ts +4 -0
  11. package/dist/types/core/addr.d.ts +10 -0
  12. package/dist/types/core/addresses.d.ts +46 -0
  13. package/dist/types/core/files.d.ts +6 -0
  14. package/dist/types/core/graph.d.ts +31 -1
  15. package/dist/types/core/schema-docs.d.ts +16 -0
  16. package/package.json +7 -7
  17. package/src/adapters/devices/derive.ts +32 -0
  18. package/src/adapters/devices/raisecom-ros/index.ts +23 -3
  19. package/src/adapters/devices/routeros/index.ts +10 -0
  20. package/src/adapters/devices/vrp/index.ts +10 -1
  21. package/src/cli/graph.ts +1 -15
  22. package/src/cli/index.ts +154 -20
  23. package/src/cli/rename.ts +129 -0
  24. package/src/core/across.ts +322 -0
  25. package/src/core/addr.ts +29 -0
  26. package/src/core/addresses.ts +316 -0
  27. package/src/core/files.ts +32 -0
  28. package/src/core/graph.ts +27 -6
  29. package/src/core/schema-docs.ts +166 -0
  30. package/src/core/validate.ts +2 -52
  31. package/src/studio/data.ts +255 -0
  32. package/src/studio/git.ts +119 -0
  33. package/src/studio/public/app.js +165 -0
  34. package/src/studio/public/compare.js +243 -0
  35. package/src/studio/public/diff.js +97 -0
  36. package/src/studio/public/dom.js +108 -0
  37. package/src/studio/public/evaluate.js +648 -0
  38. package/src/studio/public/export.js +122 -0
  39. package/src/studio/public/favicon.svg +12 -0
  40. package/src/studio/public/graph.js +983 -0
  41. package/src/studio/public/index.html +71 -0
  42. package/src/studio/public/net.js +128 -0
  43. package/src/studio/public/special-gothic-latin-ext.woff2 +0 -0
  44. package/src/studio/public/special-gothic-latin.woff2 +0 -0
  45. package/src/studio/public/special-gothic-license.txt +93 -0
  46. package/src/studio/public/style.css +1334 -0
  47. package/src/studio/public/tables.js +1223 -0
  48. package/src/studio/public/tools.js +578 -0
  49. package/src/studio/server.ts +219 -0
  50. package/templates/project/AGENTS.md +2 -0
  51. package/dist/chunks/index-fq57hya4.js +0 -20
  52. package/dist/chunks/studio-femhydvd.js +0 -764
  53. package/src/cli/studio-page.ts +0 -876
  54. package/src/cli/studio.ts +0 -126
package/dist/cli/index.js CHANGED
@@ -1,26 +1,34 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- contains,
4
- familyOf,
5
- host,
6
- isAddress,
7
- isCidr,
8
- isGlobal,
9
- netmask,
10
- network,
11
- parse,
12
- split,
13
- wildcard
14
- } from "../chunks/index-dczpxzzm.js";
3
+ portsOf,
4
+ spell,
5
+ LAYERS,
6
+ deviceId,
7
+ keyOf,
8
+ holdersOf,
9
+ graphOf,
10
+ only,
11
+ addressesOf,
12
+ freeOfLength,
13
+ studioData
14
+ } from "../chunks/index-n4zpd25n.js";
15
15
  import {
16
- __require,
17
- __toESM
18
- } from "../chunks/index-fq57hya4.js";
16
+ familyOf2,
17
+ split,
18
+ isAddress2,
19
+ isCidr2,
20
+ network,
21
+ contains2,
22
+ netmask,
23
+ wildcard,
24
+ host2,
25
+ isGlobal2
26
+ } from "../chunks/index-v8zgr7wm.js";
19
27
 
20
28
  // src/cli/index.ts
21
29
  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";
30
+ import { existsSync as existsSync3, mkdirSync, readdirSync as readdirSync2, readFileSync as readFileSync4, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
31
+ import { basename as basename2, dirname as dirname2, join as join4, relative as relative2, resolve as resolve3, sep } from "node:path";
24
32
 
25
33
  // src/core/secrets.ts
26
34
  import { spawnSync } from "node:child_process";
@@ -86,10 +94,10 @@ function valueOf(reference) {
86
94
  const fullPath = join(root, path);
87
95
  if (!existsSync(fullPath))
88
96
  throw new Error(`secret file ${path} does not exist`);
89
- const value2 = readFileSync(fullPath, "utf8").trimEnd();
90
- if (!value2)
97
+ const value = readFileSync(fullPath, "utf8").trimEnd();
98
+ if (!value)
91
99
  throw new Error(`secret file ${path} is empty`);
92
- return value2;
100
+ return value;
93
101
  }
94
102
  const value = process.env[reference] || dotenv()[reference];
95
103
  if (!value)
@@ -119,9 +127,9 @@ function offset(zone) {
119
127
  // src/transport/ssh.ts
120
128
  import { Client } from "ssh2";
121
129
  function connect(login) {
122
- return new Promise((resolve2, reject) => {
130
+ return new Promise((resolve, reject) => {
123
131
  const client = new Client;
124
- client.on("ready", () => resolve2(client)).on("error", reject).connect({
132
+ client.on("ready", () => resolve(client)).on("error", reject).connect({
125
133
  host: login.host,
126
134
  port: login.port ?? 22,
127
135
  username: login.user,
@@ -132,29 +140,29 @@ function connect(login) {
132
140
  });
133
141
  }
134
142
  function exec(client, command, timeout = 120000) {
135
- return new Promise((resolve2, reject) => {
143
+ return new Promise((resolve, reject) => {
136
144
  client.exec(command, (error, stream) => {
137
145
  if (error)
138
146
  return reject(error);
139
147
  let output = "";
140
148
  const append = (chunk) => output += chunk.toString("utf8");
141
- const timer = setTimeout(() => resolve2(output.trim()), timeout);
149
+ const timer = setTimeout(() => resolve(output.trim()), timeout);
142
150
  stream.on("data", append);
143
151
  stream.stderr.on("data", append);
144
152
  stream.on("close", () => {
145
153
  clearTimeout(timer);
146
- resolve2(output.trim());
154
+ resolve(output.trim());
147
155
  });
148
156
  });
149
157
  });
150
158
  }
151
159
  function shell(client, columns = 200, rows = 1000) {
152
- return new Promise((resolve2, reject) => {
153
- client.shell({ cols: columns, rows }, (error, channel) => error ? reject(error) : resolve2(channel));
160
+ return new Promise((resolve, reject) => {
161
+ client.shell({ cols: columns, rows }, (error, channel) => error ? reject(error) : resolve(channel));
154
162
  });
155
163
  }
156
164
  function readUntil(channel, done, timeout, settle = 400) {
157
- return new Promise((resolve2) => {
165
+ return new Promise((resolve) => {
158
166
  let output = "";
159
167
  let finished = false;
160
168
  const finish = () => {
@@ -162,7 +170,7 @@ function readUntil(channel, done, timeout, settle = 400) {
162
170
  return;
163
171
  finished = true;
164
172
  channel.removeListener("data", onData);
165
- resolve2(output);
173
+ resolve(output);
166
174
  };
167
175
  const onData = (chunk) => {
168
176
  output += chunk.toString("utf8");
@@ -176,12 +184,12 @@ function readUntil(channel, done, timeout, settle = 400) {
176
184
  function send(channel, line, timeout = 20000, settle = 1500) {
177
185
  channel.write(line + `
178
186
  `);
179
- return new Promise((resolve2) => {
187
+ return new Promise((resolve) => {
180
188
  let output = "";
181
189
  let timer;
182
190
  const finish = () => {
183
191
  channel.removeListener("data", onData);
184
- resolve2(output);
192
+ resolve(output);
185
193
  };
186
194
  const onData = (chunk) => {
187
195
  output += chunk.toString("utf8");
@@ -193,7 +201,7 @@ function send(channel, line, timeout = 20000, settle = 1500) {
193
201
  });
194
202
  }
195
203
  function upload(client, path, content) {
196
- return new Promise((resolve2, reject) => {
204
+ return new Promise((resolve, reject) => {
197
205
  client.sftp((error, sftp) => {
198
206
  if (error)
199
207
  return reject(error);
@@ -202,50 +210,12 @@ function upload(client, path, content) {
202
210
  if (writeError)
203
211
  reject(writeError);
204
212
  else
205
- resolve2();
213
+ resolve();
206
214
  });
207
215
  });
208
216
  });
209
217
  }
210
218
 
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
219
  // src/adapters/devices/derive.ts
250
220
  function lagIds(device) {
251
221
  const numbers = new Map;
@@ -261,7 +231,37 @@ function lagIds(device) {
261
231
  function ipv4Networks(device, iface) {
262
232
  const settings = device.interfaces?.[iface] ?? device.ports?.[iface];
263
233
  const addresses = (settings?.addresses ?? []).map((entry) => typeof entry === "string" ? entry : entry.address);
264
- return addresses.filter((address) => familyOf(address) === "ipv4").map((address) => network(address));
234
+ return addresses.filter((address) => familyOf2(address) === "ipv4").map((address) => network(address));
235
+ }
236
+ var SWITCH_SERVICES = ["ssh", "telnet", "snmp"];
237
+ function switchUnsupported(device) {
238
+ const found = [];
239
+ for (const service of SWITCH_SERVICES) {
240
+ const settings = device.management?.[service];
241
+ if (settings?.allow)
242
+ found.push(`management.${service}.allow, every service takes management.allow`);
243
+ if (settings?.port)
244
+ found.push(`management.${service}.port`);
245
+ }
246
+ for (const [name, user] of Object.entries(device.users ?? {})) {
247
+ if (user.description)
248
+ found.push(`users.${name}.description`);
249
+ if (user.ssh_keys?.length)
250
+ found.push(`users.${name}.ssh_keys`);
251
+ }
252
+ if (device.system?.ip)
253
+ found.push("system.ip");
254
+ for (const [name, port] of Object.entries(device.ports ?? {})) {
255
+ if (port?.hardware_offload !== undefined)
256
+ found.push(`ports.${name}.hardware_offload`);
257
+ }
258
+ for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
259
+ if (iface.addresses?.some((entry) => typeof entry !== "string"))
260
+ found.push(`interfaces.${name}.addresses with a peer`);
261
+ if (iface.hardware_offload !== undefined)
262
+ found.push(`interfaces.${name}.hardware_offload`);
263
+ }
264
+ return found;
265
265
  }
266
266
 
267
267
  // src/adapters/devices/lines.ts
@@ -353,7 +353,7 @@ function compare(desired, current, dialect) {
353
353
  }
354
354
  return [...groups.values()].filter((group) => group.add.length || group.remove.length);
355
355
  }
356
- function vlanListChange(groups, prefix, parse2, format, removal) {
356
+ function vlanListChange(groups, prefix, parse, format, removal) {
357
357
  const top = groups.find((group) => group.path.length === 0);
358
358
  if (!top)
359
359
  return;
@@ -361,8 +361,8 @@ function vlanListChange(groups, prefix, parse2, format, removal) {
361
361
  const fresh = top.add.find((line) => line.startsWith(prefix));
362
362
  if (!old && !fresh)
363
363
  return;
364
- const before = old ? parse2(old) : [];
365
- const after = fresh ? parse2(fresh) : [];
364
+ const before = old ? parse(old) : [];
365
+ const after = fresh ? parse(fresh) : [];
366
366
  top.remove = top.remove.filter((line) => line !== old);
367
367
  top.add = top.add.filter((line) => line !== fresh);
368
368
  const added = after.filter((id) => !before.includes(id));
@@ -500,7 +500,7 @@ function render(device) {
500
500
  }
501
501
  if (allow.length) {
502
502
  lines.push(`access-list ${MANAGEMENT_ACL}`, " description management");
503
- allow.forEach((cidr, index) => lines.push(` rule ${(index + 1) * 10} permit ${host(cidr)} ${netmask(split(cidr).len)}`));
503
+ allow.forEach((cidr, index) => lines.push(` rule ${(index + 1) * 10} permit ${host2(cidr)} ${netmask(split(cidr).len)}`));
504
504
  lines.push(" rule 100 deny any", "!");
505
505
  }
506
506
  for (const [name, user] of Object.entries(device.users ?? {}))
@@ -525,8 +525,8 @@ function render(device) {
525
525
  lines.push(`interface vlan ${vlanId(iface.vlan)}`);
526
526
  for (const entry of iface.addresses ?? []) {
527
527
  const address = typeof entry === "string" ? entry : entry.address;
528
- if (familyOf(address) === "ipv4")
529
- lines.push(`ip address ${host(address)} ${netmask(split(address).len)}`);
528
+ if (familyOf2(address) === "ipv4")
529
+ lines.push(`ip address ${host2(address)} ${netmask(split(address).len)}`);
530
530
  }
531
531
  const ospfSettings = Object.values(ospf?.areas ?? {}).find((area) => area.interfaces[name])?.interfaces[name];
532
532
  if (ospfSettings?.network === "point-to-point")
@@ -545,7 +545,7 @@ function render(device) {
545
545
  for (const [areaId, area] of Object.entries(ospf.areas)) {
546
546
  for (const iface of Object.keys(area.interfaces)) {
547
547
  for (const prefix of ipv4Networks(device, iface)) {
548
- lines.push(`network ${host(prefix)} ${wildcard(split(prefix).len)} area ${areaId}`);
548
+ lines.push(`network ${host2(prefix)} ${wildcard(split(prefix).len)} area ${areaId}`);
549
549
  }
550
550
  }
551
551
  }
@@ -580,7 +580,7 @@ function render(device) {
580
580
  lines.push(`snmp-server access-list ${MANAGEMENT_ACL}`);
581
581
  for (const route of device.routing?.static ?? []) {
582
582
  const { addr, len } = split(route.prefix);
583
- if (familyOf(addr) === "ipv4" && route.via)
583
+ if (familyOf2(addr) === "ipv4" && route.via)
584
584
  lines.push(`ip route ${addr} ${netmask(len)} ${route.via}`);
585
585
  }
586
586
  if (management.telnet) {
@@ -716,10 +716,17 @@ function unsupported(device) {
716
716
  found.push(`interfaces.${name}, a ${iface.type}`);
717
717
  continue;
718
718
  }
719
- if (iface.vrf)
720
- found.push(`interfaces.${name}.vrf`);
719
+ for (const field of ["vrf", "description", "mtu", "ipv6_ra"]) {
720
+ if (iface[field] !== undefined)
721
+ found.push(`interfaces.${name}.${field}`);
722
+ }
723
+ for (const entry of iface.addresses ?? []) {
724
+ const address = typeof entry === "string" ? entry : entry.address;
725
+ if (familyOf2(address) === "ipv6")
726
+ found.push(`interfaces.${name}.addresses ${address}, only IPv4 here`);
727
+ }
721
728
  for (const group of iface.vrrp ?? []) {
722
- if (familyOf(group.address) === "ipv6")
729
+ if (familyOf2(group.address) === "ipv6")
723
730
  found.push(`interfaces.${name}.vrrp for IPv6`);
724
731
  }
725
732
  }
@@ -740,8 +747,15 @@ function unsupported(device) {
740
747
  }
741
748
  }
742
749
  for (const route of routing?.static ?? []) {
743
- if (!route.via || route.vrf)
750
+ if (!route.via || route.vrf || familyOf2(route.prefix) === "ipv6") {
744
751
  found.push(`routing.static ${route.prefix}, only an IPv4 next hop in the default table`);
752
+ }
753
+ if (route.description || route.distance)
754
+ found.push(`routing.static ${route.prefix} description and distance`);
755
+ }
756
+ for (const [name, vlan] of Object.entries(device.vlans ?? {})) {
757
+ if (vlan.description)
758
+ found.push(`vlans.${name}.description`);
745
759
  }
746
760
  const UNSUPPORTED_SECTIONS = [
747
761
  "firewall",
@@ -768,6 +782,10 @@ function unsupported(device) {
768
782
  if (!SNMP_AUTH[user.auth])
769
783
  found.push(`management.snmp.users.${name}.auth ${user.auth}, the platform authenticates with SHA1 or MD5`);
770
784
  }
785
+ if (management.snmp?.contact || management.snmp?.location)
786
+ found.push("management.snmp.contact and location");
787
+ if (management.ssh?.weak_crypto)
788
+ found.push("management.ssh.weak_crypto");
771
789
  if (device.lldp && device.lldp !== true)
772
790
  found.push("lldp.interfaces, LLDP is per device");
773
791
  if (device.stp?.mode && device.stp.mode !== "mstp")
@@ -780,6 +798,7 @@ function unsupported(device) {
780
798
  if (system.dns || system.logging || system.release_channel || system.banner || system.ntp?.serve) {
781
799
  found.push("system.dns, logging, release_channel, banner and ntp.serve");
782
800
  }
801
+ found.push(...switchUnsupported(device));
783
802
  return found;
784
803
  }
785
804
  var REFUSED = /^(%|Error|Incomplete|Unrecognized)|can not|cannot |is in use|Wrong parameter|invalid|unsuccessful/im;
@@ -961,7 +980,7 @@ function parseLine(menu, line) {
961
980
  }
962
981
  return { menu, verb, selector, flags, attrs };
963
982
  }
964
- function parse2(text) {
983
+ function parse(text) {
965
984
  const commands = [];
966
985
  const generated = new Set;
967
986
  let menu = "";
@@ -972,9 +991,9 @@ function parse2(text) {
972
991
  menu = line;
973
992
  continue;
974
993
  }
975
- const marker2 = /^# generated (\S+)$/.exec(line);
976
- if (marker2)
977
- generated.add(marker2[1]);
994
+ const marker = /^# generated (\S+)$/.exec(line);
995
+ if (marker)
996
+ generated.add(marker[1]);
978
997
  if (menu && /^(add|set)( |$)/.test(line))
979
998
  commands.push(parseLine(menu, line));
980
999
  }
@@ -1064,12 +1083,12 @@ function normalValue(command, property, value) {
1064
1083
  if (property === "tagged" || property === "untagged" || property === "slaves")
1065
1084
  return value.split(",").sort().join(",");
1066
1085
  const onInterface = property === "address" && (command.menu === "/ip address" || command.menu === "/ipv6 address");
1067
- const isAddress2 = /(^|-)address$/.test(property) && !property.endsWith("mac-address");
1086
+ const isAddress = /(^|-)address$/.test(property) && !property.endsWith("mac-address");
1068
1087
  if (onInterface && !value.includes("/"))
1069
1088
  return `${value}/${value.includes(":") ? 64 : 32}`;
1070
- if (!onInterface && isAddress2 && /^[\d.]+\/32$/.test(value))
1089
+ if (!onInterface && isAddress && /^[\d.]+\/32$/.test(value))
1071
1090
  return value.slice(0, -3);
1072
- if (!onInterface && isAddress2 && value.includes(":") && !value.includes("/") && !value.includes(","))
1091
+ if (!onInterface && isAddress && value.includes(":") && !value.includes("/") && !value.includes(","))
1073
1092
  return `${value}/128`;
1074
1093
  return value;
1075
1094
  }
@@ -1398,13 +1417,13 @@ function planOrdered(menu, wanted, existing) {
1398
1417
  const inKeptChain = `[:typeof [:find $keep [${menu} get $id chain]]] != "nil"`;
1399
1418
  removeOld = `:local keep {${keep}}; :foreach id in=$${OLD_RULES} do={ :if (${inKeptChain}) do={ ${menu} remove $id } }`;
1400
1419
  }
1401
- const send2 = [
1420
+ const send = [
1402
1421
  `:global ${OLD_RULES} [${menu} find${where}]`,
1403
1422
  ...wanted.map((item) => line(menu, "add", "", item.flags, item.attrs)),
1404
1423
  `:global ${OLD_RULES}; ${removeOld}; /system script environment remove [find name="${OLD_RULES}"]`
1405
1424
  ];
1406
1425
  const show = [...existing.map((item) => `- ${canonical(item)}`), ...wanted.map((item) => `+ ${canonical(item)}`)];
1407
- result.first.push({ title: `${menu}, in the order of the config`, show, send: send2 });
1426
+ result.first.push({ title: `${menu}, in the order of the config`, show, send });
1408
1427
  if (hasChains) {
1409
1428
  for (const chain of droppedChains) {
1410
1429
  const selector = `[ find chain=${quote(chain)}${DYNAMIC.has(menu) ? " and !dynamic" : ""} ]`;
@@ -1414,8 +1433,8 @@ function planOrdered(menu, wanted, existing) {
1414
1433
  return result;
1415
1434
  }
1416
1435
  function plan2(desired, current, options) {
1417
- const want = parse2(desired);
1418
- const have = parse2(current);
1436
+ const want = parse(desired);
1437
+ const have = parse(current);
1419
1438
  const owned = (command) => !(command.menu.endsWith("address-list") && want.generated.has(command.attrs.list ?? ""));
1420
1439
  const wantedByMenu = byMenu(want.commands.filter(owned));
1421
1440
  const existingByMenu = byMenu(have.commands.filter(owned));
@@ -1460,22 +1479,22 @@ class Output {
1460
1479
  `;
1461
1480
  }
1462
1481
  }
1463
- function quote2(value2) {
1464
- const plain = /^[\w.:/@-]+$/.test(value2);
1482
+ function quote2(value) {
1483
+ const plain = /^[\w.:/@-]+$/.test(value);
1465
1484
  if (plain)
1466
- return value2;
1467
- const escaped = value2.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\$/g, "\\$");
1485
+ return value;
1486
+ const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\$/g, "\\$");
1468
1487
  return `"${escaped}"`;
1469
1488
  }
1470
1489
  function command(verb, properties) {
1471
- const words2 = [verb];
1472
- for (const [name, value2] of Object.entries(properties)) {
1473
- if (value2 === undefined || value2 === false)
1490
+ const words = [verb];
1491
+ for (const [name, value] of Object.entries(properties)) {
1492
+ if (value === undefined || value === false)
1474
1493
  continue;
1475
- const written = typeof value2 === "number" ? String(value2) : quote2(value2);
1476
- words2.push(`${name}=${written}`);
1494
+ const written = typeof value === "number" ? String(value) : quote2(value);
1495
+ words.push(`${name}=${written}`);
1477
1496
  }
1478
- return words2.join(" ");
1497
+ return words.join(" ");
1479
1498
  }
1480
1499
  var yesNo = (on) => on ? "yes" : "no";
1481
1500
  var familySuffix = (family) => family === "ipv4" ? "V4" : "V6";
@@ -1738,7 +1757,7 @@ function splitEndpoint(endpoint) {
1738
1757
  function vrrp(out, ctx) {
1739
1758
  for (const { name, settings } of ctx.routed()) {
1740
1759
  for (const group of settings.vrrp ?? []) {
1741
- const ipv6 = familyOf(group.address) === "ipv6";
1760
+ const ipv6 = familyOf2(group.address) === "ipv6";
1742
1761
  out.add("/interface vrrp", command("add", {
1743
1762
  interface: name,
1744
1763
  interval: group.interval ? duration(group.interval) : undefined,
@@ -1864,7 +1883,7 @@ function addresses(out, ctx) {
1864
1883
  for (const entry of settings.addresses ?? []) {
1865
1884
  const address = typeof entry === "string" ? entry : entry.address;
1866
1885
  const peer = typeof entry === "string" ? undefined : entry.peer;
1867
- if (familyOf(address) === "ipv4") {
1886
+ if (familyOf2(address) === "ipv4") {
1868
1887
  out.add("/ip address", command("add", { address, comment: settings.description, interface: name, network: peer }));
1869
1888
  } else {
1870
1889
  out.add("/ipv6 address", command("add", { address, advertise: yesNo(settings.ipv6_ra), comment: settings.description, interface: name }));
@@ -1873,7 +1892,7 @@ function addresses(out, ctx) {
1873
1892
  for (const group of settings.vrrp ?? []) {
1874
1893
  const vrrpInterface = vrrpName(name, group.id);
1875
1894
  const comment = `VRRP ${group.id} on ${name}`;
1876
- if (familyOf(group.address) === "ipv4") {
1895
+ if (familyOf2(group.address) === "ipv4") {
1877
1896
  out.add("/ip address", command("add", { address: `${group.address}/32`, comment, interface: vrrpInterface }));
1878
1897
  } else {
1879
1898
  out.add("/ipv6 address", command("add", {
@@ -1915,30 +1934,30 @@ function dhcp(out, ctx) {
1915
1934
  }
1916
1935
  }
1917
1936
  function dns(out, ctx) {
1918
- const dns2 = ctx.device.system?.dns;
1919
- out.add("/ip dns", command("set", { "allow-remote-requests": yesNo(dns2?.serve), servers: dns2?.servers.join(",") ?? "" }));
1937
+ const dns = ctx.device.system?.dns;
1938
+ out.add("/ip dns", command("set", { "allow-remote-requests": yesNo(dns?.serve), servers: dns?.servers.join(",") ?? "" }));
1920
1939
  }
1921
1940
  function addressLists(out, ctx) {
1922
1941
  const { device } = ctx;
1923
- const entries2 = [];
1942
+ const entries = [];
1924
1943
  for (const [list, members] of Object.entries(device.firewall?.address_sets ?? {})) {
1925
1944
  for (const address of members)
1926
- entries2.push({ list, address });
1945
+ entries.push({ list, address });
1927
1946
  }
1928
1947
  for (const [list, set] of Object.entries(device.prefix_sets ?? {})) {
1929
1948
  if ("prefixes" in set)
1930
1949
  for (const address of set.prefixes)
1931
- entries2.push({ list, address });
1950
+ entries.push({ list, address });
1932
1951
  else
1933
1952
  out.add("/ip firewall address-list", `# generated ${list}`);
1934
1953
  }
1935
1954
  for (const prefix of device.routing?.bgp?.networks ?? []) {
1936
- entries2.push({ list: NETWORKS[familyOf(host(prefix))], address: prefix });
1955
+ entries.push({ list: NETWORKS[familyOf2(host2(prefix))], address: prefix });
1937
1956
  }
1938
1957
  for (const family of ["ipv4", "ipv6"]) {
1939
1958
  const menu = family === "ipv4" ? "/ip firewall address-list" : "/ipv6 firewall address-list";
1940
- for (const { list, address } of entries2) {
1941
- if (familyOf(host(address)) !== family)
1959
+ for (const { list, address } of entries) {
1960
+ if (familyOf2(host2(address)) !== family)
1942
1961
  continue;
1943
1962
  out.add(menu, command("add", { address, list }));
1944
1963
  }
@@ -1958,7 +1977,7 @@ function renderFirewall(out, ctx) {
1958
1977
  function untracked(out, ctx, family) {
1959
1978
  const menu = family === "ipv4" ? "/ip firewall raw" : "/ipv6 firewall raw";
1960
1979
  for (const prefix of ctx.device.firewall?.untracked ?? []) {
1961
- if (familyOf(prefix) !== family)
1980
+ if (familyOf2(prefix) !== family)
1962
1981
  continue;
1963
1982
  for (const [direction, property] of [
1964
1983
  ["to", "dst-address"],
@@ -1975,8 +1994,8 @@ function families(ctx, match) {
1975
1994
  if (match?.family)
1976
1995
  candidates = [match.family];
1977
1996
  else if (address)
1978
- candidates = [familyOf(address)];
1979
- const setHas = (set, family) => !set || (sets[set] ?? []).some((member) => familyOf(member) === family);
1997
+ candidates = [familyOf2(address)];
1998
+ const setHas = (set, family) => !set || (sets[set] ?? []).some((member) => familyOf2(member) === family);
1980
1999
  return candidates.filter((family) => setHas(match?.src_set, family) && setHas(match?.dst_set, family));
1981
2000
  }
1982
2001
  function matchProperties(ctx, match, family) {
@@ -2027,7 +2046,7 @@ function nat(out, ctx, family) {
2027
2046
  for (const rule of rules ?? []) {
2028
2047
  if (!families(ctx, rule.match).includes(family))
2029
2048
  continue;
2030
- if (rule.action !== "masquerade" && familyOf(rule.to) !== family)
2049
+ if (rule.action !== "masquerade" && familyOf2(rule.to) !== family)
2031
2050
  continue;
2032
2051
  const action = rule.action === "masquerade" ? {
2033
2052
  action: "masquerade"
@@ -2049,7 +2068,7 @@ function switchAcls(out, ctx) {
2049
2068
  continue;
2050
2069
  for (const rule of ctx.device.acls?.[settings.acl] ?? []) {
2051
2070
  const match = rule.match ?? {};
2052
- const ipv6 = match.family === "ipv6" || [match.src, match.dst].some((address2) => address2 && familyOf(address2) === "ipv6");
2071
+ const ipv6 = match.family === "ipv6" || [match.src, match.dst].some((address) => address && familyOf2(address) === "ipv6");
2053
2072
  const address = (prefix) => prefix && !ipv6 ? dottedMask(prefix) : prefix;
2054
2073
  for (const srcPort of each(match.src_port)) {
2055
2074
  for (const dstPort of each(match.dst_port)) {
@@ -2094,7 +2113,7 @@ function renderRouting(out, ctx) {
2094
2113
  }
2095
2114
  function staticRoutes(out, ctx) {
2096
2115
  for (const route of ctx.device.routing?.static ?? []) {
2097
- const menu = familyOf(host(route.prefix)) === "ipv4" ? "/ip route" : "/ipv6 route";
2116
+ const menu = familyOf2(host2(route.prefix)) === "ipv4" ? "/ip route" : "/ipv6 route";
2098
2117
  const verb = route.blackhole ? "add blackhole" : "add";
2099
2118
  const gateway = route.via ?? (route.interface ? ctx.name(route.interface) : undefined);
2100
2119
  out.add(menu, command(verb, {
@@ -2125,21 +2144,21 @@ function settingsOf(ctx, neighbor) {
2125
2144
  return { ...group, ...neighbor };
2126
2145
  }
2127
2146
  function familiesOf(ctx, neighbor) {
2128
- return settingsOf(ctx, neighbor).families ?? [familyOf(neighbor.address)];
2147
+ return settingsOf(ctx, neighbor).families ?? [familyOf2(neighbor.address)];
2129
2148
  }
2130
2149
  var chainName = (policy, family) => `${policy}-${familySuffix(family)}`;
2131
2150
  function appliesTo(ctx, rule, family) {
2132
2151
  const match = rule.match;
2133
2152
  if (match?.family && match.family !== family)
2134
2153
  return false;
2135
- if (match?.prefix && familyOf(host(match.prefix)) !== family)
2154
+ if (match?.prefix && familyOf2(host2(match.prefix)) !== family)
2136
2155
  return false;
2137
2156
  if (match?.prefix_set) {
2138
2157
  const set = ctx.device.prefix_sets?.[match.prefix_set];
2139
2158
  if (!set)
2140
2159
  return false;
2141
2160
  const firstPrefix = "source" in set ? undefined : set.prefixes[0];
2142
- const setFamily = "source" in set ? set.source.family : firstPrefix && familyOf(host(firstPrefix));
2161
+ const setFamily = "source" in set ? set.source.family : firstPrefix && familyOf2(host2(firstPrefix));
2143
2162
  if (setFamily !== family)
2144
2163
  return false;
2145
2164
  }
@@ -2170,35 +2189,35 @@ function conditions(rule) {
2170
2189
  }
2171
2190
  function actions(rule, family) {
2172
2191
  const set = rule.set ?? {};
2173
- const steps2 = [];
2192
+ const steps = [];
2174
2193
  if (set.local_pref !== undefined)
2175
- steps2.push(`set bgp-local-pref ${set.local_pref}`);
2194
+ steps.push(`set bgp-local-pref ${set.local_pref}`);
2176
2195
  if (set.med !== undefined)
2177
- steps2.push(`set bgp-med ${set.med}`);
2196
+ steps.push(`set bgp-med ${set.med}`);
2178
2197
  if (set.prepend !== undefined)
2179
- steps2.push(`set bgp-path-prepend ${set.prepend}`);
2198
+ steps.push(`set bgp-path-prepend ${set.prepend}`);
2180
2199
  for (const pattern of set.remove_communities ?? [])
2181
- steps2.push(`delete bgp-communities regexp "${communityPattern(pattern)}"`);
2200
+ steps.push(`delete bgp-communities regexp "${communityPattern(pattern)}"`);
2182
2201
  if (set.communities)
2183
- steps2.push(`set bgp-communities ${set.communities.join(",")}`);
2202
+ steps.push(`set bgp-communities ${set.communities.join(",")}`);
2184
2203
  if (set.add_communities)
2185
- steps2.push(`append bgp-communities ${set.add_communities.join(",")}`);
2204
+ steps.push(`append bgp-communities ${set.add_communities.join(",")}`);
2186
2205
  for (const pattern of set.remove_large_communities ?? []) {
2187
- steps2.push(`delete bgp-large-communities regexp "${communityPattern(pattern)}"`);
2206
+ steps.push(`delete bgp-large-communities regexp "${communityPattern(pattern)}"`);
2188
2207
  }
2189
2208
  if (set.large_communities)
2190
- steps2.push(`set bgp-large-communities ${set.large_communities.join(",")}`);
2209
+ steps.push(`set bgp-large-communities ${set.large_communities.join(",")}`);
2191
2210
  if (set.add_large_communities)
2192
- steps2.push(`append bgp-large-communities ${set.add_large_communities.join(",")}`);
2193
- if (set.next_hop && familyOf(set.next_hop) === family)
2194
- steps2.push(`set gw ${set.next_hop}`);
2195
- if (set.preferred_source && familyOf(set.preferred_source) === family)
2196
- steps2.push(`set pref-src ${set.preferred_source}`);
2211
+ steps.push(`append bgp-large-communities ${set.add_large_communities.join(",")}`);
2212
+ if (set.next_hop && familyOf2(set.next_hop) === family)
2213
+ steps.push(`set gw ${set.next_hop}`);
2214
+ if (set.preferred_source && familyOf2(set.preferred_source) === family)
2215
+ steps.push(`set pref-src ${set.preferred_source}`);
2197
2216
  if (set.blackhole)
2198
- steps2.push("set blackhole yes");
2217
+ steps.push("set blackhole yes");
2199
2218
  if (rule.action)
2200
- steps2.push(rule.action);
2201
- return steps2;
2219
+ steps.push(rule.action);
2220
+ return steps;
2202
2221
  }
2203
2222
  function ruleText(rule, family) {
2204
2223
  const parts = [];
@@ -2207,24 +2226,24 @@ function ruleText(rule, family) {
2207
2226
  if (rule.match?.rpki)
2208
2227
  parts.push(`rpki-verify ${RPKI_GROUP}`);
2209
2228
  const tests = conditions(rule);
2210
- const steps2 = actions(rule, family);
2229
+ const steps = actions(rule, family);
2211
2230
  if (tests.length)
2212
- parts.push(`if (${tests.join(" && ")})${steps2.length ? ` { ${steps2.join("; ")} }` : ""}`);
2231
+ parts.push(`if (${tests.join(" && ")})${steps.length ? ` { ${steps.join("; ")} }` : ""}`);
2213
2232
  else
2214
- parts.push(...steps2);
2233
+ parts.push(...steps);
2215
2234
  return parts.join("; ");
2216
2235
  }
2217
2236
  function policies(out, ctx) {
2218
2237
  const bgp = ctx.device.routing.bgp;
2219
- const policies2 = ctx.device.policies ?? {};
2238
+ const policies = ctx.device.policies ?? {};
2220
2239
  const wanted = new Map;
2221
2240
  const want = (policy, family) => {
2222
- const families2 = wanted.get(policy) ?? new Set;
2223
- if (families2.has(family))
2241
+ const families = wanted.get(policy) ?? new Set;
2242
+ if (families.has(family))
2224
2243
  return;
2225
- families2.add(family);
2226
- wanted.set(policy, families2);
2227
- for (const rule of policies2[policy] ?? [])
2244
+ families.add(family);
2245
+ wanted.set(policy, families);
2246
+ for (const rule of policies[policy] ?? [])
2228
2247
  if (rule.call && appliesTo(ctx, rule, family))
2229
2248
  want(rule.call, family);
2230
2249
  };
@@ -2240,13 +2259,13 @@ function policies(out, ctx) {
2240
2259
  }
2241
2260
  }
2242
2261
  }
2243
- const called = new Set(Object.values(policies2).flatMap((rules) => rules.flatMap((rule) => rule.call ? [rule.call] : [])));
2262
+ const called = new Set(Object.values(policies).flatMap((rules) => rules.flatMap((rule) => rule.call ? [rule.call] : [])));
2244
2263
  for (const policy of [...wanted.keys()].sort()) {
2245
2264
  for (const family of ["ipv4", "ipv6"]) {
2246
2265
  if (!wanted.get(policy).has(family))
2247
2266
  continue;
2248
2267
  const chain = chainName(policy, family);
2249
- const rules = (policies2[policy] ?? []).filter((rule) => appliesTo(ctx, rule, family));
2268
+ const rules = (policies[policy] ?? []).filter((rule) => appliesTo(ctx, rule, family));
2250
2269
  for (const rule of rules) {
2251
2270
  const text = ruleText(rule, family);
2252
2271
  if (text)
@@ -2271,9 +2290,9 @@ function bgpConnections(out, ctx) {
2271
2290
  for (const [name, neighbor] of Object.entries(bgp.neighbors ?? {})) {
2272
2291
  const settings = settingsOf(ctx, neighbor);
2273
2292
  const [family] = familiesOf(ctx, neighbor);
2274
- const addressFamily = familyOf(neighbor.address);
2293
+ const addressFamily = familyOf2(neighbor.address);
2275
2294
  const internal = settings.remote_as === bgp.asn;
2276
- const originates = bgp.networks?.some((prefix) => familyOf(host(prefix)) === family);
2295
+ const originates = bgp.networks?.some((prefix) => familyOf2(host2(prefix)) === family);
2277
2296
  const role = settings.role ? ROLES[settings.role] : internal ? "ibgp" : "ebgp";
2278
2297
  out.add("/routing bgp connection", command("add", {
2279
2298
  afi: addressFamily === family ? undefined : family === "ipv6" ? "ipv6" : "ip",
@@ -2299,19 +2318,19 @@ function bgpConnections(out, ctx) {
2299
2318
  }
2300
2319
  }
2301
2320
  function ospf(out, ctx) {
2302
- const ospf2 = ctx.device.routing?.ospf;
2303
- if (!ospf2)
2321
+ const ospf = ctx.device.routing?.ospf;
2322
+ if (!ospf)
2304
2323
  return;
2305
- for (const family of ospf2.families ?? ["ipv4"]) {
2324
+ for (const family of ospf.families ?? ["ipv4"]) {
2306
2325
  const version = family === "ipv4" ? 2 : 3;
2307
2326
  const instance = `ospf-v${version}`;
2308
2327
  out.add("/routing ospf instance", command("add", {
2309
2328
  name: instance,
2310
- redistribute: ospf2.redistribute?.join(","),
2329
+ redistribute: ospf.redistribute?.join(","),
2311
2330
  "router-id": ctx.device.routing?.router_id,
2312
2331
  version
2313
2332
  }));
2314
- for (const [areaId, area] of Object.entries(ospf2.areas)) {
2333
+ for (const [areaId, area] of Object.entries(ospf.areas)) {
2315
2334
  const areaName = `${instance}-${areaId}`;
2316
2335
  out.add("/routing ospf area", command("add", { "area-id": areaId, instance, name: areaName }));
2317
2336
  for (const [iface, settings] of Object.entries(area.interfaces)) {
@@ -2388,19 +2407,19 @@ function layerTwo(out, ctx) {
2388
2407
  }
2389
2408
  function snmp(out, ctx) {
2390
2409
  const management = ctx.device.management ?? {};
2391
- const snmp2 = management.snmp;
2392
- if (!snmp2) {
2410
+ const snmp = management.snmp;
2411
+ if (!snmp) {
2393
2412
  out.add("/snmp", command("set", { enabled: "no" }));
2394
2413
  out.add("/snmp community", command("set [ find default=yes ]", { addresses: "::/0", name: "public" }));
2395
2414
  return;
2396
2415
  }
2397
- const allow = (snmp2.allow ?? management.allow ?? []).join(",");
2398
- out.add("/snmp", command("set", { contact: snmp2.contact ?? "", enabled: "yes", location: snmp2.location ?? "" }));
2399
- if (snmp2.community)
2400
- out.add("/snmp community", command("set [ find default=yes ]", { addresses: allow, name: marker(snmp2.community) }));
2416
+ const allow = (snmp.allow ?? management.allow ?? []).join(",");
2417
+ out.add("/snmp", command("set", { contact: snmp.contact ?? "", enabled: "yes", location: snmp.location ?? "" }));
2418
+ if (snmp.community)
2419
+ out.add("/snmp community", command("set [ find default=yes ]", { addresses: allow, name: marker(snmp.community) }));
2401
2420
  else
2402
2421
  out.add("/snmp community", command("set [ find default=yes ]", { addresses: "::/0", disabled: "yes", name: "public" }));
2403
- for (const [name, user] of Object.entries(snmp2.users ?? {})) {
2422
+ for (const [name, user] of Object.entries(snmp.users ?? {})) {
2404
2423
  out.add("/snmp community", command("add", {
2405
2424
  addresses: allow,
2406
2425
  "authentication-password": marker(user.auth_password),
@@ -2467,15 +2486,15 @@ function renderSystem(out, ctx) {
2467
2486
  flowExport(out, ctx);
2468
2487
  }
2469
2488
  function logging(out, ctx) {
2470
- const logging2 = ctx.device.system?.logging;
2471
- const local = logging2?.local;
2489
+ const logging = ctx.device.system?.logging;
2490
+ const local = logging?.local;
2472
2491
  out.add("/system logging action", command("set 1", {
2473
2492
  "disk-file-count": local?.files ?? 2,
2474
2493
  "disk-lines-per-file": local?.lines_per_file ?? 1000
2475
2494
  }));
2476
2495
  for (const topic of local ? topicsFrom(local.level) : [])
2477
2496
  out.add("/system logging", command("add", { action: "disk", topics: topic }));
2478
- (logging2?.remote ?? []).forEach((collector, index) => {
2497
+ (logging?.remote ?? []).forEach((collector, index) => {
2479
2498
  const action = `remote${index + 1}`;
2480
2499
  out.add("/system logging action", command("add", {
2481
2500
  name: action,
@@ -2553,17 +2572,17 @@ async function read2(device) {
2553
2572
  try {
2554
2573
  await checkVersion(client, device);
2555
2574
  const config = await exec(client, "/export", 300000);
2556
- const users2 = await exec(client, "/user export");
2557
- const certificates2 = (await exec(client, LIST_CERTIFICATES)).trim();
2558
- const listed = certificates2.split(`
2559
- `).every((line2) => line2.trim().startsWith("add fingerprint="));
2560
- if (certificates2 && !listed) {
2561
- throw new Error(`${device.name}: certificates could not be listed: ${certificates2.slice(0, 120)}`);
2575
+ const users = await exec(client, "/user export");
2576
+ const certificates = (await exec(client, LIST_CERTIFICATES)).trim();
2577
+ const listed = certificates.split(`
2578
+ `).every((line) => line.trim().startsWith("add fingerprint="));
2579
+ if (certificates && !listed) {
2580
+ throw new Error(`${device.name}: certificates could not be listed: ${certificates.slice(0, 120)}`);
2562
2581
  }
2563
- const userMenu = users2.slice(users2.indexOf(`
2582
+ const userMenu = users.slice(users.indexOf(`
2564
2583
  /`)).trim();
2565
- const certificateMenu = certificates2 ? `/certificate
2566
- ${certificates2}` : "";
2584
+ const certificateMenu = certificates ? `/certificate
2585
+ ${certificates}` : "";
2567
2586
  const text = [config.trim(), userMenu, certificateMenu].filter(Boolean).join(`
2568
2587
  `);
2569
2588
  return withoutTimestamp(text) + `
@@ -2588,11 +2607,19 @@ function unsupported2(device) {
2588
2607
  found.push(`ports.${name}.storm_control with a different percent per kind, the platform takes one`);
2589
2608
  }
2590
2609
  }
2610
+ for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
2611
+ if (iface.type !== "lag")
2612
+ continue;
2613
+ for (const field of ["storm_control", "stp"]) {
2614
+ if (iface[field] !== undefined)
2615
+ found.push(`interfaces.${name}.${field}, on a LAG`);
2616
+ }
2617
+ }
2591
2618
  const bgp = device.routing?.bgp;
2592
2619
  for (const [name, neighbor] of Object.entries(bgp?.neighbors ?? {})) {
2593
2620
  const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
2594
- const families2 = neighbor.families ?? group?.families ?? [familyOf(neighbor.address)];
2595
- if (families2.length > 1)
2621
+ const families = neighbor.families ?? group?.families ?? [familyOf2(neighbor.address)];
2622
+ if (families.length > 1)
2596
2623
  found.push(`routing.bgp.neighbors.${name}.families, one family per session`);
2597
2624
  }
2598
2625
  const management = device.management;
@@ -2600,6 +2627,8 @@ function unsupported2(device) {
2600
2627
  found.push("management.console, the console uses the device's users");
2601
2628
  if (management?.privilege_password)
2602
2629
  found.push("management.privilege_password, the platform has no privileged mode");
2630
+ if (management?.snmp?.port)
2631
+ found.push("management.snmp.port");
2603
2632
  if (management?.ssh?.auth_timeout || management?.ssh?.auth_retries) {
2604
2633
  found.push("management.ssh.auth_timeout and auth_retries");
2605
2634
  }
@@ -2616,8 +2645,8 @@ function unsupported2(device) {
2616
2645
  }
2617
2646
  var ROLLBACK_SCHEDULER = "apply-rollback";
2618
2647
  var escapeForString = (text) => text.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\$/g, "\\$");
2619
- async function refusal(client, command2) {
2620
- const compiled = (await exec(client, `:put [:parse "${escapeForString(command2)}"]`)).trim();
2648
+ async function refusal(client, command) {
2649
+ const compiled = (await exec(client, `:put [:parse "${escapeForString(command)}"]`)).trim();
2621
2650
  const refused = !compiled.startsWith("(") || /syntax error|bad parameter|no such|expected/.test(compiled);
2622
2651
  return refused ? compiled.slice(0, 200) : undefined;
2623
2652
  }
@@ -2628,25 +2657,25 @@ async function installCertificate(client, device, name) {
2628
2657
  await upload(client, `${name}.crt`, certificate.certificate);
2629
2658
  await exec(client, `/certificate import file-name=${name}.crt name=${name} passphrase=""`);
2630
2659
  if (certificate.private_key) {
2631
- const value2 = resolve(certificate.private_key);
2632
- const privateKey = value2.includes("-----BEGIN") ? `${value2}
2633
- ` : Buffer.from(value2, "base64").toString("utf8");
2660
+ const value = resolve(certificate.private_key);
2661
+ const privateKey = value.includes("-----BEGIN") ? `${value}
2662
+ ` : Buffer.from(value, "base64").toString("utf8");
2634
2663
  await upload(client, `${name}.key`, privateKey);
2635
2664
  await exec(client, `/certificate import file-name=${name}.key name=${name} passphrase=""`);
2636
2665
  }
2637
2666
  await exec(client, `/file remove [find name="${name}.crt" or name="${name}.key"]`);
2638
2667
  }
2639
- async function apply2(device, _network, changes2, options) {
2668
+ async function apply2(device, _network, changes, options) {
2640
2669
  const client = await login(device);
2641
2670
  await checkVersion(client, device);
2642
- const commands = changes2.steps.flatMap((step) => step.send.map((command2) => ({ title: step.title, command: command2 })));
2643
- for (const { title, command: command2 } of commands) {
2644
- if (command2.startsWith(INSTALL_CERTIFICATE))
2671
+ const commands = changes.steps.flatMap((step) => step.send.map((command) => ({ title: step.title, command })));
2672
+ for (const { title, command } of commands) {
2673
+ if (command.startsWith(INSTALL_CERTIFICATE))
2645
2674
  continue;
2646
- const reason = await refusal(client, substitute(command2));
2675
+ const reason = await refusal(client, substitute(command));
2647
2676
  if (reason) {
2648
2677
  client.end();
2649
- throw new Error(`${title}: the device will not parse ${command2.slice(0, 200)}
2678
+ throw new Error(`${title}: the device will not parse ${command.slice(0, 200)}
2650
2679
  ${reason}`);
2651
2680
  }
2652
2681
  }
@@ -2663,15 +2692,15 @@ async function apply2(device, _network, changes2, options) {
2663
2692
  await exec(client, `/system backup save name=${backup} dont-encrypt=yes`, 120000);
2664
2693
  await exec(client, `/system scheduler remove [find name="${ROLLBACK_SCHEDULER}"]`);
2665
2694
  await exec(client, armRollback);
2666
- for (const { title, command: command2 } of commands) {
2667
- if (command2.startsWith(INSTALL_CERTIFICATE)) {
2668
- const certificateName = command2.slice(INSTALL_CERTIFICATE.length).trim();
2695
+ for (const { title, command } of commands) {
2696
+ if (command.startsWith(INSTALL_CERTIFICATE)) {
2697
+ const certificateName = command.slice(INSTALL_CERTIFICATE.length).trim();
2669
2698
  await installCertificate(client, device, certificateName);
2670
2699
  continue;
2671
2700
  }
2672
- const answer = (await exec(client, substitute(command2), 120000)).trim();
2701
+ const answer = (await exec(client, substitute(command), 120000)).trim();
2673
2702
  if (answer)
2674
- throw new Error(`${title}: ${command2.slice(0, 160)}
2703
+ throw new Error(`${title}: ${command.slice(0, 160)}
2675
2704
  device said: ${answer.slice(0, 300)}`);
2676
2705
  }
2677
2706
  } catch (error) {
@@ -2743,20 +2772,20 @@ var DEVICE_FACTS2 = [
2743
2772
  "Info:"
2744
2773
  ];
2745
2774
  var UNDO_BY_KEYWORD2 = /^(description|name|port default vlan|port link-type|port trunk pvid vlan|set authentication password|eth-trunk|ip binding vpn-instance)\b/;
2746
- function undo2(line2) {
2747
- if (line2.startsWith("undo "))
2748
- return line2.slice("undo ".length);
2749
- if (/^rule \d+/.test(line2))
2750
- return `undo ${line2.split(" ").slice(0, 2).join(" ")}`;
2751
- const keyword = UNDO_BY_KEYWORD2.exec(line2);
2775
+ function undo2(line) {
2776
+ if (line.startsWith("undo "))
2777
+ return line.slice("undo ".length);
2778
+ if (/^rule \d+/.test(line))
2779
+ return `undo ${line.split(" ").slice(0, 2).join(" ")}`;
2780
+ const keyword = UNDO_BY_KEYWORD2.exec(line);
2752
2781
  if (keyword)
2753
2782
  return `undo ${keyword[1]}`;
2754
- if (line2.startsWith("storm control "))
2755
- return `undo ${line2.replace(/ (min-rate|max-rate).*$/, "")}`;
2756
- return `undo ${line2}`;
2783
+ if (line.startsWith("storm control "))
2784
+ return `undo ${line.replace(/ (min-rate|max-rate).*$/, "")}`;
2785
+ return `undo ${line}`;
2757
2786
  }
2758
- function canon(line2) {
2759
- return line2.replace(/(irreversible-cipher|cipher) \S+/g, "cipher <secret>").replace(/<secret:\w+>/g, "<secret>");
2787
+ function canon(line) {
2788
+ return line.replace(/(irreversible-cipher|cipher) \S+/g, "cipher <secret>").replace(/<secret:\w+>/g, "<secret>");
2760
2789
  }
2761
2790
  function order(path) {
2762
2791
  const header = path[0] ?? "";
@@ -2778,7 +2807,7 @@ var dialect2 = {
2778
2807
  block: /^(interface |vlan \d+$|acl number |aaa$|user-interface |ospf |ip vpn-instance |bfd$)/,
2779
2808
  indented: true,
2780
2809
  terminator: "#",
2781
- ignore: (line2) => DEVICE_FACTS2.some((fact) => line2.startsWith(fact)),
2810
+ ignore: (line) => DEVICE_FACTS2.some((fact) => line.startsWith(fact)),
2782
2811
  undo: undo2,
2783
2812
  leave: "quit",
2784
2813
  first: /^authentication-mode /,
@@ -2792,13 +2821,13 @@ var SNMP_GROUP2 = "circuit";
2792
2821
  var USER_LEVEL = { admin: 3, operator: 2, "read-only": 1 };
2793
2822
  var SNMP_AUTH3 = { sha1: "sha", sha256: "sha2-256" };
2794
2823
  var SNMP_PRIVACY2 = { aes128: "aes128", des: "des56" };
2795
- function batchIds(line2) {
2796
- const words2 = line2.replace(/^vlan batch /, "").split(" ");
2824
+ function batchIds(line) {
2825
+ const words = line.replace(/^vlan batch /, "").split(" ");
2797
2826
  const ids = [];
2798
- for (let index = 0;index < words2.length; index++) {
2799
- const first = Number(words2[index]);
2800
- if (words2[index + 1] === "to") {
2801
- const last = Number(words2[index + 2]);
2827
+ for (let index = 0;index < words.length; index++) {
2828
+ const first = Number(words[index]);
2829
+ if (words[index + 1] === "to") {
2830
+ const last = Number(words[index + 2]);
2802
2831
  for (let id = first;id <= last; id++)
2803
2832
  ids.push(id);
2804
2833
  index += 2;
@@ -2935,7 +2964,7 @@ function managementAcl(config, device) {
2935
2964
  const allow = device.management?.allow ?? [];
2936
2965
  if (!allow.length)
2937
2966
  return;
2938
- const rules = allow.map((cidr, index) => `rule ${(index + 1) * 5} permit source ${host(cidr)} ${wildcard(split(cidr).len)}`);
2967
+ const rules = allow.map((cidr, index) => `rule ${(index + 1) * 5} permit source ${host2(cidr)} ${wildcard(split(cidr).len)}`);
2939
2968
  config.block(`acl number ${MANAGEMENT_ACL2}`, ["description Management access", ...rules, `rule ${(allow.length + 1) * 5} deny`]);
2940
2969
  }
2941
2970
  function users2(config, device) {
@@ -3014,8 +3043,8 @@ function vlanInterface(device, name, iface) {
3014
3043
  lines.push(`ip binding vpn-instance ${iface.vrf}`);
3015
3044
  for (const entry of iface.addresses ?? []) {
3016
3045
  const address = typeof entry === "string" ? entry : entry.address;
3017
- if (familyOf(address) === "ipv4")
3018
- lines.push(`ip address ${host(address)} ${netmask(split(address).len)}`);
3046
+ if (familyOf2(address) === "ipv4")
3047
+ lines.push(`ip address ${host2(address)} ${netmask(split(address).len)}`);
3019
3048
  else
3020
3049
  lines.push(`ipv6 address ${address}`);
3021
3050
  }
@@ -3070,7 +3099,7 @@ function staticRoutes2(config, device, nameOf) {
3070
3099
  const vrf = route.vrf ? ` vpn-instance ${route.vrf}` : "";
3071
3100
  const preference = route.distance ? ` preference ${route.distance}` : "";
3072
3101
  const description = route.description ? ` description ${route.description}` : "";
3073
- if (familyOf(addr) === "ipv4")
3102
+ if (familyOf2(addr) === "ipv4")
3074
3103
  config.line(`ip route-static${vrf} ${addr} ${netmask(len)} ${nextHop}${preference}${description}`);
3075
3104
  else
3076
3105
  config.line(`ipv6 route-static${vrf} ${addr} ${len} ${nextHop}${preference}`);
@@ -3078,21 +3107,21 @@ function staticRoutes2(config, device, nameOf) {
3078
3107
  config.separator();
3079
3108
  }
3080
3109
  function ospf2(config, device, nameOf) {
3081
- const ospf3 = device.routing?.ospf;
3082
- if (!ospf3)
3110
+ const ospf = device.routing?.ospf;
3111
+ if (!ospf)
3083
3112
  return;
3084
3113
  const routerId = device.routing?.router_id;
3085
3114
  const lines = [];
3086
- for (const source of ospf3.redistribute ?? [])
3115
+ for (const source of ospf.redistribute ?? [])
3087
3116
  lines.push(`import-route ${source === "connected" ? "direct" : "static"}`);
3088
3117
  const areaLines = [];
3089
- for (const [areaId, area] of Object.entries(ospf3.areas)) {
3118
+ for (const [areaId, area] of Object.entries(ospf.areas)) {
3090
3119
  areaLines.push(`area ${areaId}`);
3091
3120
  for (const [iface, settings] of Object.entries(area.interfaces)) {
3092
3121
  if (settings?.passive)
3093
3122
  lines.push(`silent-interface ${nameOf(iface)}`);
3094
3123
  for (const prefix of ipv4Networks(device, iface))
3095
- areaLines.push(` network ${host(prefix)} ${wildcard(split(prefix).len)}`);
3124
+ areaLines.push(` network ${host2(prefix)} ${wildcard(split(prefix).len)}`);
3096
3125
  }
3097
3126
  }
3098
3127
  config.block(`ospf ${OSPF_PROCESS2}${routerId ? ` router-id ${routerId}` : ""}`, [...lines, ...areaLines]);
@@ -3113,23 +3142,23 @@ function flowExport2(config, device) {
3113
3142
  config.separator();
3114
3143
  }
3115
3144
  function snmp2(config, device) {
3116
- const snmp3 = device.management?.snmp;
3117
- if (!snmp3)
3145
+ const snmp = device.management?.snmp;
3146
+ if (!snmp)
3118
3147
  return;
3119
- const versions = [snmp3.community ? "v2c" : "", snmp3.users ? "v3" : ""].filter(Boolean).join(" ");
3148
+ const versions = [snmp.community ? "v2c" : "", snmp.users ? "v3" : ""].filter(Boolean).join(" ");
3120
3149
  const guarded = (device.management?.allow?.length ?? 0) > 0;
3121
3150
  config.line("snmp-agent");
3122
3151
  config.line(`snmp-agent sys-info version ${versions}`);
3123
- if (snmp3.contact)
3124
- config.line(`snmp-agent sys-info contact ${snmp3.contact}`);
3125
- if (snmp3.location)
3126
- config.line(`snmp-agent sys-info location ${snmp3.location}`);
3127
- if (snmp3.community)
3128
- config.line(`snmp-agent community read cipher ${marker(snmp3.community)}${guarded ? ` acl ${MANAGEMENT_ACL2}` : ""}`);
3129
- if (snmp3.users) {
3152
+ if (snmp.contact)
3153
+ config.line(`snmp-agent sys-info contact ${snmp.contact}`);
3154
+ if (snmp.location)
3155
+ config.line(`snmp-agent sys-info location ${snmp.location}`);
3156
+ if (snmp.community)
3157
+ config.line(`snmp-agent community read cipher ${marker(snmp.community)}${guarded ? ` acl ${MANAGEMENT_ACL2}` : ""}`);
3158
+ if (snmp.users) {
3130
3159
  config.line("snmp-agent mib-view included all iso");
3131
3160
  config.line(`snmp-agent group v3 ${SNMP_GROUP2} privacy read-view all${guarded ? ` acl ${MANAGEMENT_ACL2}` : ""}`);
3132
- for (const [name, user] of Object.entries(snmp3.users)) {
3161
+ for (const [name, user] of Object.entries(snmp.users)) {
3133
3162
  config.line(`snmp-agent usm-user v3 ${name} group ${SNMP_GROUP2}`);
3134
3163
  config.line(`snmp-agent usm-user v3 ${name} authentication-mode ${SNMP_AUTH3[user.auth]} cipher ${marker(user.auth_password)}`);
3135
3164
  config.line(`snmp-agent usm-user v3 ${name} privacy-mode ${SNMP_PRIVACY2[user.privacy]} cipher ${marker(user.privacy_password)}`);
@@ -3138,8 +3167,8 @@ function snmp2(config, device) {
3138
3167
  config.separator();
3139
3168
  }
3140
3169
  function ssh(config, device) {
3141
- const ssh2 = device.management?.ssh;
3142
- if (!ssh2)
3170
+ const ssh = device.management?.ssh;
3171
+ if (!ssh)
3143
3172
  return;
3144
3173
  const guarded = (device.management?.allow?.length ?? 0) > 0;
3145
3174
  config.line("stelnet server enable");
@@ -3152,7 +3181,7 @@ function ssh(config, device) {
3152
3181
  config.line(`ssh server acl ${MANAGEMENT_ACL2}`);
3153
3182
  config.line("ssh authorization-type default aaa");
3154
3183
  config.separator();
3155
- if (!ssh2.weak_crypto) {
3184
+ if (!ssh.weak_crypto) {
3156
3185
  config.line("ssh server cipher aes256_ctr aes128_ctr");
3157
3186
  config.line("ssh server hmac sha2_256_96 sha2_256");
3158
3187
  config.line("ssh server key-exchange dh_group_exchange_sha256 ecdh_sha2_nistp256 ecdh_sha2_nistp384 ecdh_sha2_nistp521");
@@ -3194,10 +3223,10 @@ async function read3(device) {
3194
3223
  channel.end();
3195
3224
  const lines = dump.replace(/\r/g, "").split(`
3196
3225
  `);
3197
- const start = lines.findIndex((line2) => line2.startsWith("!Software Version"));
3226
+ const start = lines.findIndex((line) => line.startsWith("!Software Version"));
3198
3227
  if (start < 0)
3199
3228
  throw new Error(`${device.name} returned no configuration`);
3200
- const body = lines.slice(start).filter((line2) => !/^<.*>$/.test(line2.trim()) && !line2.startsWith("!Last configuration"));
3229
+ const body = lines.slice(start).filter((line) => !/^<.*>$/.test(line.trim()) && !line.startsWith("!Last configuration"));
3201
3230
  return scrub(body.join(`
3202
3231
  `).trim() + `
3203
3232
  `);
@@ -3225,10 +3254,16 @@ function unsupported3(device) {
3225
3254
  found.push(`interfaces.${name}, a ${iface.type}`);
3226
3255
  if (iface.type === "lag" && iface.addresses?.length)
3227
3256
  found.push(`interfaces.${name}.addresses, LAGs are switched`);
3257
+ if (iface.mtu !== undefined)
3258
+ found.push(`interfaces.${name}.mtu`);
3228
3259
  for (const group of iface.vrrp ?? [])
3229
- if (familyOf(group.address) === "ipv6")
3260
+ if (familyOf2(group.address) === "ipv6")
3230
3261
  found.push(`interfaces.${name}.vrrp for IPv6`);
3231
3262
  }
3263
+ for (const [name, vrf] of Object.entries(device.vrfs ?? {})) {
3264
+ if (vrf.description)
3265
+ found.push(`vrfs.${name}.description`);
3266
+ }
3232
3267
  const routing = device.routing;
3233
3268
  if (routing?.bgp)
3234
3269
  found.push("routing.bgp");
@@ -3255,10 +3290,13 @@ function unsupported3(device) {
3255
3290
  found.push("management.ssh.auth_timeout and auth_retries");
3256
3291
  if (device.lldp && device.lldp !== true)
3257
3292
  found.push("lldp.interfaces, use lldp on the ports instead");
3258
- const system2 = device.system ?? {};
3259
- if (system2.dns || system2.logging || system2.release_channel || system2.banner) {
3293
+ const system = device.system ?? {};
3294
+ if (system.dns || system.logging || system.release_channel || system.banner) {
3260
3295
  found.push("system.dns, logging, release_channel and banner");
3261
3296
  }
3297
+ if (system.transceiver_monitoring !== undefined)
3298
+ found.push("system.transceiver_monitoring");
3299
+ found.push(...switchUnsupported(device));
3262
3300
  const flow = device.flow_export;
3263
3301
  if (flow && flow.protocol !== "sflow")
3264
3302
  found.push(`flow_export.protocol ${flow.protocol}, the platform exports sFlow`);
@@ -3271,31 +3309,31 @@ function unsupported3(device) {
3271
3309
  }
3272
3310
  var REFUSED2 = /^\s*Error|Unrecognized command|Incomplete command|Wrong parameter|Too many parameters/m;
3273
3311
  var INTERACTIVE = /^(snmp-agent usm-user v3 \S+ (?:authentication|privacy)-mode \S+) cipher <secret:([A-Za-z0-9_./:-]+)>$/;
3274
- async function sendLine(channel, line2) {
3275
- const interactive = INTERACTIVE.exec(line2.trim());
3312
+ async function sendLine(channel, line) {
3313
+ const interactive = INTERACTIVE.exec(line.trim());
3276
3314
  if (interactive) {
3277
- const [, command2, reference] = interactive;
3315
+ const [, command, reference] = interactive;
3278
3316
  const password = valueOfReference(reference);
3279
- let answer2 = await send(channel, command2, 15000, 900);
3280
- answer2 += await send(channel, password, 15000, 900);
3281
- answer2 += await send(channel, password, 15000, 900);
3282
- return answer2;
3317
+ let answer = await send(channel, command, 15000, 900);
3318
+ answer += await send(channel, password, 15000, 900);
3319
+ answer += await send(channel, password, 15000, 900);
3320
+ return answer;
3283
3321
  }
3284
- let answer = await send(channel, substitute(line2), 20000, 800);
3322
+ let answer = await send(channel, substitute(line), 20000, 800);
3285
3323
  if (/\[Y\/N\]/i.test(answer))
3286
3324
  answer += await send(channel, "Y", 20000, 800);
3287
3325
  return answer;
3288
3326
  }
3289
- async function apply3(device, _network, plan3) {
3327
+ async function apply3(device, _network, plan) {
3290
3328
  const client = await login2(device);
3291
3329
  const channel = await openShell(client);
3292
3330
  try {
3293
3331
  await send(channel, "system-view", 1e4, 1500);
3294
- for (const step of plan3.steps) {
3295
- for (const line2 of step.send) {
3296
- const answer = await sendLine(channel, line2);
3332
+ for (const step of plan.steps) {
3333
+ for (const line of step.send) {
3334
+ const answer = await sendLine(channel, line);
3297
3335
  if (REFUSED2.test(answer))
3298
- throw new Error(`${step.title}: ${line2}
3336
+ throw new Error(`${step.title}: ${line}
3299
3337
  device said: ${answer.trim().slice(0, 300)}`);
3300
3338
  }
3301
3339
  }
@@ -3331,19 +3369,19 @@ function plan3(desired, current) {
3331
3369
  const groups = compare(desired, current, dialect2);
3332
3370
  vlanListChange(groups, "vlan batch ", batchIds, (ids) => [`vlan batch ${vlanBatch(ids)}`], (ids) => [`vlan batch ${vlanBatch(ids)}`]);
3333
3371
  const top = groups.find((group) => group.path.length === 0);
3334
- const removedBatch = top?.remove.find((line2) => line2.startsWith("vlan batch ")) ?? "";
3372
+ const removedBatch = top?.remove.find((line) => line.startsWith("vlan batch ")) ?? "";
3335
3373
  const removedIds = removedBatch ? batchIds(removedBatch) : [];
3336
3374
  const firstRemovals = [
3337
- ...removedIds.map((id) => `interface Vlanif${id}`).filter((line2) => top?.remove.includes(line2)),
3338
- ...removedIds.map((id) => `vlan ${id}`).filter((line2) => top?.remove.includes(line2)),
3375
+ ...removedIds.map((id) => `interface Vlanif${id}`).filter((line) => top?.remove.includes(line)),
3376
+ ...removedIds.map((id) => `vlan ${id}`).filter((line) => top?.remove.includes(line)),
3339
3377
  ...removedBatch ? [removedBatch] : []
3340
3378
  ];
3341
3379
  if (top)
3342
- top.remove = top.remove.filter((line2) => !firstRemovals.includes(line2));
3380
+ top.remove = top.remove.filter((line) => !firstRemovals.includes(line));
3343
3381
  const planned = steps(groups.filter((group) => group.add.length || group.remove.length), dialect2);
3344
3382
  if (firstRemovals.length) {
3345
- const sent = firstRemovals.filter((line2) => !/^vlan \d+$/.test(line2)).map(undo2);
3346
- planned.unshift({ title: "VLANs no longer used", show: firstRemovals.map((line2) => `- ${line2}`), send: sent });
3383
+ const sent = firstRemovals.filter((line) => !/^vlan \d+$/.test(line)).map(undo2);
3384
+ planned.unshift({ title: "VLANs no longer used", show: firstRemovals.map((line) => `- ${line}`), send: sent });
3347
3385
  }
3348
3386
  return { steps: planned, problems: [] };
3349
3387
  }
@@ -3378,17 +3416,17 @@ var registries = { "ripe-stat": ripeStat };
3378
3416
  // src/adapters/registries/librenms.ts
3379
3417
  var AUTH = { sha1: "SHA", sha256: "SHA-256" };
3380
3418
  var PRIVACY = { aes128: "AES", des: "DES" };
3381
- function settingsOf2(network2) {
3382
- if (!network2.librenms)
3419
+ function settingsOf2(network) {
3420
+ if (!network.librenms)
3383
3421
  throw new Error("the network has no librenms settings");
3384
3422
  return {
3385
- base: `${network2.librenms.url.replace(/\/+$/, "")}/api/v0`,
3386
- headers: { "X-Auth-Token": resolve(network2.librenms.api_token), "Content-Type": "application/json" }
3423
+ base: `${network.librenms.url.replace(/\/+$/, "")}/api/v0`,
3424
+ headers: { "X-Auth-Token": resolve(network.librenms.api_token), "Content-Type": "application/json" }
3387
3425
  };
3388
3426
  }
3389
3427
  function credentials(device) {
3390
- const snmp3 = device.management?.snmp;
3391
- const [name, user] = Object.entries(snmp3?.users ?? {})[0] ?? [];
3428
+ const snmp = device.management?.snmp;
3429
+ const [name, user] = Object.entries(snmp?.users ?? {})[0] ?? [];
3392
3430
  if (name && user) {
3393
3431
  return {
3394
3432
  snmpver: "v3",
@@ -3400,26 +3438,26 @@ function credentials(device) {
3400
3438
  cryptoalgo: PRIVACY[user.privacy]
3401
3439
  };
3402
3440
  }
3403
- if (snmp3?.community)
3404
- return { snmpver: "v2c", community: resolve(snmp3.community) };
3441
+ if (snmp?.community)
3442
+ return { snmpver: "v2c", community: resolve(snmp.community) };
3405
3443
  return;
3406
3444
  }
3407
- async function planLibreNms(network2) {
3408
- const { base, headers } = settingsOf2(network2);
3445
+ async function planLibreNms(network) {
3446
+ const { base, headers } = settingsOf2(network);
3409
3447
  const response = await fetch(`${base}/devices?type=all`, { headers, signal: AbortSignal.timeout(30000) });
3410
3448
  if (!response.ok)
3411
3449
  throw new Error(`LibreNMS answered ${response.status} to the device list`);
3412
3450
  const monitored = new Set((await response.json()).devices.map((device) => device.hostname));
3413
- const ours = new Set(network2.devices.map((device) => device.connection.host));
3414
- const missing = network2.devices.filter((device) => !monitored.has(device.connection.host));
3451
+ const ours = new Set(network.devices.map((device) => device.connection.host));
3452
+ const missing = network.devices.filter((device) => !monitored.has(device.connection.host));
3415
3453
  return {
3416
3454
  additions: missing.filter((device) => device.management?.snmp),
3417
3455
  withoutSnmp: missing.filter((device) => !device.management?.snmp),
3418
3456
  unknown: [...monitored].filter((hostname) => !ours.has(hostname)).sort()
3419
3457
  };
3420
3458
  }
3421
- async function sendLibreNms(network2, additions) {
3422
- const { base, headers } = settingsOf2(network2);
3459
+ async function sendLibreNms(network, additions) {
3460
+ const { base, headers } = settingsOf2(network);
3423
3461
  for (const device of additions) {
3424
3462
  const body = { hostname: device.connection.host, display_template: device.name, ...credentials(device) };
3425
3463
  const response = await fetch(`${base}/devices`, {
@@ -3438,55 +3476,55 @@ async function sendLibreNms(network2, additions) {
3438
3476
 
3439
3477
  // src/adapters/registries/peeringdb.ts
3440
3478
  var API = "https://www.peeringdb.com/api";
3441
- var addressesOf = (iface) => (iface.addresses ?? []).map((entry) => typeof entry === "string" ? entry : entry.address);
3479
+ var addressesOf2 = (iface) => (iface.addresses ?? []).map((entry) => typeof entry === "string" ? entry : entry.address);
3442
3480
  function peersWithRouteServer(device, iface) {
3443
3481
  const bgp = device.routing?.bgp;
3444
- const subnets = addressesOf(iface);
3482
+ const subnets = addressesOf2(iface);
3445
3483
  return Object.values(bgp?.neighbors ?? {}).some((neighbor) => {
3446
3484
  const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
3447
3485
  const role = neighbor.role ?? group?.role;
3448
- return role === "rs" && subnets.some((subnet) => contains(subnet, neighbor.address));
3486
+ return role === "rs" && subnets.some((subnet) => contains2(subnet, neighbor.address));
3449
3487
  });
3450
3488
  }
3451
- function wanted(network2) {
3489
+ function wanted(network) {
3452
3490
  const records = [];
3453
- for (const device of network2.devices) {
3491
+ for (const device of network.devices) {
3454
3492
  for (const iface of Object.values(device.interfaces ?? {})) {
3455
3493
  const exchange = iface.exchange;
3456
3494
  if (!exchange?.peeringdb_ixlan)
3457
3495
  continue;
3458
- const published = addressesOf(iface).map(host).filter(isGlobal);
3496
+ const published = addressesOf2(iface).map(host2).filter(isGlobal2);
3459
3497
  records.push({
3460
3498
  name: exchange.name,
3461
3499
  ixlan_id: exchange.peeringdb_ixlan,
3462
3500
  speed: exchange.speed,
3463
- ipaddr4: published.find((address) => familyOf(address) === "ipv4") ?? null,
3464
- ipaddr6: published.find((address) => familyOf(address) === "ipv6") ?? null,
3501
+ ipaddr4: published.find((address) => familyOf2(address) === "ipv4") ?? null,
3502
+ ipaddr6: published.find((address) => familyOf2(address) === "ipv6") ?? null,
3465
3503
  is_rs_peer: peersWithRouteServer(device, iface)
3466
3504
  });
3467
3505
  }
3468
3506
  }
3469
3507
  return records;
3470
3508
  }
3471
- async function planPeeringDb(network2) {
3472
- if (!network2.asn)
3509
+ async function planPeeringDb(network) {
3510
+ if (!network.asn)
3473
3511
  throw new Error("the network declares no asn");
3474
- const response = await fetch(`${API}/netixlan?asn=${network2.asn}`, {
3512
+ const response = await fetch(`${API}/netixlan?asn=${network.asn}`, {
3475
3513
  headers: { Accept: "application/json" },
3476
3514
  signal: AbortSignal.timeout(30000)
3477
3515
  });
3478
3516
  if (!response.ok)
3479
3517
  throw new Error(`PeeringDB answered ${response.status}`);
3480
3518
  const existing = (await response.json()).data;
3481
- const desired = wanted(network2);
3519
+ const desired = wanted(network);
3482
3520
  const netId = existing[0]?.net_id;
3483
- const changes2 = [];
3521
+ const changes = [];
3484
3522
  for (const record of desired) {
3485
3523
  const current = existing.find((candidate) => candidate.ixlan_id === record.ixlan_id);
3486
3524
  const body = {
3487
3525
  net_id: netId,
3488
3526
  ixlan_id: record.ixlan_id,
3489
- asn: network2.asn,
3527
+ asn: network.asn,
3490
3528
  speed: record.speed,
3491
3529
  ipaddr4: record.ipaddr4,
3492
3530
  ipaddr6: record.ipaddr6,
@@ -3494,27 +3532,27 @@ async function planPeeringDb(network2) {
3494
3532
  operational: current?.operational ?? true
3495
3533
  };
3496
3534
  if (!current) {
3497
- changes2.push({ summary: `+ ${record.name}: ${record.ipaddr4}, ${record.ipaddr6}, ${record.speed} Mbit/s`, body });
3535
+ changes.push({ summary: `+ ${record.name}: ${record.ipaddr4}, ${record.ipaddr6}, ${record.speed} Mbit/s`, body });
3498
3536
  continue;
3499
3537
  }
3500
3538
  const differs = current.ipaddr4 !== record.ipaddr4 || current.ipaddr6 !== record.ipaddr6 || current.speed !== record.speed || current.is_rs_peer !== record.is_rs_peer;
3501
3539
  if (differs) {
3502
3540
  const before = `${current.ipaddr4}, ${current.ipaddr6}, ${current.speed} Mbit/s, route server ${current.is_rs_peer}`;
3503
3541
  const after = `${record.ipaddr4}, ${record.ipaddr6}, ${record.speed} Mbit/s, route server ${record.is_rs_peer}`;
3504
- changes2.push({ summary: `~ ${record.name}: ${before} to ${after}`, id: current.id, body });
3542
+ changes.push({ summary: `~ ${record.name}: ${before} to ${after}`, id: current.id, body });
3505
3543
  }
3506
3544
  }
3507
3545
  const unknown = existing.filter((record) => !desired.some((candidate) => candidate.ixlan_id === record.ixlan_id)).map((record) => record.name);
3508
- if (changes2.some((change) => !change.id) && !netId) {
3546
+ if (changes.some((change) => !change.id) && !netId) {
3509
3547
  throw new Error("PeeringDB holds no presence for this network yet, so its record id is unknown; add the first one by hand");
3510
3548
  }
3511
- return { changes: changes2, unknown };
3549
+ return { changes, unknown };
3512
3550
  }
3513
- async function sendPeeringDb(network2, changes2) {
3514
- if (!network2.peeringdb)
3551
+ async function sendPeeringDb(network, changes) {
3552
+ if (!network.peeringdb)
3515
3553
  throw new Error("the network declares no peeringdb api_key");
3516
- const apiKey = resolve(network2.peeringdb.api_key);
3517
- for (const change of changes2) {
3554
+ const apiKey = resolve(network.peeringdb.api_key);
3555
+ for (const change of changes) {
3518
3556
  const response = await fetch(`${API}/netixlan${change.id ? `/${change.id}` : ""}`, {
3519
3557
  method: change.id ? "PUT" : "POST",
3520
3558
  headers: { Authorization: `Api-Key ${apiKey}`, "Content-Type": "application/json" },
@@ -3532,13 +3570,13 @@ async function sendPeeringDb(network2, changes2) {
3532
3570
  // src/adapters/registries/prometheus.ts
3533
3571
  var AUTH2 = { sha1: "SHA", sha256: "SHA256" };
3534
3572
  var PRIVACY2 = { aes128: "AES", des: "DES" };
3535
- function prometheusTargets(network2) {
3573
+ function prometheusTargets(network) {
3536
3574
  return {
3537
- targets: network2.devices.filter((device) => device.management?.snmp).map((device) => ({
3575
+ targets: network.devices.filter((device) => device.management?.snmp).map((device) => ({
3538
3576
  targets: [device.connection.host],
3539
3577
  labels: { device: device.name, platform: device.platform, model: device.model, __param_auth: device.name }
3540
3578
  })),
3541
- withoutSnmp: network2.devices.filter((device) => !device.management?.snmp)
3579
+ withoutSnmp: network.devices.filter((device) => !device.management?.snmp)
3542
3580
  };
3543
3581
  }
3544
3582
  function fromEnvironment(secret, notes, where) {
@@ -3547,17 +3585,17 @@ function fromEnvironment(secret, notes, where) {
3547
3585
  notes.push(`${where} is read from a file; write its value in place of FILL_IN`);
3548
3586
  return "FILL_IN";
3549
3587
  }
3550
- function snmpExporterAuths(network2) {
3588
+ function snmpExporterAuths(network) {
3551
3589
  const notes = [];
3552
3590
  const lines = [
3553
3591
  "# Written by `circuit prometheus --auths`. Load it beside snmp.yml, with --config.expand-environment-variables.",
3554
3592
  "auths:"
3555
3593
  ];
3556
- for (const device of network2.devices) {
3557
- const snmp3 = device.management?.snmp;
3558
- if (!snmp3)
3594
+ for (const device of network.devices) {
3595
+ const snmp = device.management?.snmp;
3596
+ if (!snmp)
3559
3597
  continue;
3560
- const [name, user] = Object.entries(snmp3.users ?? {})[0] ?? [];
3598
+ const [name, user] = Object.entries(snmp.users ?? {})[0] ?? [];
3561
3599
  lines.push(` ${device.name}:`);
3562
3600
  if (name && user) {
3563
3601
  lines.push(" version: 3", ` username: ${name}`, " security_level: authPriv", ` password: ${fromEnvironment(user.auth_password, notes, `${device.name}'s SNMP password`)}`, ` auth_protocol: ${AUTH2[user.auth]}`, ` priv_protocol: ${PRIVACY2[user.privacy]}`, ` priv_password: ${fromEnvironment(user.privacy_password, notes, `${device.name}'s SNMP privacy password`)}`);
@@ -3571,428 +3609,254 @@ function snmpExporterAuths(network2) {
3571
3609
  `, notes };
3572
3610
  }
3573
3611
 
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
- };
3612
+ // src/core/validate.ts
3613
+ import { X509Certificate as X509Certificate2 } from "node:crypto";
3586
3614
 
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;
3615
+ // src/core/across.ts
3616
+ function checkAcross(devices) {
3617
+ const holders = holdersOf(devices);
3618
+ const owners = new Map;
3619
+ for (const holder of holders) {
3620
+ const key = keyOf(host2(holder.address));
3621
+ if (!owners.has(key))
3622
+ owners.set(key, holder);
3603
3623
  }
3624
+ return [
3625
+ ...checkLinks(devices),
3626
+ ...checkAddresses(holders),
3627
+ ...checkSessions(devices, owners),
3628
+ ...checkVrrp(holders),
3629
+ ...checkTunnels(devices, owners)
3630
+ ];
3604
3631
  }
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];
3632
+ function carried(device, port) {
3633
+ const id = (vlan) => vlan ? device.vlans?.[vlan]?.id : undefined;
3634
+ return {
3635
+ access: id(port.access_vlan),
3636
+ native: id(port.native_vlan),
3637
+ trunk: (port.trunk_vlans ?? []).map(id).sort((a, b) => (a ?? 0) - (b ?? 0))
3638
+ };
3609
3639
  }
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];
3640
+ function vlanNames(device, settings) {
3641
+ const names = [settings.access_vlan, settings.native_vlan, ...settings.trunk_vlans ?? []].filter((name) => name !== undefined);
3642
+ return new Map(names.flatMap((name) => device.vlans?.[name] ? [[device.vlans[name].id, name]] : []));
3626
3643
  }
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);
3644
+ function carrierOf(device, portName, port) {
3645
+ const lag = port.lag ? device.interfaces?.[port.lag] : undefined;
3646
+ if (port.lag && lag?.type === "lag")
3647
+ return { name: port.lag, settings: lag };
3648
+ return { name: portName, settings: port };
3634
3649
  }
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`;
3650
+ var plainAddresses = (settings, family) => (settings.addresses ?? []).filter((entry) => typeof entry === "string" && isCidr2(entry) && familyOf2(entry) === family);
3651
+ function addressDisagreement(here, there, far, farName) {
3652
+ for (const family of ["ipv4", "ipv6"]) {
3653
+ const ours = plainAddresses(here, family);
3654
+ const theirs = plainAddresses(there, family);
3655
+ if (!ours.length || !theirs.length)
3656
+ continue;
3657
+ const theirPrefixes = new Set(theirs.map(network));
3658
+ if (!ours.some((address) => theirPrefixes.has(network(address)))) {
3659
+ return `has ${ours.join(", ")}, and ${far.name} ${farName} has ${theirs.join(", ")}: not one prefix of one length`;
3660
+ }
3661
+ }
3662
+ const farHosts = new Set((there.addresses ?? []).map((entry) => keyOf(host2(typeof entry === "string" ? entry : entry.address))));
3663
+ for (const entry of here.addresses ?? []) {
3664
+ if (typeof entry === "string" || !keyOf(entry.peer) || farHosts.has(keyOf(entry.peer)))
3665
+ continue;
3666
+ return `${entry.address} names peer ${entry.peer}, which ${far.name} ${farName} does not hold`;
3667
+ }
3668
+ return;
3641
3669
  }
3642
- function cables(devices, graph) {
3670
+ function checkLinks(devices) {
3671
+ const findings = [];
3643
3672
  const byName = new Map(devices.map((device) => [device.name, device]));
3644
- const drawn = new Set;
3645
- const bundles = new Map;
3646
3673
  for (const device of devices) {
3647
3674
  for (const [portName, port] of Object.entries(device.ports ?? {})) {
3648
3675
  const link = port?.link;
3649
3676
  if (!port || !link)
3650
3677
  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}`);
3678
+ const report = (message, level = "error") => findings.push({
3679
+ level,
3680
+ device: device.name,
3681
+ message: `port ${portName} link: ${message}`
3682
+ });
3656
3683
  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`;
3684
+ if (!far) {
3685
+ report(`device ${link.device} is not in the network`);
3669
3686
  continue;
3670
3687
  }
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)
3688
+ const farPort = far.ports?.[link.port];
3689
+ if (!farPort) {
3690
+ report(`${link.device} does not declare port ${link.port}`);
3686
3691
  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
- });
3692
+ }
3693
+ const linksBack = farPort.link?.device === device.name && farPort.link.port === portName;
3694
+ if (!linksBack) {
3695
+ report(`${link.device} ${link.port} does not link back to it`);
3696
+ continue;
3697
+ }
3698
+ const here = JSON.stringify(carried(device, port));
3699
+ const there = JSON.stringify(carried(far, farPort));
3700
+ if (here !== there)
3701
+ report(`carries ${here}, and ${link.device} ${link.port} carries ${there}`);
3702
+ if (port.speed && farPort.speed && port.speed !== farPort.speed) {
3703
+ report(`speed ${port.speed}, and ${link.device} ${link.port} is ${farPort.speed}`);
3704
+ }
3705
+ const near = carrierOf(device, portName, port);
3706
+ const away = carrierOf(far, link.port, farPort);
3707
+ const mtu = near.settings.mtu ?? 1500;
3708
+ const farMtu = away.settings.mtu ?? 1500;
3709
+ if (mtu !== farMtu)
3710
+ report(`MTU ${mtu} on ${near.name}, and ${link.device} ${away.name} has ${farMtu}`);
3711
+ const disagreement = addressDisagreement(near.settings, away.settings, far, away.name);
3712
+ if (disagreement)
3713
+ report(`${near.name} ${disagreement}`);
3714
+ const farNames = vlanNames(far, away.settings);
3715
+ for (const [id, name] of vlanNames(device, near.settings)) {
3716
+ const farName = farNames.get(id);
3717
+ if (farName && farName !== name)
3718
+ report(`VLAN ${id} is ${name} here and ${farName} on ${link.device}`, "warning");
3719
+ }
3699
3720
  }
3700
3721
  }
3722
+ return findings;
3701
3723
  }
3702
- function segments(holders2, owners, graph) {
3703
- const bySegment = new Map;
3704
- const pairs = new Set;
3705
- 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
- });
3724
+ function checkAddresses(holders) {
3725
+ const findings = [];
3726
+ const first = new Map;
3727
+ const isLoopback = (holder) => holder.device.interfaces?.[holder.name]?.type === "loopback";
3728
+ for (const holder of holders) {
3729
+ const key = keyOf(host2(holder.address));
3730
+ const earlier = first.get(key);
3731
+ if (!earlier) {
3732
+ first.set(key, holder);
3721
3733
  continue;
3722
3734
  }
3723
- if (isHostRoute(holder.address))
3735
+ if (earlier.device === holder.device)
3724
3736
  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
- });
3742
- 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
- }
3737
+ const anycast = isLoopback(earlier) && isLoopback(holder);
3738
+ findings.push({
3739
+ level: anycast ? "warning" : "error",
3740
+ device: holder.device.name,
3741
+ message: `${host2(holder.address)} on ${holder.name} is also on ${earlier.device.name} ${earlier.name}${anycast ? ", which is right only for anycast" : ""}`
3742
+ });
3754
3743
  }
3744
+ return findings;
3755
3745
  }
3756
- function sessions(devices, owners, graph) {
3757
- const all = devices.flatMap((device) => {
3746
+ function checkSessions(devices, owners) {
3747
+ const findings = [];
3748
+ const sessionsOf = (device) => {
3758
3749
  const bgp = device.routing?.bgp;
3759
3750
  return Object.entries(bgp?.neighbors ?? {}).map(([name, neighbor]) => {
3760
3751
  const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
3761
3752
  return {
3762
- device,
3763
3753
  name,
3764
3754
  address: neighbor.address,
3765
- ownAs: bgp.asn,
3766
3755
  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
3756
+ localAddress: neighbor.local_address ?? group?.local_address
3771
3757
  };
3772
3758
  });
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
3759
  };
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;
3760
+ for (const device of devices) {
3761
+ for (const session of sessionsOf(device)) {
3762
+ const far = owners.get(keyOf(session.address) ?? "")?.device;
3763
+ if (!far || far === device)
3764
+ continue;
3765
+ const report = (message) => findings.push({ level: "error", device: device.name, message: `neighbor ${session.name}: ${message}` });
3766
+ const farBgp = far.routing?.bgp;
3767
+ if (!farBgp) {
3768
+ report(`${session.address} is ${far.name}'s, which runs no BGP`);
3769
+ continue;
3770
+ }
3771
+ if (session.remoteAs !== undefined && session.remoteAs !== farBgp.asn) {
3772
+ report(`expects AS${session.remoteAs}, and ${far.name} is AS${farBgp.asn}`);
3773
+ }
3774
+ const back = sessionsOf(far).find((other) => owners.get(keyOf(other.address) ?? "")?.device === device && familyOf2(other.address) === familyOf2(session.address));
3775
+ if (!back) {
3776
+ report(`${far.name} has no session back to ${device.name}`);
3777
+ continue;
3778
+ }
3779
+ if (session.localAddress && keyOf(session.localAddress) !== keyOf(back.address)) {
3780
+ report(`starts from ${session.localAddress}, and ${far.name}'s session ${back.name} expects ${back.address}`);
3781
+ }
3803
3782
  }
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
3783
  }
3784
+ return findings;
3815
3785
  }
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
- }
3786
+ function checkVrrp(holders) {
3787
+ const findings = [];
3788
+ const bySegment = new Map;
3789
+ for (const holder of holders) {
3790
+ const segment = network(holder.address);
3791
+ for (const group of holder.vrrp) {
3792
+ if (!keyOf(group.address) || familyOf2(group.address) !== familyOf2(holder.address))
3793
+ continue;
3794
+ if (!contains2(segment, group.address))
3795
+ continue;
3796
+ bySegment.set(segment, [...bySegment.get(segment) ?? [], { holder, id: group.id, address: group.address }]);
3834
3797
  }
3835
3798
  }
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]
3799
+ for (const members of bySegment.values()) {
3800
+ for (const member of members) {
3801
+ const where = `${member.holder.device.name} ${member.holder.name}`;
3802
+ const others = members.filter((other) => other.holder.device !== member.holder.device);
3803
+ const sameId = others.find((other) => other.id === member.id && keyOf(other.address) !== keyOf(member.address));
3804
+ if (sameId) {
3805
+ findings.push({
3806
+ level: "error",
3807
+ device: member.holder.device.name,
3808
+ message: `VRRP ${member.id} on ${member.holder.name} is ${member.address}, and on ${sameId.holder.device.name} ${sameId.holder.name} it is ${sameId.address}`
3809
+ });
3810
+ }
3811
+ const sameAddress = others.find((other) => keyOf(other.address) === keyOf(member.address) && other.id !== member.id);
3812
+ if (sameAddress) {
3813
+ findings.push({
3814
+ level: "error",
3815
+ device: member.holder.device.name,
3816
+ message: `VRRP address ${member.address} is id ${member.id} on ${where}, and id ${sameAddress.id} on ${sameAddress.holder.device.name} ${sameAddress.holder.name}`
3848
3817
  });
3849
3818
  }
3850
3819
  }
3851
3820
  }
3821
+ return findings;
3852
3822
  }
3853
- var endpointHost = (endpoint) => endpoint.replace(/:\d+$/, "").replace(/^\[(.*)\]$/, "$1");
3854
- function tunnels2(devices, owners, graph) {
3823
+ function checkTunnels(devices, owners) {
3824
+ const findings = [];
3855
3825
  const ends = devices.flatMap((device) => Object.entries(device.interfaces ?? {}).flatMap(([name, iface]) => iface.type === "gre" || iface.type === "vxlan" ? [{ device, name, iface }] : []));
3856
3826
  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
3827
  for (const end of ends) {
3864
- if (paired.has(end))
3828
+ const far = owners.get(keyOf(end.iface.remote) ?? "")?.device;
3829
+ if (!far || far === end.device)
3865
3830
  continue;
3831
+ const report = (message) => findings.push({ level: "error", device: end.device.name, message: `interface ${end.name}: ${message}` });
3866
3832
  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
- });
3877
- continue;
3878
- }
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
- });
3833
+ 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));
3834
+ if (!mirror) {
3835
+ report(`${end.iface.remote} is ${far.name}'s, which has no ${kind} tunnel from there back to ${end.iface.local}`);
3889
3836
  continue;
3890
3837
  }
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)
3838
+ if (end.iface.type !== "vxlan" || mirror.iface.type !== "vxlan")
3947
3839
  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);
3840
+ if (end.iface.vni !== mirror.iface.vni)
3841
+ report(`VNI ${end.iface.vni}, and ${far.name} ${mirror.name} has ${mirror.iface.vni}`);
3842
+ const port = end.iface.port ?? 4789;
3843
+ const farPort = mirror.iface.port ?? 4789;
3844
+ if (port !== farPort)
3845
+ report(`UDP port ${port}, and ${far.name} ${mirror.name} has ${farPort}`);
3968
3846
  }
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 };
3847
+ return findings;
3983
3848
  }
3984
3849
 
3985
3850
  // src/core/validate.ts
3986
- import { X509Certificate as X509Certificate2 } from "node:crypto";
3987
- function validate(network2) {
3851
+ function validate(network) {
3988
3852
  const findings = [];
3989
3853
  const seen = new Set;
3990
- for (const device of network2.devices) {
3854
+ for (const device of network.devices) {
3991
3855
  if (seen.has(device.name))
3992
3856
  findings.push({ level: "error", device: device.name, message: "declared twice" });
3993
3857
  seen.add(device.name);
3994
3858
  }
3995
- for (const device of network2.devices) {
3859
+ for (const device of network.devices) {
3996
3860
  const report = (message) => findings.push({ level: "error", device: device.name, message });
3997
3861
  checkDevice(device, report);
3998
3862
  const adapter = adapters[device.platform];
@@ -4002,9 +3866,9 @@ function validate(network2) {
4002
3866
  for (const field of adapter.unsupported(device))
4003
3867
  report(`${device.platform} cannot express ${field}`);
4004
3868
  }
4005
- findings.push(...checkLinks(network2.devices));
4006
- for (const check of network2.checks ?? [])
4007
- findings.push(...check(network2.devices));
3869
+ findings.push(...checkAcross(network.devices));
3870
+ for (const check of network.checks ?? [])
3871
+ findings.push(...check(network.devices));
4008
3872
  return findings;
4009
3873
  }
4010
3874
 
@@ -4036,18 +3900,18 @@ class Names {
4036
3900
  }
4037
3901
  function checkDevice(device, report) {
4038
3902
  const names = new Names(device, report);
4039
- const cidr = (where, value2) => {
4040
- if (!isCidr(value2))
4041
- report(`${where}: '${value2}' is not an address with a length`);
3903
+ const cidr = (where, value) => {
3904
+ if (!isCidr2(value))
3905
+ report(`${where}: '${value}' is not an address with a length`);
4042
3906
  };
4043
- const ip = (where, value2) => {
4044
- if (!isAddress(value2))
4045
- report(`${where}: '${value2}' is not an address`);
3907
+ const ip = (where, value) => {
3908
+ if (!isAddress2(value))
3909
+ report(`${where}: '${value}' is not an address`);
4046
3910
  };
4047
3911
  if (!device.users?.[device.connection.user])
4048
3912
  report(`connection user '${device.connection.user}' is not one of its users`);
4049
3913
  checkVlans(device, report);
4050
- checkAddresses(device, report, cidr, ip);
3914
+ checkAddresses2(device, report, cidr, ip);
4051
3915
  checkPorts(device, names, report);
4052
3916
  checkInterfaces(device, names, report, ip);
4053
3917
  checkRouting(device, names, report, cidr, ip);
@@ -4066,13 +3930,13 @@ function checkVlans(device, report) {
4066
3930
  ids.set(vlan.id, name);
4067
3931
  }
4068
3932
  }
4069
- function checkAddresses(device, report, cidr, ip) {
3933
+ function checkAddresses2(device, report, cidr, ip) {
4070
3934
  const holderOf = new Map;
4071
- const holders2 = [
3935
+ const holders = [
4072
3936
  ...Object.entries(device.ports ?? {}).map(([name, settings]) => ({ where: `port ${name}`, settings })),
4073
3937
  ...Object.entries(device.interfaces ?? {}).map(([name, settings]) => ({ where: `interface ${name}`, settings }))
4074
3938
  ];
4075
- for (const { where, settings } of holders2) {
3939
+ for (const { where, settings } of holders) {
4076
3940
  for (const entry of settings?.addresses ?? []) {
4077
3941
  const address = typeof entry === "string" ? entry : entry.address;
4078
3942
  cidr(where, address);
@@ -4135,7 +3999,7 @@ function checkInterfaces(device, names, report, ip) {
4135
3999
  if (iface.type === "wireguard") {
4136
4000
  for (const peer of iface.peers ?? []) {
4137
4001
  for (const address of peer.allowed_addresses) {
4138
- if (!isCidr(address))
4002
+ if (!isCidr2(address))
4139
4003
  report(`interface ${name} peer ${peer.name}: '${address}' is not an address with a length`);
4140
4004
  }
4141
4005
  }
@@ -4144,7 +4008,7 @@ function checkInterfaces(device, names, report, ip) {
4144
4008
  }
4145
4009
  function checkRouting(device, names, report, cidr, ip) {
4146
4010
  const routing = device.routing;
4147
- const policies2 = device.policies ?? {};
4011
+ const policies = device.policies ?? {};
4148
4012
  for (const route of routing?.static ?? []) {
4149
4013
  cidr("static route", route.prefix);
4150
4014
  if (!route.via && !route.interface && !route.blackhole)
@@ -4155,7 +4019,7 @@ function checkRouting(device, names, report, cidr, ip) {
4155
4019
  names.expect(`static route ${route.prefix} VRF`, route.vrf, route.vrf !== undefined && route.vrf in (device.vrfs ?? {}));
4156
4020
  }
4157
4021
  const bgp = routing?.bgp;
4158
- const isPolicy = (policy) => policy !== undefined && (policy in policies2);
4022
+ const isPolicy = (policy) => policy !== undefined && (policy in policies);
4159
4023
  for (const prefix of bgp?.networks ?? [])
4160
4024
  cidr("BGP network", prefix);
4161
4025
  for (const [name, group] of Object.entries(bgp?.groups ?? {})) {
@@ -4173,7 +4037,7 @@ function checkRouting(device, names, report, cidr, ip) {
4173
4037
  names.expect(`neighbor ${name} policy`, policy, isPolicy(policy));
4174
4038
  }
4175
4039
  const local = neighbor.local_address ?? group?.local_address;
4176
- if (local && familyOf(local) !== familyOf(neighbor.address))
4040
+ if (local && familyOf2(local) !== familyOf2(neighbor.address))
4177
4041
  report(`neighbor ${name} local_address and address differ in family`);
4178
4042
  }
4179
4043
  for (const [areaId, area] of Object.entries(routing?.ospf?.areas ?? {})) {
@@ -4184,11 +4048,11 @@ function checkRouting(device, names, report, cidr, ip) {
4184
4048
  }
4185
4049
  }
4186
4050
  function checkPolicies(device, names, report, cidr) {
4187
- const policies2 = device.policies ?? {};
4051
+ const policies = device.policies ?? {};
4188
4052
  const sets = device.prefix_sets ?? {};
4189
- for (const [name, rules] of Object.entries(policies2)) {
4053
+ for (const [name, rules] of Object.entries(policies)) {
4190
4054
  for (const rule of rules) {
4191
- names.expect(`policy ${name} call`, rule.call, rule.call !== undefined && rule.call in policies2);
4055
+ names.expect(`policy ${name} call`, rule.call, rule.call !== undefined && rule.call in policies);
4192
4056
  const set = rule.match?.prefix_set;
4193
4057
  names.expect(`policy ${name} prefix set`, set, set !== undefined && set in sets);
4194
4058
  if (rule.match?.prefix)
@@ -4198,11 +4062,11 @@ function checkPolicies(device, names, report, cidr) {
4198
4062
  const visit = (policy, path) => {
4199
4063
  if (path.includes(policy))
4200
4064
  return report(`policy ${[...path, policy].join(" -> ")} calls itself`);
4201
- for (const rule of policies2[policy] ?? [])
4065
+ for (const rule of policies[policy] ?? [])
4202
4066
  if (rule.call)
4203
4067
  visit(rule.call, [...path, policy]);
4204
4068
  };
4205
- for (const policy of Object.keys(policies2))
4069
+ for (const policy of Object.keys(policies))
4206
4070
  visit(policy, []);
4207
4071
  }
4208
4072
  function checkFirewall(device, names, cidr) {
@@ -4210,7 +4074,7 @@ function checkFirewall(device, names, cidr) {
4210
4074
  const sets = firewall?.address_sets ?? {};
4211
4075
  for (const [name, members] of Object.entries(sets)) {
4212
4076
  for (const member of members) {
4213
- if (!isAddress(member))
4077
+ if (!isAddress2(member))
4214
4078
  cidr(`address set ${name}`, member);
4215
4079
  }
4216
4080
  }
@@ -4236,12 +4100,12 @@ function checkServices(device, names, report, cidr) {
4236
4100
  for (const [name, server] of Object.entries(device.dhcp ?? {})) {
4237
4101
  names.expectInterface(`DHCP ${name} interface`, server.interface);
4238
4102
  cidr(`DHCP ${name}`, server.network);
4239
- if (!isCidr(server.network))
4103
+ if (!isCidr2(server.network))
4240
4104
  continue;
4241
4105
  const reserved = (server.reservations ?? []).map((reservation) => reservation.address);
4242
- const addresses2 = [server.gateway, ...server.pool, ...reserved];
4243
- for (const address of addresses2) {
4244
- if (!contains(server.network, address))
4106
+ const addresses = [server.gateway, ...server.pool, ...reserved];
4107
+ for (const address of addresses) {
4108
+ if (!contains2(server.network, address))
4245
4109
  report(`DHCP ${name}: ${address} is outside ${server.network}`);
4246
4110
  }
4247
4111
  }
@@ -4263,8 +4127,8 @@ function checkServices(device, names, report, cidr) {
4263
4127
  report("SNMP is on with neither a community nor a user");
4264
4128
  }
4265
4129
  function checkCertificates(device, names, report) {
4266
- const certificates2 = device.certificates ?? {};
4267
- for (const [name, certificate] of Object.entries(certificates2)) {
4130
+ const certificates = device.certificates ?? {};
4131
+ for (const [name, certificate] of Object.entries(certificates)) {
4268
4132
  try {
4269
4133
  new X509Certificate2(certificate.certificate);
4270
4134
  } catch {
@@ -4273,54 +4137,12 @@ function checkCertificates(device, names, report) {
4273
4137
  }
4274
4138
  for (const service of ["https", "api_tls"]) {
4275
4139
  const name = device.management?.[service]?.certificate;
4276
- names.expect(`${service} certificate`, name, name !== undefined && name in certificates2);
4277
- if (name && certificates2[name] && !certificates2[name].private_key) {
4140
+ names.expect(`${service} certificate`, name, name !== undefined && name in certificates);
4141
+ if (name && certificates[name] && !certificates[name].private_key) {
4278
4142
  report(`${service} certificate ${name} has no private key to serve with`);
4279
4143
  }
4280
4144
  }
4281
4145
  }
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
4146
 
4325
4147
  // src/cli/graph.ts
4326
4148
  function markOf(graph, node) {
@@ -4336,7 +4158,7 @@ function markOf(graph, node) {
4336
4158
  }
4337
4159
  function mermaid(graph) {
4338
4160
  const ids = new Map(graph.nodes.map((node, index) => [node.id, `n${index}`]));
4339
- const text = (...lines2) => `"${lines2.map((line2) => line2.replace(/"/g, "#quot;").replace(/</g, "#lt;").replace(/>/g, "#gt;")).join("<br/>")}"`;
4161
+ const text = (...lines) => `"${lines.map((line) => line.replace(/"/g, "#quot;").replace(/</g, "#lt;").replace(/>/g, "#gt;")).join("<br/>")}"`;
4340
4162
  const shapes = {
4341
4163
  device: (label) => `[${label}]`,
4342
4164
  segment: (label) => `([${label}])`,
@@ -4362,7 +4184,7 @@ function mermaid(graph) {
4362
4184
  `;
4363
4185
  }
4364
4186
  function dot(graph) {
4365
- const text = (value2) => `"${value2.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
4187
+ const text = (value) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
4366
4188
  const colors = { error: "#d1242f", warning: "#bf8700" };
4367
4189
  const shapes = {
4368
4190
  device: "shape=box, style=rounded",
@@ -4392,6 +4214,116 @@ ${node.detail[0] ?? ""}` : node.label;
4392
4214
  `;
4393
4215
  }
4394
4216
 
4217
+ // src/cli/rename.ts
4218
+ import { existsSync as existsSync2, readFileSync as readFileSync3, renameSync, writeFileSync } from "node:fs";
4219
+ import { basename, dirname, extname, join as join3, resolve as resolve2 } from "node:path";
4220
+
4221
+ // src/core/files.ts
4222
+ import { readdirSync, readFileSync as readFileSync2 } from "node:fs";
4223
+ import { join as join2, relative } from "node:path";
4224
+ var SKIPPED = new Set(["node_modules", ".git", ".circuit", "dist"]);
4225
+ function projectSources(root, directory = root) {
4226
+ const entries = readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
4227
+ return entries.flatMap((entry) => {
4228
+ if (entry.isDirectory())
4229
+ return SKIPPED.has(entry.name) ? [] : projectSources(root, join2(directory, entry.name));
4230
+ return /\.(ts|mts|cts)$/.test(entry.name) ? [relative(root, join2(directory, entry.name))] : [];
4231
+ });
4232
+ }
4233
+ var FIRST_NAME = /^[\s\S]*?\bname\s*:\s*(["'`])(.*?)\1/;
4234
+ function declaresDevice(text, name) {
4235
+ return [...text.matchAll(/\bdefineDevice\s*\(/g)].some((call) => FIRST_NAME.exec(text.slice(call.index + call[0].length))?.[2] === name);
4236
+ }
4237
+ function deviceFiles(root, name, sources = projectSources(root)) {
4238
+ return sources.filter((file) => declaresDevice(readFileSync2(join2(root, file), "utf8"), name));
4239
+ }
4240
+
4241
+ // src/cli/rename.ts
4242
+ var escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4243
+ function planRename(root, from, to, options) {
4244
+ const changes = [];
4245
+ const moves = [];
4246
+ const exact = new RegExp(`(["'\`])${escape(from)}\\1`, "g");
4247
+ const anywhere = new RegExp(`(^|[^A-Za-z0-9_.-])${escape(from)}(?![A-Za-z0-9_-])`);
4248
+ const sources = projectSources(root);
4249
+ const declaring = new Set;
4250
+ for (const file of sources) {
4251
+ const text = readFileSync3(join3(root, file), "utf8");
4252
+ const lines = text.split(`
4253
+ `);
4254
+ if (declaresDevice(text, from))
4255
+ declaring.add(file);
4256
+ for (const [index, before] of lines.entries()) {
4257
+ const after = before.replace(exact, (_match, quote) => `${quote}${to}${quote}`);
4258
+ if (after !== before)
4259
+ changes.push({ file, line: index + 1, before, after });
4260
+ }
4261
+ }
4262
+ if (!declaring.size) {
4263
+ 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`);
4264
+ }
4265
+ for (const snapshot of options.snapshots) {
4266
+ moves.push({ from: snapshot, to: join3(dirname(snapshot), `${to}${extname(snapshot)}`) });
4267
+ }
4268
+ if (options.file) {
4269
+ if (declaring.size > 1)
4270
+ throw new Error(`${[...declaring].join(" and ")} both declare name: "${from}"; rename the file by hand`);
4271
+ const [file] = [...declaring];
4272
+ const target = join3(dirname(file), `${to}${extname(file)}`);
4273
+ if (existsSync2(join3(root, target)))
4274
+ throw new Error(`${target} already exists`);
4275
+ moves.push({ from: file, to: target });
4276
+ changes.push(...importChanges(root, sources, file, target, changes));
4277
+ }
4278
+ const mentions = sources.flatMap((file) => readFileSync3(join3(root, file), "utf8").split(`
4279
+ `).flatMap((original, index) => {
4280
+ const text = changes.find((change) => change.file === file && change.line === index + 1)?.after ?? original;
4281
+ return anywhere.test(text) ? [{ file, line: index + 1, text: text.trim() }] : [];
4282
+ }));
4283
+ return { changes, moves, mentions };
4284
+ }
4285
+ function importChanges(root, sources, file, target, earlier) {
4286
+ const result = [];
4287
+ const withoutExtension = (path) => path.replace(/\.(ts|mts|cts|js|mjs|cjs)$/, "");
4288
+ const moved = withoutExtension(resolve2(root, file));
4289
+ for (const source of sources) {
4290
+ const lines = readFileSync3(join3(root, source), "utf8").split(`
4291
+ `);
4292
+ for (const [index, original] of lines.entries()) {
4293
+ const line = index + 1;
4294
+ const changed = earlier.find((change) => change.file === source && change.line === line);
4295
+ const before = changed?.after ?? original;
4296
+ const after = before.replace(/(\bfrom\s+|\bimport\s*\(\s*)(["'])(\.{1,2}\/[^"']+)\2/g, (match, lead, quote, specifier) => {
4297
+ if (withoutExtension(resolve2(root, dirname(source), specifier)) !== moved)
4298
+ return match;
4299
+ const extension = specifier.match(/\.(ts|mts|cts|js|mjs|cjs)$/)?.[0] ?? "";
4300
+ const renamed = `${dirname(specifier)}/${basename(withoutExtension(target))}${extension}`;
4301
+ return `${lead}${quote}${renamed}${quote}`;
4302
+ });
4303
+ if (after === before)
4304
+ continue;
4305
+ if (changed)
4306
+ changed.after = after;
4307
+ else
4308
+ result.push({ file: source, line, before: original, after });
4309
+ }
4310
+ }
4311
+ return result;
4312
+ }
4313
+ function applyRename(root, plan) {
4314
+ const files = new Set(plan.changes.map((change) => change.file));
4315
+ for (const file of files) {
4316
+ const lines = readFileSync3(join3(root, file), "utf8").split(`
4317
+ `);
4318
+ for (const change of plan.changes.filter((each) => each.file === file))
4319
+ lines[change.line - 1] = change.after;
4320
+ writeFileSync(join3(root, file), lines.join(`
4321
+ `));
4322
+ }
4323
+ for (const move of plan.moves)
4324
+ renameSync(join3(root, move.from), join3(root, move.to));
4325
+ }
4326
+
4395
4327
  // src/cli/index.ts
4396
4328
  var HELP = `usage: circuit <command> [device...] [options]
4397
4329
 
@@ -4412,18 +4344,28 @@ var HELP = `usage: circuit <command> [device...] [options]
4412
4344
  prometheus every device with SNMP, as Prometheus targets for snmp_exporter
4413
4345
  --auths snmp_exporter's auths for them instead, with secrets as \${NAME}
4414
4346
  secrets every secret the config uses, and whether each can be read
4347
+ rename <device> <new-name>
4348
+ give a device a new name in every file that names it, with --confirm
4349
+ --file also rename the file that declares it, to <new-name>.ts
4350
+ addresses [prefix] every address the config uses, by prefix, and what is still free
4351
+ --length=N free prefixes of this length, instead of the fewest that cover the free space
4352
+ --format=json as JSON
4415
4353
  graph [device...] the network as a graph, from the config alone, without contacting a device
4416
4354
  --layer=l1 l1, l2, l3, bgp, ospf or tunnels, several with commas, or all
4417
4355
  --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
4356
+ studio Circuit Studio: the network in the browser, drawn again whenever the config changes
4357
+ --port=4600 the port it listens on
4420
4358
  --no-open do not open the browser
4359
+ --host=0.0.0.0 listen on a server's address instead, with --allow-host
4360
+ --allow-host=studio.example.com
4361
+ the names it is reached by, several with commas
4362
+ --json print what Studio shows, as JSON, instead
4421
4363
 
4422
4364
  --config <path> the network's config file, circuit.config.ts in the working directory by default`;
4423
4365
  var DEFAULT_CONFIG = "circuit.config.ts";
4424
4366
  var [command2, ...args] = process.argv.slice(2);
4425
4367
  if (command2 === "new") {
4426
- const { create } = await import("../chunks/new-7n8qvtjd.js");
4368
+ const { create } = await import("../chunks/new-ny89s7y7.js");
4427
4369
  await create(args);
4428
4370
  process.exit(0);
4429
4371
  }
@@ -4434,12 +4376,12 @@ if (!command2 || command2 === "help" || command2 === "--help" || command2 === "-
4434
4376
  var configAt = args.indexOf("--config");
4435
4377
  var configArgument = configAt >= 0 ? args[configAt + 1] : args.find((arg) => arg.startsWith("--config="))?.slice("--config=".length);
4436
4378
  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)) {
4379
+ var configPath = resolve3(process.cwd(), configArgument ?? DEFAULT_CONFIG);
4380
+ if (!existsSync3(configPath)) {
4439
4381
  console.error(configArgument ? `no config at ${configPath}` : `no ${DEFAULT_CONFIG} in ${process.cwd()}; name another with --config <path>`);
4440
4382
  process.exit(1);
4441
4383
  }
4442
- var root2 = dirname(configPath);
4384
+ var root2 = dirname2(configPath);
4443
4385
  useRoot(root2);
4444
4386
  async function load(path) {
4445
4387
  if (process.versions.bun)
@@ -4471,9 +4413,9 @@ function one() {
4471
4413
  return device ?? fail("name a device");
4472
4414
  }
4473
4415
  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);
4416
+ var generated = join4(root2, ".circuit");
4417
+ var stateDirectory = join4(generated, "state");
4418
+ var statePath = (device) => join4(stateDirectory, device.name + adapterOf(device).extension);
4477
4419
  var GENERATED_README = `# Written by Circuit
4478
4420
 
4479
4421
  Do not edit anything here: an edit changes nothing on a device, and Circuit overwrites it. Change the config instead.
@@ -4482,8 +4424,8 @@ Do not edit anything here: an edit changes nothing on a device, and Circuit over
4482
4424
  `;
4483
4425
  function writeState(device, text) {
4484
4426
  mkdirSync(stateDirectory, { recursive: true });
4485
- writeFileSync(join2(generated, "README.md"), GENERATED_README);
4486
- writeFileSync(statePath(device), text);
4427
+ writeFileSync2(join4(generated, "README.md"), GENERATED_README);
4428
+ writeFileSync2(statePath(device), text);
4487
4429
  }
4488
4430
  function guard(devices) {
4489
4431
  const relevant = new Set(devices.map((device) => device.name));
@@ -4498,18 +4440,18 @@ function guard(devices) {
4498
4440
  fail(`
4499
4441
  ${errors} error(s).`);
4500
4442
  }
4501
- function show(device, plan4) {
4502
- const commands = plan4.steps.reduce((count, step) => count + step.send.length, 0);
4503
- const summary = commands ? `${commands} command(s) in ${plan4.steps.length} step(s)` : "matches";
4443
+ function show(device, plan) {
4444
+ const commands = plan.steps.reduce((count, step) => count + step.send.length, 0);
4445
+ const summary = commands ? `${commands} command(s) in ${plan.steps.length} step(s)` : "matches";
4504
4446
  console.log(`${device.name}: ${summary}`);
4505
- for (const step of plan4.steps) {
4447
+ for (const step of plan.steps) {
4506
4448
  console.log(` ${step.title}`);
4507
- for (const line2 of step.show)
4508
- console.log(` ${line2.slice(0, 220)}`);
4449
+ for (const line of step.show)
4450
+ console.log(` ${line.slice(0, 220)}`);
4509
4451
  }
4510
- for (const problem of plan4.problems)
4452
+ for (const problem of plan.problems)
4511
4453
  console.log(` PROBLEM ${problem}`);
4512
- return commands + plan4.problems.length;
4454
+ return commands + plan.problems.length;
4513
4455
  }
4514
4456
  function validateCommand() {
4515
4457
  guard([...network2.devices]);
@@ -4525,13 +4467,13 @@ function diffCommand() {
4525
4467
  let differences = 0;
4526
4468
  for (const device of devices) {
4527
4469
  const path = statePath(device);
4528
- if (!existsSync2(path)) {
4470
+ if (!existsSync3(path)) {
4529
4471
  console.log(`${device.name}: no snapshot, run snapshot first`);
4530
4472
  continue;
4531
4473
  }
4532
4474
  const adapter = adapterOf(device);
4533
- const plan4 = adapter.plan(adapter.render(device), readFileSync2(path, "utf8"), options);
4534
- differences += show(device, plan4);
4475
+ const plan = adapter.plan(adapter.render(device), readFileSync4(path, "utf8"), options);
4476
+ differences += show(device, plan);
4535
4477
  }
4536
4478
  process.exit(differences ? 1 : 0);
4537
4479
  }
@@ -4542,13 +4484,13 @@ async function snapshotCommand() {
4542
4484
  console.log(`${device.name}: ${text.split(`
4543
4485
  `).length} lines`);
4544
4486
  }
4545
- if (names.length || !existsSync2(stateDirectory))
4487
+ if (names.length || !existsSync3(stateDirectory))
4546
4488
  return;
4547
- const current = new Set(network2.devices.map((device) => basename(statePath(device))));
4548
- for (const file of readdirSync(stateDirectory)) {
4489
+ const current = new Set(network2.devices.map((device) => basename2(statePath(device))));
4490
+ for (const file of readdirSync2(stateDirectory)) {
4549
4491
  if (current.has(file))
4550
4492
  continue;
4551
- rmSync(join2(stateDirectory, file));
4493
+ rmSync(join4(stateDirectory, file));
4552
4494
  console.log(`${file}: removed, no such device in the config`);
4553
4495
  }
4554
4496
  }
@@ -4558,10 +4500,10 @@ async function applyCommand() {
4558
4500
  const adapter = adapterOf(device);
4559
4501
  const live = await adapter.read(device, network2.devices);
4560
4502
  writeState(device, live);
4561
- const plan4 = adapter.plan(adapter.render(device), live, options);
4562
- if (!show(device, plan4))
4503
+ const plan = adapter.plan(adapter.render(device), live, options);
4504
+ if (!show(device, plan))
4563
4505
  return;
4564
- if (plan4.problems.length)
4506
+ if (plan.problems.length)
4565
4507
  fail(`
4566
4508
  Not sent: resolve the problems first.`);
4567
4509
  if (!confirmed) {
@@ -4569,13 +4511,13 @@ Not sent: resolve the problems first.`);
4569
4511
  Nothing sent. Re-run with --confirm.`);
4570
4512
  return;
4571
4513
  }
4572
- await adapter.apply(device, network2.devices, plan4, options);
4514
+ await adapter.apply(device, network2.devices, plan, options);
4573
4515
  writeState(device, await adapter.read(device, network2.devices));
4574
4516
  console.log("snapshot updated");
4575
4517
  }
4576
4518
  async function refreshCommand() {
4577
4519
  const device = one();
4578
- const fill2 = adapterOf(device).fill ?? fail(`${device.platform} cannot hold a fetched prefix set`);
4520
+ const fill = adapterOf(device).fill ?? fail(`${device.platform} cannot hold a fetched prefix set`);
4579
4521
  for (const [name, set] of Object.entries(device.prefix_sets ?? {})) {
4580
4522
  if (!("source" in set))
4581
4523
  continue;
@@ -4586,7 +4528,7 @@ async function refreshCommand() {
4586
4528
  }
4587
4529
  console.log(`${name}: ${prefixes.length} prefixes from ${registry} ${query}`);
4588
4530
  if (confirmed)
4589
- await fill2(device, network2.devices, name, family, prefixes);
4531
+ await fill(device, network2.devices, name, family, prefixes);
4590
4532
  }
4591
4533
  if (!confirmed)
4592
4534
  console.log(`
@@ -4634,13 +4576,13 @@ function communitiesCommand() {
4634
4576
  }
4635
4577
  }
4636
4578
  async function peeringdbCommand() {
4637
- const plan4 = await planPeeringDb(network2);
4638
- for (const name of plan4.unknown) {
4579
+ const plan = await planPeeringDb(network2);
4580
+ for (const name of plan.unknown) {
4639
4581
  console.log(` PeeringDB lists ${name}, which the config does not. Remove it by hand if the network left.`);
4640
4582
  }
4641
- for (const change of plan4.changes)
4583
+ for (const change of plan.changes)
4642
4584
  console.log(` ${change.summary}`);
4643
- if (!plan4.changes.length) {
4585
+ if (!plan.changes.length) {
4644
4586
  console.log("PeeringDB matches the config.");
4645
4587
  return;
4646
4588
  }
@@ -4649,17 +4591,17 @@ async function peeringdbCommand() {
4649
4591
  Nothing sent. Re-run with --confirm.`);
4650
4592
  return;
4651
4593
  }
4652
- await sendPeeringDb(network2, plan4.changes);
4594
+ await sendPeeringDb(network2, plan.changes);
4653
4595
  }
4654
4596
  async function librenmsCommand() {
4655
- const plan4 = await planLibreNms(network2);
4656
- for (const hostname of plan4.unknown)
4597
+ const plan = await planLibreNms(network2);
4598
+ for (const hostname of plan.unknown)
4657
4599
  console.log(` LibreNMS monitors ${hostname}, which the config does not. Remove it by hand if it is gone.`);
4658
- for (const device of plan4.withoutSnmp)
4600
+ for (const device of plan.withoutSnmp)
4659
4601
  console.log(` ${device.name} has no SNMP in its config, so LibreNMS cannot poll it. Add management.snmp first.`);
4660
- for (const device of plan4.additions)
4602
+ for (const device of plan.additions)
4661
4603
  console.log(` add ${device.name}, ${device.connection.host}`);
4662
- if (!plan4.additions.length) {
4604
+ if (!plan.additions.length) {
4663
4605
  console.log("LibreNMS monitors every device it can.");
4664
4606
  return;
4665
4607
  }
@@ -4668,7 +4610,7 @@ async function librenmsCommand() {
4668
4610
  Nothing sent. Re-run with --confirm.`);
4669
4611
  return;
4670
4612
  }
4671
- await sendLibreNms(network2, plan4.additions);
4613
+ await sendLibreNms(network2, plan.additions);
4672
4614
  }
4673
4615
  function prometheusCommand() {
4674
4616
  if (flags.includes("--auths")) {
@@ -4683,14 +4625,14 @@ function prometheusCommand() {
4683
4625
  console.error(`${device.name} has no SNMP in its config; left out`);
4684
4626
  console.log(JSON.stringify(targets, null, 4));
4685
4627
  }
4686
- function secretsIn(value2, found = new Set) {
4687
- if (isSecret(value2))
4688
- found.add(referenceOf(value2));
4689
- else if (Array.isArray(value2))
4690
- for (const item of value2)
4628
+ function secretsIn(value, found = new Set) {
4629
+ if (isSecret(value))
4630
+ found.add(referenceOf(value));
4631
+ else if (Array.isArray(value))
4632
+ for (const item of value)
4691
4633
  secretsIn(item, found);
4692
- else if (typeof value2 === "object" && value2 !== null)
4693
- for (const item of Object.values(value2))
4634
+ else if (typeof value === "object" && value !== null)
4635
+ for (const item of Object.values(value))
4694
4636
  secretsIn(item, found);
4695
4637
  return found;
4696
4638
  }
@@ -4714,20 +4656,29 @@ ${references.length} secret(s), ${missing} missing.`);
4714
4656
  var flagValue = (name) => flags.find((flag) => flag.startsWith(`--${name}=`))?.slice(name.length + 3);
4715
4657
  function driftOf(device) {
4716
4658
  const path = statePath(device);
4717
- if (!existsSync2(path))
4659
+ if (!existsSync3(path))
4718
4660
  return { status: "no snapshot", lines: [] };
4719
4661
  try {
4720
4662
  const adapter = adapterOf(device);
4721
- const plan4 = adapter.plan(adapter.render(device), readFileSync2(path, "utf8"), options);
4663
+ const plan = adapter.plan(adapter.render(device), readFileSync4(path, "utf8"), options);
4722
4664
  const lines = [
4723
- ...plan4.steps.flatMap((step) => [step.title, ...step.show.map((line2) => ` ${line2}`)]),
4724
- ...plan4.problems.map((problem) => `PROBLEM ${problem}`)
4665
+ ...plan.steps.flatMap((step) => [step.title, ...step.show.map((line) => ` ${line}`)]),
4666
+ ...plan.problems.map((problem) => `PROBLEM ${problem}`)
4725
4667
  ];
4726
4668
  return { status: lines.length ? "differs" : "matches", lines };
4727
4669
  } catch (error) {
4728
4670
  return { status: "cannot plan", lines: [error.message] };
4729
4671
  }
4730
4672
  }
4673
+ function annotatedGraph(layers, deviceNames) {
4674
+ const graph = only(graphOf(network2.devices), layers, deviceNames);
4675
+ const shown = new Set(graph.nodes.map((node) => node.label));
4676
+ return {
4677
+ ...graph,
4678
+ findings: validate(network2).filter((finding) => !finding.device || shown.has(finding.device)),
4679
+ drift: Object.fromEntries(network2.devices.filter((device) => shown.has(device.name)).map((device) => [device.name, driftOf(device)]))
4680
+ };
4681
+ }
4731
4682
  function graphCommand() {
4732
4683
  const requested = (flagValue("layer") ?? "l1").split(",");
4733
4684
  const layers = requested.includes("all") ? [...LAYERS] : requested;
@@ -4736,13 +4687,7 @@ function graphCommand() {
4736
4687
  fail(`no layer ${unknown.join(", ")}; known: ${LAYERS.join(", ")}, all`);
4737
4688
  const format = flagValue("format") ?? "mermaid";
4738
4689
  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
- };
4690
+ const annotated = annotatedGraph(layers, names.length ? devices.map((device) => device.name) : []);
4746
4691
  if (format === "mermaid")
4747
4692
  process.stdout.write(mermaid(annotated));
4748
4693
  else if (format === "dot")
@@ -4752,12 +4697,117 @@ function graphCommand() {
4752
4697
  else
4753
4698
  fail(`no format ${format}; known: mermaid, dot, json`);
4754
4699
  }
4700
+ function printSegment(segment, indent) {
4701
+ const next = segment.next ? `, next ${segment.next}` : "";
4702
+ console.log(`${indent}${segment.prefix}, ${segment.free} free${next}`);
4703
+ const width = Math.max(...segment.uses.map((use) => use.address.length)) + 2;
4704
+ for (const use of segment.uses)
4705
+ console.log(`${indent} ${use.address.padEnd(width)}${use.what}`);
4706
+ }
4707
+ function addressesCommand() {
4708
+ const [within] = names;
4709
+ if (within && !isCidr2(within))
4710
+ fail(`${within} is not a prefix, such as 10.0.0.0/16`);
4711
+ const lengthText = flagValue("length");
4712
+ const length = lengthText === undefined ? undefined : Number(lengthText);
4713
+ if (length !== undefined && !(Number.isInteger(length) && length >= 0 && length <= 128))
4714
+ fail("--length takes a prefix length, such as 24");
4715
+ const report = addressesOf(network2.devices, within);
4716
+ if (flagValue("format") === "json") {
4717
+ console.log(JSON.stringify(report, null, 4));
4718
+ return;
4719
+ }
4720
+ for (const space of report.spaces) {
4721
+ console.log(`${space.prefix}, ${space.owner}`);
4722
+ for (const segment of space.segments)
4723
+ printSegment(segment, " ");
4724
+ for (const block of space.blocks)
4725
+ console.log(` ${block.prefix} ${block.what}`);
4726
+ if (length === undefined) {
4727
+ console.log(` free: ${space.free.join(", ") || "none"}`);
4728
+ } else {
4729
+ const { prefixes, total } = freeOfLength(space.free, length, 16);
4730
+ const more = total > BigInt(prefixes.length) ? `, and ${total - BigInt(prefixes.length)} more` : "";
4731
+ console.log(` free /${length}: ${prefixes.join(", ") || "none"}${more}`);
4732
+ }
4733
+ console.log("");
4734
+ }
4735
+ const { segments, blocks } = report.elsewhere;
4736
+ if (segments.length || blocks.length) {
4737
+ console.log("Outside the space the network originates");
4738
+ for (const segment of segments)
4739
+ printSegment(segment, " ");
4740
+ for (const block of blocks)
4741
+ console.log(` ${block.prefix} ${block.what}`);
4742
+ }
4743
+ if (!report.spaces.length && !segments.length && !blocks.length)
4744
+ console.log("The config holds no addresses.");
4745
+ }
4746
+ function renameCommand() {
4747
+ const [from, to] = names;
4748
+ if (!from || !to)
4749
+ fail("usage: circuit rename <device> <new-name> [--file] [--confirm]");
4750
+ const device = network2.devices.find((candidate) => candidate.name === from);
4751
+ if (!device)
4752
+ fail(`no device ${from}; known: ${network2.devices.map((candidate) => candidate.name).join(", ")}`);
4753
+ if (network2.devices.some((candidate) => candidate.name === to))
4754
+ fail(`${to} is already a device`);
4755
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(to))
4756
+ fail(`${to} is not a hostname: letters, digits, dots, dashes and underscores`);
4757
+ const snapshot = statePath(device);
4758
+ const snapshots = existsSync3(snapshot) ? [relative2(root2, snapshot)] : [];
4759
+ let plan;
4760
+ try {
4761
+ plan = planRename(root2, from, to, { file: flags.includes("--file"), snapshots });
4762
+ } catch (error) {
4763
+ fail(error.message);
4764
+ }
4765
+ for (const change of plan.changes) {
4766
+ console.log(`${change.file}:${change.line}`);
4767
+ console.log(` - ${change.before.trim()}`);
4768
+ console.log(` + ${change.after.trim()}`);
4769
+ }
4770
+ for (const move of plan.moves)
4771
+ console.log(`move ${move.from} to ${move.to}`);
4772
+ if (plan.mentions.length) {
4773
+ console.log(`
4774
+ Still naming ${from}, left as they are: change them by hand if they mean the device.`);
4775
+ for (const mention of plan.mentions)
4776
+ console.log(` ${mention.file}:${mention.line} ${mention.text.slice(0, 160)}`);
4777
+ }
4778
+ if (!confirmed) {
4779
+ console.log(`
4780
+ Nothing written. Re-run with --confirm.`);
4781
+ return;
4782
+ }
4783
+ applyRename(root2, plan);
4784
+ console.log(`
4785
+ Renamed. Run circuit validate, then circuit apply ${to}: the device keeps its old hostname until then.`);
4786
+ }
4755
4787
  async function studioCommand() {
4788
+ if (flags.includes("--json")) {
4789
+ const sources = projectSources(root2);
4790
+ const data = studioData(network2.devices, annotatedGraph(LAYERS, []), {
4791
+ render: (device) => adapterOf(device).render(device),
4792
+ snapshot: (device) => existsSync3(statePath(device)) ? readFileSync4(statePath(device), "utf8") : undefined,
4793
+ files: (device) => ({
4794
+ source: deviceFiles(root2, device.name, sources)[0]?.split(sep).join("/"),
4795
+ snapshot: existsSync3(statePath(device)) ? relative2(root2, statePath(device)).split(sep).join("/") : undefined
4796
+ })
4797
+ });
4798
+ console.log(JSON.stringify(data));
4799
+ return;
4800
+ }
4756
4801
  const port = Number(flagValue("port") ?? 4600);
4757
4802
  if (!Number.isInteger(port) || port < 1 || port > 65535)
4758
4803
  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") });
4804
+ const { isLoopback, studio } = await import("../chunks/server-zapfzhv4.js");
4805
+ const host = flagValue("host") ?? "127.0.0.1";
4806
+ const allowHosts = (flagValue("allow-host") ?? "").split(",").filter(Boolean);
4807
+ if (!isLoopback(host) && !allowHosts.length) {
4808
+ fail(`Studio on ${host} needs --allow-host=<name>, the name people reach it by, such as studio.example.com`);
4809
+ }
4810
+ await studio({ configPath, root: root2, port, open: !flags.includes("--no-open"), host, allowHosts });
4761
4811
  }
4762
4812
  var commands = {
4763
4813
  validate: validateCommand,
@@ -4772,6 +4822,8 @@ var commands = {
4772
4822
  librenms: librenmsCommand,
4773
4823
  prometheus: prometheusCommand,
4774
4824
  secrets: secretsCommand,
4825
+ rename: renameCommand,
4826
+ addresses: addressesCommand,
4775
4827
  graph: graphCommand,
4776
4828
  studio: studioCommand
4777
4829
  };