@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/src/cli/index.ts CHANGED
@@ -3,20 +3,24 @@
3
3
 
4
4
  import { createPrivateKey, createPublicKey } from "node:crypto";
5
5
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
- import { basename, dirname, join, resolve } from "node:path";
6
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
7
7
  import { adapters } from "../adapters/devices";
8
8
  import type { ApplyOptions, DeviceAdapter, Plan } from "../adapters/devices/types";
9
9
  import { registries } from "../adapters/registries";
10
10
  import { planLibreNms, sendLibreNms } from "../adapters/registries/librenms";
11
11
  import { planPeeringDb, sendPeeringDb } from "../adapters/registries/peeringdb";
12
12
  import { prometheusTargets, snmpExporterAuths } from "../adapters/registries/prometheus";
13
- import { network as prefixOf } from "../core/addr";
13
+ import { isCidr, network as prefixOf } from "../core/addr";
14
+ import { addressesOf, freeOfLength, type Segment } from "../core/addresses";
14
15
  import type { Network } from "../core/define";
15
- import { graphOf, LAYERS, only, type Layer } from "../core/graph";
16
+ import { type Annotated, type Drift, graphOf, LAYERS, only, type Layer } from "../core/graph";
16
17
  import { isSecret, referenceOf, resolve as reveal, sourceOf, useRoot } from "../core/secrets";
17
18
  import { validate } from "../core/validate";
18
19
  import type { Device } from "../schema";
19
- import { type Annotated, dot, type Drift, mermaid } from "./graph";
20
+ import { dot, mermaid } from "./graph";
21
+ import { applyRename, planRename, type RenamePlan } from "./rename";
22
+ import { deviceFiles, projectSources } from "../core/files";
23
+ import { studioData } from "../studio/data";
20
24
 
21
25
  const HELP = `usage: circuit <command> [device...] [options]
22
26
 
@@ -37,12 +41,22 @@ const HELP = `usage: circuit <command> [device...] [options]
37
41
  prometheus every device with SNMP, as Prometheus targets for snmp_exporter
38
42
  --auths snmp_exporter's auths for them instead, with secrets as \${NAME}
39
43
  secrets every secret the config uses, and whether each can be read
44
+ rename <device> <new-name>
45
+ give a device a new name in every file that names it, with --confirm
46
+ --file also rename the file that declares it, to <new-name>.ts
47
+ addresses [prefix] every address the config uses, by prefix, and what is still free
48
+ --length=N free prefixes of this length, instead of the fewest that cover the free space
49
+ --format=json as JSON
40
50
  graph [device...] the network as a graph, from the config alone, without contacting a device
41
51
  --layer=l1 l1, l2, l3, bgp, ospf or tunnels, several with commas, or all
42
52
  --format=mermaid mermaid, dot or json
43
- studio the graph in the browser, drawn again whenever the config changes
44
- --port=4600 the port it listens on, on this machine only
53
+ studio Circuit Studio: the network in the browser, drawn again whenever the config changes
54
+ --port=4600 the port it listens on
45
55
  --no-open do not open the browser
56
+ --host=0.0.0.0 listen on a server's address instead, with --allow-host
57
+ --allow-host=studio.example.com
58
+ the names it is reached by, several with commas
59
+ --json print what Studio shows, as JSON, instead
46
60
 
47
61
  --config <path> the network's config file, circuit.config.ts in the working directory by default`;
48
62
 
@@ -426,6 +440,20 @@ function driftOf(device: Device): Drift {
426
440
  }
427
441
  }
428
442
 
443
+ /** The graph on these layers, around these devices when any are named, with what validate found and how each device drawn compares with its snapshot. */
444
+ function annotatedGraph(layers: readonly Layer[], deviceNames: readonly string[]): Annotated {
445
+ const graph = only(graphOf(network.devices), layers, deviceNames);
446
+ const shown = new Set(graph.nodes.map((node) => node.label));
447
+
448
+ return {
449
+ ...graph,
450
+ findings: validate(network).filter((finding) => !finding.device || shown.has(finding.device)),
451
+ drift: Object.fromEntries(
452
+ network.devices.filter((device) => shown.has(device.name)).map((device) => [device.name, driftOf(device)])
453
+ ),
454
+ };
455
+ }
456
+
429
457
  function graphCommand(): void {
430
458
  const requested = (flagValue("layer") ?? "l1").split(",");
431
459
  const layers = requested.includes("all") ? [...LAYERS] : (requested as Layer[]);
@@ -435,18 +463,7 @@ function graphCommand(): void {
435
463
 
436
464
  const format = flagValue("format") ?? "mermaid";
437
465
  const devices = selected(true);
438
-
439
- const graph = only(graphOf(network.devices), layers, names.length ? devices.map((device) => device.name) : []);
440
- const shown = new Set(graph.nodes.map((node) => node.label));
441
-
442
- // Each device drawn is compared with its snapshot; findings are kept for those, and for the network as a whole.
443
- const annotated: Annotated = {
444
- ...graph,
445
- findings: validate(network).filter((finding) => !finding.device || shown.has(finding.device)),
446
- drift: Object.fromEntries(
447
- network.devices.filter((device) => shown.has(device.name)).map((device) => [device.name, driftOf(device)])
448
- ),
449
- };
466
+ const annotated = annotatedGraph(layers, names.length ? devices.map((device) => device.name) : []);
450
467
 
451
468
  if (format === "mermaid") process.stdout.write(mermaid(annotated));
452
469
  else if (format === "dot") process.stdout.write(dot(annotated));
@@ -454,12 +471,127 @@ function graphCommand(): void {
454
471
  else fail(`no format ${format}; known: mermaid, dot, json`);
455
472
  }
456
473
 
474
+ /** One segment and what uses each address in it. */
475
+ function printSegment(segment: Segment, indent: string): void {
476
+ const next = segment.next ? `, next ${segment.next}` : "";
477
+ console.log(`${indent}${segment.prefix}, ${segment.free} free${next}`);
478
+
479
+ const width = Math.max(...segment.uses.map((use) => use.address.length)) + 2;
480
+ for (const use of segment.uses) console.log(`${indent} ${use.address.padEnd(width)}${use.what}`);
481
+ }
482
+
483
+ function addressesCommand(): void {
484
+ const [within] = names;
485
+ if (within && !isCidr(within)) fail(`${within} is not a prefix, such as 10.0.0.0/16`);
486
+
487
+ const lengthText = flagValue("length");
488
+ const length = lengthText === undefined ? undefined : Number(lengthText);
489
+ if (length !== undefined && !(Number.isInteger(length) && length >= 0 && length <= 128))
490
+ fail("--length takes a prefix length, such as 24");
491
+
492
+ const report = addressesOf(network.devices, within);
493
+
494
+ if (flagValue("format") === "json") {
495
+ console.log(JSON.stringify(report, null, 4));
496
+ return;
497
+ }
498
+
499
+ for (const space of report.spaces) {
500
+ console.log(`${space.prefix}, ${space.owner}`);
501
+ for (const segment of space.segments) printSegment(segment, " ");
502
+ for (const block of space.blocks) console.log(` ${block.prefix} ${block.what}`);
503
+
504
+ if (length === undefined) {
505
+ console.log(` free: ${space.free.join(", ") || "none"}`);
506
+ } else {
507
+ const { prefixes, total } = freeOfLength(space.free, length, 16);
508
+ const more = total > BigInt(prefixes.length) ? `, and ${total - BigInt(prefixes.length)} more` : "";
509
+ console.log(` free /${length}: ${prefixes.join(", ") || "none"}${more}`);
510
+ }
511
+
512
+ console.log("");
513
+ }
514
+
515
+ const { segments, blocks } = report.elsewhere;
516
+ if (segments.length || blocks.length) {
517
+ console.log("Outside the space the network originates");
518
+ for (const segment of segments) printSegment(segment, " ");
519
+ for (const block of blocks) console.log(` ${block.prefix} ${block.what}`);
520
+ }
521
+
522
+ if (!report.spaces.length && !segments.length && !blocks.length) console.log("The config holds no addresses.");
523
+ }
524
+
525
+ function renameCommand(): void {
526
+ const [from, to] = names;
527
+ if (!from || !to) fail("usage: circuit rename <device> <new-name> [--file] [--confirm]");
528
+
529
+ const device = network.devices.find((candidate) => candidate.name === from);
530
+ if (!device) fail(`no device ${from}; known: ${network.devices.map((candidate) => candidate.name).join(", ")}`);
531
+ if (network.devices.some((candidate) => candidate.name === to)) fail(`${to} is already a device`);
532
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(to)) fail(`${to} is not a hostname: letters, digits, dots, dashes and underscores`);
533
+
534
+ const snapshot = statePath(device);
535
+ const snapshots = existsSync(snapshot) ? [relative(root, snapshot)] : [];
536
+
537
+ let plan: RenamePlan;
538
+ try {
539
+ plan = planRename(root, from, to, { file: flags.includes("--file"), snapshots });
540
+ } catch (error) {
541
+ fail((error as Error).message);
542
+ }
543
+
544
+ for (const change of plan.changes) {
545
+ console.log(`${change.file}:${change.line}`);
546
+ console.log(` - ${change.before.trim()}`);
547
+ console.log(` + ${change.after.trim()}`);
548
+ }
549
+
550
+ for (const move of plan.moves) console.log(`move ${move.from} to ${move.to}`);
551
+
552
+ if (plan.mentions.length) {
553
+ console.log(`\nStill naming ${from}, left as they are: change them by hand if they mean the device.`);
554
+ for (const mention of plan.mentions) console.log(` ${mention.file}:${mention.line} ${mention.text.slice(0, 160)}`);
555
+ }
556
+
557
+ if (!confirmed) {
558
+ console.log("\nNothing written. Re-run with --confirm.");
559
+ return;
560
+ }
561
+
562
+ applyRename(root, plan);
563
+ console.log(`\nRenamed. Run circuit validate, then circuit apply ${to}: the device keeps its old hostname until then.`);
564
+ }
565
+
457
566
  async function studioCommand(): Promise<void> {
567
+ if (flags.includes("--json")) {
568
+ const sources = projectSources(root);
569
+ const data = studioData(network.devices, annotatedGraph(LAYERS, []), {
570
+ render: (device) => adapterOf(device).render(device),
571
+ snapshot: (device) => (existsSync(statePath(device)) ? readFileSync(statePath(device), "utf8") : undefined),
572
+ files: (device) => ({
573
+ // Written with forward slashes on every system, as git and the page take them.
574
+ source: deviceFiles(root, device.name, sources)[0]?.split(sep).join("/"),
575
+ snapshot: existsSync(statePath(device)) ? relative(root, statePath(device)).split(sep).join("/") : undefined,
576
+ }),
577
+ });
578
+ console.log(JSON.stringify(data));
579
+ return;
580
+ }
581
+
458
582
  const port = Number(flagValue("port") ?? 4600);
459
583
  if (!Number.isInteger(port) || port < 1 || port > 65535) fail("--port takes a number from 1 to 65535");
460
584
 
461
- const { studio } = await import("./studio");
462
- await studio({ configPath, root, port, open: !flags.includes("--no-open") });
585
+ const { isLoopback, studio } = await import("../studio/server");
586
+ const host = flagValue("host") ?? "127.0.0.1";
587
+ const allowHosts = (flagValue("allow-host") ?? "").split(",").filter(Boolean);
588
+
589
+ // Off this machine, Studio must be told the names it is reached by: every other name is refused.
590
+ if (!isLoopback(host) && !allowHosts.length) {
591
+ fail(`Studio on ${host} needs --allow-host=<name>, the name people reach it by, such as studio.example.com`);
592
+ }
593
+
594
+ await studio({ configPath, root, port, open: !flags.includes("--no-open"), host, allowHosts });
463
595
  }
464
596
 
465
597
  const commands: Record<string, () => void | Promise<void>> = {
@@ -475,6 +607,8 @@ const commands: Record<string, () => void | Promise<void>> = {
475
607
  librenms: librenmsCommand,
476
608
  prometheus: prometheusCommand,
477
609
  secrets: secretsCommand,
610
+ rename: renameCommand,
611
+ addresses: addressesCommand,
478
612
  graph: graphCommand,
479
613
  studio: studioCommand,
480
614
  };
@@ -0,0 +1,129 @@
1
+ // `circuit rename`: a device's new name everywhere the project's files name it, its snapshot renamed to match, and with `--file` its file too. It plans first and writes only with --confirm.
2
+
3
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
4
+ import { basename, dirname, extname, join, resolve } from "node:path";
5
+ import { declaresDevice, projectSources } from "../core/files";
6
+
7
+ /** One line that changes. */
8
+ export type LineChange = { file: string; line: number; before: string; after: string };
9
+
10
+ export type RenamePlan = {
11
+ changes: LineChange[];
12
+ /** Files that move, from and to, relative to the project. */
13
+ moves: { from: string; to: string }[];
14
+ /** Lines that still name the device inside a longer text, such as a description or a comment, which the rename leaves alone. */
15
+ mentions: { file: string; line: number; text: string }[];
16
+ };
17
+
18
+ const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
19
+
20
+ /**
21
+ * Plan renaming a device from `from` to `to` in the project at `root`.
22
+ *
23
+ * A string that is exactly the old name becomes the new name: `name: "edge-01"`, `link: { device: "edge-01", ... }`, a check's list of routers, and a key such as a tenant's ports by device, which would otherwise no longer be found. A BGP neighbor named after the device is renamed too, which renames its session on the device: `circuit diff` shows it.
24
+ */
25
+ export function planRename(root: string, from: string, to: string, options: { file: boolean; snapshots: string[] }): RenamePlan {
26
+ const changes: LineChange[] = [];
27
+ const moves: RenamePlan["moves"] = [];
28
+
29
+ const exact = new RegExp(`(["'\`])${escape(from)}\\1`, "g");
30
+ const anywhere = new RegExp(`(^|[^A-Za-z0-9_.-])${escape(from)}(?![A-Za-z0-9_-])`);
31
+
32
+ const sources = projectSources(root);
33
+ const declaring = new Set<string>();
34
+
35
+ for (const file of sources) {
36
+ const text = readFileSync(join(root, file), "utf8");
37
+ const lines = text.split("\n");
38
+
39
+ // The device's own file calls defineDevice and gives it the name.
40
+ if (declaresDevice(text, from)) declaring.add(file);
41
+
42
+ for (const [index, before] of lines.entries()) {
43
+ const after = before.replace(exact, (_match, quote: string) => `${quote}${to}${quote}`);
44
+ if (after !== before) changes.push({ file, line: index + 1, before, after });
45
+ }
46
+ }
47
+
48
+ if (!declaring.size) {
49
+ throw new Error(
50
+ `no file declares name: "${from}"; the name may be built from parts, such as a template string, so rename it by hand`
51
+ );
52
+ }
53
+
54
+ for (const snapshot of options.snapshots) {
55
+ moves.push({ from: snapshot, to: join(dirname(snapshot), `${to}${extname(snapshot)}`) });
56
+ }
57
+
58
+ if (options.file) {
59
+ if (declaring.size > 1) throw new Error(`${[...declaring].join(" and ")} both declare name: "${from}"; rename the file by hand`);
60
+
61
+ const [file] = [...declaring] as [string];
62
+ const target = join(dirname(file), `${to}${extname(file)}`);
63
+ if (existsSync(join(root, target))) throw new Error(`${target} already exists`);
64
+
65
+ moves.push({ from: file, to: target });
66
+ changes.push(...importChanges(root, sources, file, target, changes));
67
+ }
68
+
69
+ // What still names the device once every change is made.
70
+ const mentions = sources.flatMap((file) =>
71
+ readFileSync(join(root, file), "utf8")
72
+ .split("\n")
73
+ .flatMap((original, index) => {
74
+ const text = changes.find((change) => change.file === file && change.line === index + 1)?.after ?? original;
75
+ return anywhere.test(text) ? [{ file, line: index + 1, text: text.trim() }] : [];
76
+ })
77
+ );
78
+
79
+ return { changes, moves, mentions };
80
+ }
81
+
82
+ /** Imports of a file that moves, pointed at its new name. Lines already changed are changed further. */
83
+ function importChanges(root: string, sources: string[], file: string, target: string, earlier: LineChange[]): LineChange[] {
84
+ const result: LineChange[] = [];
85
+ const withoutExtension = (path: string) => path.replace(/\.(ts|mts|cts|js|mjs|cjs)$/, "");
86
+ const moved = withoutExtension(resolve(root, file));
87
+
88
+ for (const source of sources) {
89
+ const lines = readFileSync(join(root, source), "utf8").split("\n");
90
+
91
+ for (const [index, original] of lines.entries()) {
92
+ const line = index + 1;
93
+ const changed = earlier.find((change) => change.file === source && change.line === line);
94
+ const before = changed?.after ?? original;
95
+
96
+ // A relative specifier that resolves to the moved file keeps its own form, extension or not.
97
+ const after = before.replace(
98
+ /(\bfrom\s+|\bimport\s*\(\s*)(["'])(\.{1,2}\/[^"']+)\2/g,
99
+ (match, lead: string, quote: string, specifier: string) => {
100
+ if (withoutExtension(resolve(root, dirname(source), specifier)) !== moved) return match;
101
+
102
+ const extension = specifier.match(/\.(ts|mts|cts|js|mjs|cjs)$/)?.[0] ?? "";
103
+ const renamed = `${dirname(specifier)}/${basename(withoutExtension(target))}${extension}`;
104
+ return `${lead}${quote}${renamed}${quote}`;
105
+ }
106
+ );
107
+
108
+ if (after === before) continue;
109
+
110
+ if (changed) changed.after = after;
111
+ else result.push({ file: source, line, before: original, after });
112
+ }
113
+ }
114
+
115
+ return result;
116
+ }
117
+
118
+ /** Write the plan: each changed line, then each move. */
119
+ export function applyRename(root: string, plan: RenamePlan): void {
120
+ const files = new Set(plan.changes.map((change) => change.file));
121
+
122
+ for (const file of files) {
123
+ const lines = readFileSync(join(root, file), "utf8").split("\n");
124
+ for (const change of plan.changes.filter((each) => each.file === file)) lines[change.line - 1] = change.after;
125
+ writeFileSync(join(root, file), lines.join("\n"));
126
+ }
127
+
128
+ for (const move of plan.moves) renameSync(join(root, move.from), join(root, move.to));
129
+ }
@@ -0,0 +1,322 @@
1
+ // Rules that span devices: what one device declares about another has to agree with what the other declares.
2
+
3
+ import type { Device, Gre, Port, Routed, Switched, Vxlan } from "../schema";
4
+ import { contains, familyOf, host, isCidr, network as prefixOf } from "./addr";
5
+ import type { Finding } from "./define";
6
+ import { type Holder, holdersOf, keyOf } from "./graph";
7
+
8
+ /** Every rule across devices. */
9
+ export function checkAcross(devices: readonly Device[]): Finding[] {
10
+ const holders = holdersOf(devices);
11
+
12
+ // Who holds each address. The first holder wins; a second is reported by checkAddresses.
13
+ const owners = new Map<string, Holder>();
14
+ for (const holder of holders) {
15
+ const key = keyOf(host(holder.address))!;
16
+ if (!owners.has(key)) owners.set(key, holder);
17
+ }
18
+
19
+ return [
20
+ ...checkLinks(devices),
21
+ ...checkAddresses(holders),
22
+ ...checkSessions(devices, owners),
23
+ ...checkVrrp(holders),
24
+ ...checkTunnels(devices, owners),
25
+ ];
26
+ }
27
+
28
+ /** The VLAN ids a port carries, by how it carries them, so two ends can be compared across devices. */
29
+ function carried(device: Device, port: Port<string, string, string, string>) {
30
+ const id = (vlan: string | undefined) => (vlan ? device.vlans?.[vlan]?.id : undefined);
31
+ return {
32
+ access: id(port.access_vlan),
33
+ native: id(port.native_vlan),
34
+ trunk: (port.trunk_vlans ?? []).map(id).sort((a, b) => (a ?? 0) - (b ?? 0)),
35
+ };
36
+ }
37
+
38
+ /** VLAN names by id, for the VLANs a port or LAG carries. */
39
+ function vlanNames(device: Device, settings: Switched<string>): Map<number, string> {
40
+ const names = [settings.access_vlan, settings.native_vlan, ...(settings.trunk_vlans ?? [])].filter((name) => name !== undefined);
41
+
42
+ return new Map(names.flatMap((name) => (device.vlans?.[name] ? [[device.vlans[name].id, name] as const] : [])));
43
+ }
44
+
45
+ /** What a port's settings are kept on: the LAG when the port is a member of one, otherwise the port. */
46
+ function carrierOf(device: Device, portName: string, port: Port<string, string, string, string>) {
47
+ const lag = port.lag ? device.interfaces?.[port.lag] : undefined;
48
+ if (port.lag && lag?.type === "lag") return { name: port.lag, settings: lag };
49
+
50
+ return { name: portName, settings: port };
51
+ }
52
+
53
+ /** The plain addresses of one family on an interface, leaving out point-to-point ones. */
54
+ const plainAddresses = (settings: Routed<string>, family: string) =>
55
+ (settings.addresses ?? []).filter((entry): entry is string => typeof entry === "string" && isCidr(entry) && familyOf(entry) === family);
56
+
57
+ /** Why the two ends of a cable cannot reach each other by address, or undefined when they can. */
58
+ function addressDisagreement(here: Routed<string>, there: Routed<string>, far: Device, farName: string): string | undefined {
59
+ for (const family of ["ipv4", "ipv6"]) {
60
+ const ours = plainAddresses(here, family);
61
+ const theirs = plainAddresses(there, family);
62
+ if (!ours.length || !theirs.length) continue;
63
+
64
+ // A prefix includes its length, so 10.0.0.0/24 and 10.0.0.0/25 are not one.
65
+ const theirPrefixes = new Set(theirs.map(prefixOf));
66
+ if (!ours.some((address) => theirPrefixes.has(prefixOf(address)))) {
67
+ return `has ${ours.join(", ")}, and ${far.name} ${farName} has ${theirs.join(", ")}: not one prefix of one length`;
68
+ }
69
+ }
70
+
71
+ // A point-to-point address names the far end's own address as its peer.
72
+ const farHosts = new Set((there.addresses ?? []).map((entry) => keyOf(host(typeof entry === "string" ? entry : entry.address))));
73
+
74
+ for (const entry of here.addresses ?? []) {
75
+ if (typeof entry === "string" || !keyOf(entry.peer) || farHosts.has(keyOf(entry.peer))) continue;
76
+ return `${entry.address} names peer ${entry.peer}, which ${far.name} ${farName} does not hold`;
77
+ }
78
+
79
+ return undefined;
80
+ }
81
+
82
+ /** A link names a device and port that exist, the far end links back, and both ends carry the same VLANs the same way, with the same MTU, speed and addresses. */
83
+ function checkLinks(devices: readonly Device[]): Finding[] {
84
+ const findings: Finding[] = [];
85
+ const byName = new Map(devices.map((device) => [device.name, device]));
86
+
87
+ for (const device of devices) {
88
+ for (const [portName, port] of Object.entries(device.ports ?? {})) {
89
+ const link = port?.link;
90
+ if (!port || !link) continue;
91
+
92
+ const report = (message: string, level: Finding["level"] = "error") =>
93
+ findings.push({
94
+ level,
95
+ device: device.name,
96
+ message: `port ${portName} link: ${message}`,
97
+ });
98
+
99
+ const far = byName.get(link.device);
100
+ if (!far) {
101
+ report(`device ${link.device} is not in the network`);
102
+ continue;
103
+ }
104
+
105
+ const farPort = far.ports?.[link.port];
106
+ if (!farPort) {
107
+ report(`${link.device} does not declare port ${link.port}`);
108
+ continue;
109
+ }
110
+
111
+ const linksBack = farPort.link?.device === device.name && farPort.link.port === portName;
112
+ if (!linksBack) {
113
+ report(`${link.device} ${link.port} does not link back to it`);
114
+ continue;
115
+ }
116
+
117
+ const here = JSON.stringify(carried(device, port));
118
+ const there = JSON.stringify(carried(far, farPort));
119
+ if (here !== there) report(`carries ${here}, and ${link.device} ${link.port} carries ${there}`);
120
+
121
+ if (port.speed && farPort.speed && port.speed !== farPort.speed) {
122
+ report(`speed ${port.speed}, and ${link.device} ${link.port} is ${farPort.speed}`);
123
+ }
124
+
125
+ const near = carrierOf(device, portName, port);
126
+ const away = carrierOf(far, link.port, farPort);
127
+
128
+ const mtu = near.settings.mtu ?? 1500;
129
+ const farMtu = away.settings.mtu ?? 1500;
130
+ if (mtu !== farMtu) report(`MTU ${mtu} on ${near.name}, and ${link.device} ${away.name} has ${farMtu}`);
131
+
132
+ const disagreement = addressDisagreement(near.settings, away.settings, far, away.name);
133
+ if (disagreement) report(`${near.name} ${disagreement}`);
134
+
135
+ // One VLAN under two names works, but reads as two VLANs.
136
+ const farNames = vlanNames(far, away.settings);
137
+ for (const [id, name] of vlanNames(device, near.settings)) {
138
+ const farName = farNames.get(id);
139
+ if (farName && farName !== name) report(`VLAN ${id} is ${name} here and ${farName} on ${link.device}`, "warning");
140
+ }
141
+ }
142
+ }
143
+
144
+ return findings;
145
+ }
146
+
147
+ /** No address is held by two devices. Two loopbacks may hold one on purpose, for anycast, so that is a warning. */
148
+ function checkAddresses(holders: Holder[]): Finding[] {
149
+ const findings: Finding[] = [];
150
+ const first = new Map<string, Holder>();
151
+
152
+ const isLoopback = (holder: Holder) => holder.device.interfaces?.[holder.name]?.type === "loopback";
153
+
154
+ for (const holder of holders) {
155
+ const key = keyOf(host(holder.address))!;
156
+ const earlier = first.get(key);
157
+
158
+ if (!earlier) {
159
+ first.set(key, holder);
160
+ continue;
161
+ }
162
+
163
+ // Two on one device are reported by the device's own checks.
164
+ if (earlier.device === holder.device) continue;
165
+
166
+ const anycast = isLoopback(earlier) && isLoopback(holder);
167
+
168
+ findings.push({
169
+ level: anycast ? "warning" : "error",
170
+ device: holder.device.name,
171
+ message: `${host(holder.address)} on ${holder.name} is also on ${earlier.device.name} ${earlier.name}${anycast ? ", which is right only for anycast" : ""}`,
172
+ });
173
+ }
174
+
175
+ return findings;
176
+ }
177
+
178
+ /** A BGP session to an address another of our devices holds needs that device's session back, in the AS it expects, from the address it names. */
179
+ function checkSessions(devices: readonly Device[], owners: Map<string, Holder>): Finding[] {
180
+ const findings: Finding[] = [];
181
+
182
+ const sessionsOf = (device: Device) => {
183
+ const bgp = device.routing?.bgp;
184
+
185
+ return Object.entries(bgp?.neighbors ?? {}).map(([name, neighbor]) => {
186
+ const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
187
+
188
+ return {
189
+ name,
190
+ address: neighbor.address,
191
+ remoteAs: neighbor.remote_as ?? group?.remote_as,
192
+ localAddress: neighbor.local_address ?? group?.local_address,
193
+ };
194
+ });
195
+ };
196
+
197
+ for (const device of devices) {
198
+ for (const session of sessionsOf(device)) {
199
+ const far = owners.get(keyOf(session.address) ?? "")?.device;
200
+ if (!far || far === device) continue;
201
+
202
+ const report = (message: string) =>
203
+ findings.push({ level: "error", device: device.name, message: `neighbor ${session.name}: ${message}` });
204
+
205
+ const farBgp = far.routing?.bgp;
206
+ if (!farBgp) {
207
+ report(`${session.address} is ${far.name}'s, which runs no BGP`);
208
+ continue;
209
+ }
210
+
211
+ if (session.remoteAs !== undefined && session.remoteAs !== farBgp.asn) {
212
+ report(`expects AS${session.remoteAs}, and ${far.name} is AS${farBgp.asn}`);
213
+ }
214
+
215
+ // Two routers often hold one session per family, and a session of the other family does not answer this one.
216
+ const back = sessionsOf(far).find(
217
+ (other) =>
218
+ owners.get(keyOf(other.address) ?? "")?.device === device && familyOf(other.address) === familyOf(session.address)
219
+ );
220
+ if (!back) {
221
+ report(`${far.name} has no session back to ${device.name}`);
222
+ continue;
223
+ }
224
+
225
+ // The far end only accepts the session from the address it names.
226
+ if (session.localAddress && keyOf(session.localAddress) !== keyOf(back.address)) {
227
+ report(`starts from ${session.localAddress}, and ${far.name}'s session ${back.name} expects ${back.address}`);
228
+ }
229
+ }
230
+ }
231
+
232
+ return findings;
233
+ }
234
+
235
+ /** On one segment, a VRRP id has one address and an address has one id, on every device. */
236
+ function checkVrrp(holders: Holder[]): Finding[] {
237
+ const findings: Finding[] = [];
238
+ const bySegment = new Map<string, { holder: Holder; id: number; address: string }[]>();
239
+
240
+ for (const holder of holders) {
241
+ const segment = prefixOf(holder.address);
242
+
243
+ for (const group of holder.vrrp) {
244
+ if (!keyOf(group.address) || familyOf(group.address) !== familyOf(holder.address)) continue;
245
+ if (!contains(segment, group.address)) continue;
246
+
247
+ bySegment.set(segment, [...(bySegment.get(segment) ?? []), { holder, id: group.id, address: group.address }]);
248
+ }
249
+ }
250
+
251
+ for (const members of bySegment.values()) {
252
+ for (const member of members) {
253
+ const where = `${member.holder.device.name} ${member.holder.name}`;
254
+ const others = members.filter((other) => other.holder.device !== member.holder.device);
255
+
256
+ const sameId = others.find((other) => other.id === member.id && keyOf(other.address) !== keyOf(member.address));
257
+ if (sameId) {
258
+ findings.push({
259
+ level: "error",
260
+ device: member.holder.device.name,
261
+ message: `VRRP ${member.id} on ${member.holder.name} is ${member.address}, and on ${sameId.holder.device.name} ${sameId.holder.name} it is ${sameId.address}`,
262
+ });
263
+ }
264
+
265
+ const sameAddress = others.find((other) => keyOf(other.address) === keyOf(member.address) && other.id !== member.id);
266
+ if (sameAddress) {
267
+ findings.push({
268
+ level: "error",
269
+ device: member.holder.device.name,
270
+ message: `VRRP address ${member.address} is id ${member.id} on ${where}, and id ${sameAddress.id} on ${sameAddress.holder.device.name} ${sameAddress.holder.name}`,
271
+ });
272
+ }
273
+ }
274
+ }
275
+
276
+ return findings;
277
+ }
278
+
279
+ /** A GRE or VXLAN tunnel to another of our devices has a tunnel back there, with the endpoints mirrored and, for VXLAN, the same VNI and port. */
280
+ function checkTunnels(devices: readonly Device[], owners: Map<string, Holder>): Finding[] {
281
+ const findings: Finding[] = [];
282
+
283
+ const ends = devices.flatMap((device) =>
284
+ Object.entries(device.interfaces ?? {}).flatMap(([name, iface]) =>
285
+ iface.type === "gre" || iface.type === "vxlan" ? [{ device, name, iface: iface as Gre<string> | Vxlan<string> }] : []
286
+ )
287
+ );
288
+
289
+ const same = (a: string, b: string) => keyOf(a) !== undefined && keyOf(a) === keyOf(b);
290
+
291
+ for (const end of ends) {
292
+ const far = owners.get(keyOf(end.iface.remote) ?? "")?.device;
293
+ if (!far || far === end.device) continue;
294
+
295
+ const report = (message: string) =>
296
+ findings.push({ level: "error", device: end.device.name, message: `interface ${end.name}: ${message}` });
297
+ const kind = end.iface.type === "gre" ? "GRE" : "VXLAN";
298
+
299
+ const mirror = ends.find(
300
+ (other) =>
301
+ other.device === far &&
302
+ other.iface.type === end.iface.type &&
303
+ same(other.iface.local, end.iface.remote) &&
304
+ same(other.iface.remote, end.iface.local)
305
+ );
306
+
307
+ if (!mirror) {
308
+ report(`${end.iface.remote} is ${far.name}'s, which has no ${kind} tunnel from there back to ${end.iface.local}`);
309
+ continue;
310
+ }
311
+
312
+ if (end.iface.type !== "vxlan" || mirror.iface.type !== "vxlan") continue;
313
+
314
+ if (end.iface.vni !== mirror.iface.vni) report(`VNI ${end.iface.vni}, and ${far.name} ${mirror.name} has ${mirror.iface.vni}`);
315
+
316
+ const port = end.iface.port ?? 4789;
317
+ const farPort = mirror.iface.port ?? 4789;
318
+ if (port !== farPort) report(`UDP port ${port}, and ${far.name} ${mirror.name} has ${farPort}`);
319
+ }
320
+
321
+ return findings;
322
+ }