@takodotid/circuit 0.18.5 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +18 -0
  2. package/dist/chunks/index-jtzfw679.js +936 -0
  3. package/dist/chunks/{index-ex1wcst7.js → index-xm9nma2t.js} +20 -1
  4. package/dist/chunks/server-5shmsmse.js +246 -0
  5. package/dist/cli/index.js +701 -106
  6. package/dist/index.js +1 -1
  7. package/dist/presets/index.js +1 -1
  8. package/dist/types/adapters/devices/derive.d.ts +2 -0
  9. package/dist/types/core/across.d.ts +4 -0
  10. package/dist/types/core/addr.d.ts +10 -0
  11. package/dist/types/core/addresses.d.ts +46 -0
  12. package/dist/types/core/files.d.ts +6 -0
  13. package/dist/types/core/graph.d.ts +74 -0
  14. package/dist/types/core/schema-docs.d.ts +16 -0
  15. package/package.json +1 -1
  16. package/src/adapters/devices/derive.ts +32 -0
  17. package/src/adapters/devices/raisecom-ros/index.ts +23 -3
  18. package/src/adapters/devices/routeros/index.ts +10 -0
  19. package/src/adapters/devices/vrp/index.ts +10 -1
  20. package/src/cli/graph.ts +89 -0
  21. package/src/cli/index.ts +205 -2
  22. package/src/cli/rename.ts +129 -0
  23. package/src/core/across.ts +322 -0
  24. package/src/core/addr.ts +29 -0
  25. package/src/core/addresses.ts +316 -0
  26. package/src/core/files.ts +30 -0
  27. package/src/core/graph.ts +634 -0
  28. package/src/core/schema-docs.ts +166 -0
  29. package/src/core/validate.ts +2 -52
  30. package/src/studio/data.ts +255 -0
  31. package/src/studio/git.ts +119 -0
  32. package/src/studio/public/app.js +147 -0
  33. package/src/studio/public/compare.js +243 -0
  34. package/src/studio/public/diff.js +97 -0
  35. package/src/studio/public/dom.js +108 -0
  36. package/src/studio/public/evaluate.js +648 -0
  37. package/src/studio/public/export.js +122 -0
  38. package/src/studio/public/favicon.svg +6 -0
  39. package/src/studio/public/graph.js +914 -0
  40. package/src/studio/public/index.html +67 -0
  41. package/src/studio/public/net.js +128 -0
  42. package/src/studio/public/style.css +693 -0
  43. package/src/studio/public/tables.js +1223 -0
  44. package/src/studio/public/tools.js +578 -0
  45. package/src/studio/server.ts +218 -0
  46. package/templates/project/AGENTS.md +12 -9
  47. package/templates/project/README.md +1 -0
@@ -0,0 +1,30 @@
1
+ // A project's own TypeScript files, and which of them declares a device: for renaming a device everywhere, and for a device's history.
2
+
3
+ import { readdirSync, readFileSync } from "node:fs";
4
+ import { join, relative } from "node:path";
5
+
6
+ /** Directories that hold no config of the project's own. */
7
+ const SKIPPED = new Set(["node_modules", ".git", ".circuit", "dist"]);
8
+
9
+ /** Every TypeScript file of the project, relative to its root, in name order. */
10
+ export function projectSources(root: string, directory = root): string[] {
11
+ const entries = readdirSync(directory, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
12
+
13
+ return entries.flatMap((entry) => {
14
+ if (entry.isDirectory()) return SKIPPED.has(entry.name) ? [] : projectSources(root, join(directory, entry.name));
15
+ return /\.(ts|mts|cts)$/.test(entry.name) ? [relative(root, join(directory, entry.name))] : [];
16
+ });
17
+ }
18
+
19
+ const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
20
+
21
+ /** Whether a file's text declares the device: it calls defineDevice and gives it the name, written out whole. */
22
+ export function declaresDevice(text: string, name: string): boolean {
23
+ const declares = new RegExp(`\\bname\\s*:\\s*(["'\`])${escape(name)}\\1`);
24
+ return text.includes("defineDevice") && declares.test(text);
25
+ }
26
+
27
+ /** The project files that declare the device, relative to the root. */
28
+ export function deviceFiles(root: string, name: string, sources = projectSources(root)): string[] {
29
+ return sources.filter((file) => declaresDevice(readFileSync(join(root, file), "utf8"), name));
30
+ }
@@ -0,0 +1,634 @@
1
+ // The network as a graph, read from the config alone: cables, the VLANs they carry, shared segments, BGP sessions, OSPF adjacencies and tunnels. `circuit graph` prints it and `circuit studio` draws it.
2
+
3
+ import type { Device, Gre, Routed, Switched, Vxlan, WireGuardPeer } from "../schema";
4
+ import type { Finding } from "./define";
5
+ import { contains, familyOf, host, isAddress, isCidr, network as prefixOf, parse, split } from "./addr";
6
+
7
+ /** One view of the network. Every edge belongs to one. */
8
+ export type Layer = "l1" | "l2" | "l3" | "bgp" | "ospf" | "tunnels";
9
+
10
+ export const LAYERS: readonly Layer[] = ["l1", "l2", "l3", "bgp", "ospf", "tunnels"];
11
+
12
+ /** One of our devices, a segment several of them share, or something outside the network. */
13
+ export type GraphNode = {
14
+ /** Unique among the nodes, such as `device:edge-01` or `as:64501`. */
15
+ id: string;
16
+ kind: "device" | "segment" | "outside";
17
+ /** A word or two, drawn on the node. */
18
+ label: string;
19
+ /** One fact per line, for a closer look. */
20
+ detail: string[];
21
+ };
22
+
23
+ /** A VLAN a cable carries. */
24
+ export type CarriedVlan = {
25
+ name: string;
26
+ id: number;
27
+ /** Carried without a tag: the access VLAN, or a trunk's native VLAN. */
28
+ untagged: boolean;
29
+ };
30
+
31
+ export type GraphEdge = {
32
+ layer: Layer;
33
+ /** Node ids. */
34
+ from: string;
35
+ to: string;
36
+ /** A few words, drawn on the edge. */
37
+ label: string;
38
+ /** One fact per line, for a closer look. */
39
+ detail: string[];
40
+ /** Only one end declares it: the far device does not declare the cable, session or tunnel back. */
41
+ oneSided?: true;
42
+ /** On `l2`, the VLANs the cable carries. */
43
+ vlans?: CarriedVlan[];
44
+ };
45
+
46
+ export type Graph = {
47
+ nodes: GraphNode[];
48
+ edges: GraphEdge[];
49
+ };
50
+
51
+ /** How a device compares with its last snapshot, found offline as `diff` finds it. */
52
+ export type Drift = {
53
+ status: "matches" | "differs" | "no snapshot" | "cannot plan";
54
+ /** The plan `diff` would show, when it differs. */
55
+ lines: string[];
56
+ };
57
+
58
+ /** The graph with what `validate` found and how each device drawn compares with its snapshot. */
59
+ export type Annotated = Graph & {
60
+ findings: Finding[];
61
+ /** By device name. */
62
+ drift: Record<string, Drift>;
63
+ };
64
+
65
+ /** An address one of the devices holds on a port or interface. */
66
+ export type Holder = {
67
+ device: Device;
68
+ /** The port or interface. */
69
+ name: string;
70
+ /** With its length. */
71
+ address: string;
72
+ /** The far end of a point-to-point address. */
73
+ peer?: string;
74
+ vrrp: NonNullable<Routed<string>["vrrp"]>;
75
+ };
76
+
77
+ export const deviceId = (name: string) => `device:${name}`;
78
+
79
+ /** An address as a key, so two spellings of one address compare equal. Undefined when it is not an address. */
80
+ export function keyOf(address: string | undefined): string | undefined {
81
+ if (!address || !isAddress(address)) return undefined;
82
+ return `${familyOf(address)}:${parse(address)}`;
83
+ }
84
+
85
+ const isHostRoute = (cidr: string) => {
86
+ const { len, family } = split(cidr);
87
+ return len === (family === "ipv4" ? 32 : 128);
88
+ };
89
+
90
+ class Builder {
91
+ readonly nodes = new Map<string, GraphNode>();
92
+ readonly edges: GraphEdge[] = [];
93
+
94
+ /** Add a node, or add the lines it does not hold yet to the one with that id. */
95
+ node(id: string, kind: GraphNode["kind"], label: string, detail: string[] = []): string {
96
+ const existing = this.nodes.get(id);
97
+
98
+ if (!existing) this.nodes.set(id, { id, kind, label, detail: [...detail] });
99
+ else for (const line of detail) if (!existing.detail.includes(line)) existing.detail.push(line);
100
+
101
+ return id;
102
+ }
103
+
104
+ edge(edge: GraphEdge): GraphEdge {
105
+ this.edges.push(edge);
106
+ return edge;
107
+ }
108
+ }
109
+
110
+ /** Ports, then interfaces, that can hold an address. */
111
+ export function routedOf(device: Device): [string, Routed<string>][] {
112
+ const ports = Object.entries(device.ports ?? {}).flatMap(([name, port]) => (port ? [[name, port] as [string, Routed<string>]] : []));
113
+ const interfaces = Object.entries(device.interfaces ?? {}) as [string, Routed<string>][];
114
+
115
+ return [...ports, ...interfaces];
116
+ }
117
+
118
+ /** Every valid address the devices hold. An invalid one is left for `validate` to report. */
119
+ export function holdersOf(devices: readonly Device[]): Holder[] {
120
+ return devices.flatMap((device) =>
121
+ routedOf(device).flatMap(([name, settings]) =>
122
+ (settings.addresses ?? []).flatMap((entry) => {
123
+ const address = typeof entry === "string" ? entry : entry.address;
124
+ if (!isCidr(address)) return [];
125
+
126
+ const peer = typeof entry === "string" ? undefined : entry.peer;
127
+ return [{ device, name, address, peer, vrrp: settings.vrrp ?? [] }];
128
+ })
129
+ )
130
+ );
131
+ }
132
+
133
+ function describeDevice(device: Device): string[] {
134
+ const addresses = routedOf(device)
135
+ .filter(([, settings]) => settings.addresses?.length)
136
+ .map(([name, settings]) => {
137
+ const list = (settings.addresses ?? []).map((entry) =>
138
+ typeof entry === "string" ? entry : `${entry.address} to ${entry.peer}`
139
+ );
140
+ const vrf = settings.vrf ? `, VRF ${settings.vrf}` : "";
141
+ return `${name}: ${list.join(", ")}${vrf}`;
142
+ });
143
+
144
+ return [`${device.platform} ${device.model}`, `reached at ${device.connection.host}`, ...addresses];
145
+ }
146
+
147
+ /** The VLANs a port or LAG carries, by id. */
148
+ function carriedBy(device: Device, settings: Switched<string> | undefined): CarriedVlan[] {
149
+ const vlan = (name: string | undefined, untagged: boolean): CarriedVlan[] => {
150
+ const id = name ? device.vlans?.[name]?.id : undefined;
151
+ return name && id !== undefined ? [{ name, id, untagged }] : [];
152
+ };
153
+
154
+ const trunk = (settings?.trunk_vlans ?? []).filter((name) => name !== settings?.native_vlan);
155
+
156
+ return [...vlan(settings?.access_vlan, true), ...vlan(settings?.native_vlan, true), ...trunk.flatMap((name) => vlan(name, false))].sort(
157
+ (a, b) => a.id - b.id
158
+ );
159
+ }
160
+
161
+ function vlanLabel(vlans: CarriedVlan[]): string {
162
+ if (vlans.length === 1) return `${vlans[0]!.name}${vlans[0]!.untagged ? ", untagged" : ""}`;
163
+ if (vlans.length <= 3) return vlans.map((vlan) => vlan.name).join(", ");
164
+
165
+ return `${vlans.length} VLANs`;
166
+ }
167
+
168
+ /** `l1`, every cable, with a LAG's member cables as one edge; and `l2`, the VLANs each one carries. */
169
+ function cables(devices: readonly Device[], graph: Builder): void {
170
+ const byName = new Map(devices.map((device) => [device.name, device]));
171
+ const drawn = new Set<string>();
172
+ const bundles = new Map<string, { cable: GraphEdge; members: number }>();
173
+
174
+ for (const device of devices) {
175
+ for (const [portName, port] of Object.entries(device.ports ?? {})) {
176
+ const link = port?.link;
177
+ if (!port || !link) continue;
178
+
179
+ // The far end drew this cable already when both ends name each other.
180
+ const here = `${device.name} ${portName}`;
181
+ const there = `${link.device} ${link.port}`;
182
+ if (drawn.has(`${there}|${here}`)) continue;
183
+ drawn.add(`${here}|${there}`);
184
+
185
+ const far = byName.get(link.device);
186
+ const farPort = far?.ports?.[link.port];
187
+ const linksBack = farPort?.link?.device === device.name && farPort.link.port === portName;
188
+
189
+ const to = far
190
+ ? deviceId(far.name)
191
+ : graph.node(`missing:${link.device}`, "outside", link.device, ["named by a link, and not in the network"]);
192
+
193
+ const cableLine = `${here} to ${there}`;
194
+
195
+ // A LAG's member cables to one LAG on the far side are one edge.
196
+ const lag = port.lag;
197
+ const farLag = farPort?.lag;
198
+ const bundleKey = `${device.name} ${lag}|${link.device} ${farLag}`;
199
+ const bundle = lag && farLag && linksBack ? bundles.get(bundleKey) : undefined;
200
+
201
+ if (bundle) {
202
+ bundle.members++;
203
+ bundle.cable.detail.push(cableLine);
204
+ bundle.cable.label = `${lag} to ${farLag}, ${bundle.members} cables`;
205
+ continue;
206
+ }
207
+
208
+ const isBundle = Boolean(lag && farLag && linksBack);
209
+
210
+ const cable = graph.edge({
211
+ layer: "l1",
212
+ from: deviceId(device.name),
213
+ to,
214
+ label: isBundle ? `${lag} to ${farLag}` : `${portName} to ${link.port}`,
215
+ detail: [cableLine],
216
+ ...(linksBack ? {} : { oneSided: true as const }),
217
+ });
218
+
219
+ if (isBundle) bundles.set(bundleKey, { cable, members: 1 });
220
+
221
+ // What carries the VLANs is the LAG when the port is in one.
222
+ const lagSettings = lag ? device.interfaces?.[lag] : undefined;
223
+ const carrier = lagSettings?.type === "lag" ? lagSettings : lag ? undefined : port;
224
+ const vlans = carriedBy(device, carrier);
225
+ if (!vlans.length) continue;
226
+
227
+ graph.edge({
228
+ layer: "l2",
229
+ from: deviceId(device.name),
230
+ to,
231
+ label: vlanLabel(vlans),
232
+ detail: [
233
+ `${device.name} ${lag ?? portName}`,
234
+ ...vlans.map((vlan) => `${vlan.name}, VLAN ${vlan.id}${vlan.untagged ? ", untagged" : ""}`),
235
+ ],
236
+ vlans,
237
+ ...(linksBack ? {} : { oneSided: true as const }),
238
+ });
239
+ }
240
+ }
241
+ }
242
+
243
+ /** `l3`: prefixes several devices hold an address in, and point-to-point addresses whose far end is another device. */
244
+ function segments(holders: Holder[], owners: Map<string, Holder>, graph: Builder): void {
245
+ const bySegment = new Map<string, Holder[]>();
246
+ const pairs = new Set<string>();
247
+
248
+ for (const holder of holders) {
249
+ if (holder.peer) {
250
+ const far = owners.get(keyOf(holder.peer) ?? "");
251
+ if (!far || far.device === holder.device) continue;
252
+
253
+ const ends = [`${holder.device.name} ${holder.name}`, `${far.device.name} ${far.name}`].sort().join("|");
254
+ if (pairs.has(ends)) continue;
255
+ pairs.add(ends);
256
+
257
+ graph.edge({
258
+ layer: "l3",
259
+ from: deviceId(holder.device.name),
260
+ to: deviceId(far.device.name),
261
+ label: `${host(holder.address)} to ${host(far.address)}`,
262
+ detail: [`${holder.device.name} ${holder.name} ${holder.address}`, `${far.device.name} ${far.name} ${far.address}`],
263
+ });
264
+ continue;
265
+ }
266
+
267
+ // A host route, such as a loopback's, is no segment.
268
+ if (isHostRoute(holder.address)) continue;
269
+
270
+ const segment = prefixOf(holder.address);
271
+ bySegment.set(segment, [...(bySegment.get(segment) ?? []), holder]);
272
+ }
273
+
274
+ for (const [segment, members] of bySegment) {
275
+ if (new Set(members.map((member) => member.device.name)).size < 2) continue;
276
+
277
+ const memberLines = members.map((member) => `${member.device.name} ${member.name} ${member.address}`);
278
+ const vrrpLines = members.flatMap((member) =>
279
+ member.vrrp.map((group) => `VRRP ${group.id} ${group.address} on ${member.device.name}, priority ${group.priority ?? 100}`)
280
+ );
281
+
282
+ // Two ends are a line between them; more share a segment drawn as its own node.
283
+ if (members.length === 2) {
284
+ const [a, b] = members as [Holder, Holder];
285
+
286
+ graph.edge({
287
+ layer: "l3",
288
+ from: deviceId(a.device.name),
289
+ to: deviceId(b.device.name),
290
+ label: segment,
291
+ detail: [...memberLines, ...vrrpLines],
292
+ });
293
+ continue;
294
+ }
295
+
296
+ const id = graph.node(`segment:${segment}`, "segment", segment, [...memberLines, ...vrrpLines]);
297
+
298
+ for (const member of members) {
299
+ graph.edge({
300
+ layer: "l3",
301
+ from: deviceId(member.device.name),
302
+ to: id,
303
+ label: `${member.name} ${host(member.address)}`,
304
+ detail: [`${member.device.name} ${member.name} ${member.address}`],
305
+ });
306
+ }
307
+ }
308
+ }
309
+
310
+ /** `bgp`: sessions between our devices, matched by the address each names, and to every AS outside. */
311
+ function sessions(devices: readonly Device[], owners: Map<string, Holder>, graph: Builder): void {
312
+ const all = devices.flatMap((device) => {
313
+ const bgp = device.routing?.bgp;
314
+
315
+ return Object.entries(bgp?.neighbors ?? {}).map(([name, neighbor]) => {
316
+ const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
317
+
318
+ return {
319
+ device,
320
+ name,
321
+ address: neighbor.address,
322
+ ownAs: bgp!.asn,
323
+ remoteAs: neighbor.remote_as ?? group?.remote_as,
324
+ role: neighbor.role ?? group?.role,
325
+ description: neighbor.description ?? group?.description,
326
+ import: neighbor.import ?? group?.import,
327
+ export: neighbor.export ?? group?.export,
328
+ };
329
+ });
330
+ });
331
+
332
+ type Session = (typeof all)[number];
333
+
334
+ const describe = (session: Session) => {
335
+ const parts = [
336
+ session.address,
337
+ session.remoteAs ? `AS${session.remoteAs}` : "no remote AS",
338
+ session.role,
339
+ session.import ? `import ${session.import}` : "imports nothing",
340
+ session.export ? `export ${session.export}` : "exports nothing",
341
+ ];
342
+
343
+ return `${session.device.name} ${session.name}: ${parts.filter(Boolean).join(", ")}`;
344
+ };
345
+
346
+ const ownerOf = (session: Session) => owners.get(keyOf(session.address) ?? "")?.device;
347
+ const paired = new Set<Session>();
348
+
349
+ for (const session of all) {
350
+ if (paired.has(session)) continue;
351
+
352
+ const far = ownerOf(session);
353
+
354
+ if (far && far !== session.device) {
355
+ // The far device's session back: one of the same family that names an address this device holds.
356
+ const back = all.find(
357
+ (other) =>
358
+ other.device === far &&
359
+ !paired.has(other) &&
360
+ ownerOf(other) === session.device &&
361
+ familyOf(other.address) === familyOf(session.address)
362
+ );
363
+ if (back) paired.add(back);
364
+
365
+ graph.edge({
366
+ layer: "bgp",
367
+ from: deviceId(session.device.name),
368
+ to: deviceId(far.name),
369
+ label: session.remoteAs === session.ownAs ? "iBGP" : "eBGP",
370
+ detail: [describe(session), ...(back ? [describe(back)] : [`${far.name} has no session back`])],
371
+ ...(back ? {} : { oneSided: true as const }),
372
+ });
373
+ continue;
374
+ }
375
+
376
+ const outside = session.remoteAs
377
+ ? graph.node(`as:${session.remoteAs}`, "outside", `AS${session.remoteAs}`, session.description ? [session.description] : [])
378
+ : graph.node(`address:${session.address}`, "outside", session.address, ["a BGP neighbor without a remote AS"]);
379
+
380
+ // A session inside our own AS should reach one of our devices.
381
+ const internal = session.remoteAs === session.ownAs;
382
+
383
+ graph.edge({
384
+ layer: "bgp",
385
+ from: deviceId(session.device.name),
386
+ to: outside,
387
+ label: [session.name, session.role].filter(Boolean).join(", "),
388
+ detail: [describe(session), ...(internal ? ["internal, and no device in the network holds the address"] : [])],
389
+ ...(internal ? { oneSided: true as const } : {}),
390
+ });
391
+ }
392
+ }
393
+
394
+ /** `ospf`: two devices with OSPF on interfaces in one area and one prefix, neither passive. */
395
+ function adjacencies(devices: readonly Device[], graph: Builder): void {
396
+ const byKey = new Map<string, { device: Device; name: string; line: string }[]>();
397
+
398
+ for (const device of devices) {
399
+ for (const [area, { interfaces }] of Object.entries(device.routing?.ospf?.areas ?? {})) {
400
+ for (const [name, settings] of Object.entries(interfaces)) {
401
+ if (settings?.passive) continue;
402
+
403
+ const routed = (device.ports?.[name] ?? device.interfaces?.[name]) as Routed<string> | undefined;
404
+ const cost = settings?.cost ? `, cost ${settings.cost}` : "";
405
+
406
+ for (const entry of routed?.addresses ?? []) {
407
+ const address = typeof entry === "string" ? entry : entry.address;
408
+ if (!isCidr(address) || isHostRoute(address)) continue;
409
+
410
+ const key = `${area} ${prefixOf(address)}`;
411
+ const member = { device, name, line: `${device.name} ${name} ${address}${cost}` };
412
+ byKey.set(key, [...(byKey.get(key) ?? []), member]);
413
+ }
414
+ }
415
+ }
416
+ }
417
+
418
+ for (const [key, members] of byKey) {
419
+ const [area] = key.split(" ");
420
+
421
+ for (const [index, a] of members.entries()) {
422
+ for (const b of members.slice(index + 1)) {
423
+ if (a.device === b.device) continue;
424
+
425
+ graph.edge({
426
+ layer: "ospf",
427
+ from: deviceId(a.device.name),
428
+ to: deviceId(b.device.name),
429
+ label: `area ${area}`,
430
+ detail: [a.line, b.line],
431
+ });
432
+ }
433
+ }
434
+ }
435
+ }
436
+
437
+ /** `host:port` or `[host]:port` to the host. */
438
+ const endpointHost = (endpoint: string) => endpoint.replace(/:\d+$/, "").replace(/^\[(.*)\]$/, "$1");
439
+
440
+ /** `tunnels`: GRE and VXLAN tunnels matched by their endpoints, and WireGuard peers matched to the device at the far end. */
441
+ function tunnels(devices: readonly Device[], owners: Map<string, Holder>, graph: Builder): void {
442
+ const ends = devices.flatMap((device) =>
443
+ Object.entries(device.interfaces ?? {}).flatMap(([name, iface]) =>
444
+ iface.type === "gre" || iface.type === "vxlan" ? [{ device, name, iface: iface as Gre<string> | Vxlan<string> }] : []
445
+ )
446
+ );
447
+
448
+ type End = (typeof ends)[number];
449
+
450
+ const same = (a: string, b: string) => keyOf(a) !== undefined && keyOf(a) === keyOf(b);
451
+ const mirrors = (a: End, b: End) =>
452
+ a.device !== b.device &&
453
+ a.iface.type === b.iface.type &&
454
+ same(a.iface.local, b.iface.remote) &&
455
+ same(a.iface.remote, b.iface.local) &&
456
+ (a.iface.type !== "vxlan" || (b.iface.type === "vxlan" && a.iface.vni === b.iface.vni));
457
+
458
+ const describe = (end: End) => {
459
+ const vni = end.iface.type === "vxlan" ? `, VNI ${end.iface.vni}` : "";
460
+ return `${end.device.name} ${end.name}: ${end.iface.local} to ${end.iface.remote}${vni}`;
461
+ };
462
+
463
+ const paired = new Set<End>();
464
+
465
+ for (const end of ends) {
466
+ if (paired.has(end)) continue;
467
+
468
+ const kind = end.iface.type === "gre" ? "GRE" : "VXLAN";
469
+ const mirror = ends.find((other) => !paired.has(other) && mirrors(end, other));
470
+
471
+ if (mirror) {
472
+ paired.add(mirror);
473
+
474
+ graph.edge({
475
+ layer: "tunnels",
476
+ from: deviceId(end.device.name),
477
+ to: deviceId(mirror.device.name),
478
+ label: `${kind} ${end.name} to ${mirror.name}`,
479
+ detail: [describe(end), describe(mirror)],
480
+ });
481
+ continue;
482
+ }
483
+
484
+ // The far endpoint is one of our devices, which declares no tunnel back.
485
+ const owner = owners.get(keyOf(end.iface.remote) ?? "")?.device;
486
+
487
+ if (owner && owner !== end.device) {
488
+ graph.edge({
489
+ layer: "tunnels",
490
+ from: deviceId(end.device.name),
491
+ to: deviceId(owner.name),
492
+ label: `${kind} ${end.name}`,
493
+ detail: [describe(end), `${owner.name} declares no tunnel back`],
494
+ oneSided: true,
495
+ });
496
+ continue;
497
+ }
498
+
499
+ const outside = graph.node(
500
+ `address:${end.iface.remote}`,
501
+ "outside",
502
+ end.iface.remote,
503
+ end.iface.description ? [end.iface.description] : []
504
+ );
505
+
506
+ graph.edge({
507
+ layer: "tunnels",
508
+ from: deviceId(end.device.name),
509
+ to: outside,
510
+ label: `${kind} ${end.name}`,
511
+ detail: [describe(end)],
512
+ });
513
+ }
514
+
515
+ wireguard(devices, owners, graph);
516
+ }
517
+
518
+ function wireguard(devices: readonly Device[], owners: Map<string, Holder>, graph: Builder): void {
519
+ const interfaces = devices.flatMap((device) =>
520
+ Object.entries(device.interfaces ?? {}).flatMap(([name, iface]) => (iface.type === "wireguard" ? [{ device, name, iface }] : []))
521
+ );
522
+
523
+ // A peer is another of our devices when it may send from that device's tunnel address, or when its endpoint is that device's.
524
+ const farEndOf = (device: Device, peer: WireGuardPeer) =>
525
+ interfaces.find(({ device: other, iface }) => {
526
+ if (other === device) return false;
527
+
528
+ const tunnelAddresses = (iface.addresses ?? []).map((entry) => host(typeof entry === "string" ? entry : entry.address));
529
+ const sendsFrom = tunnelAddresses.some(
530
+ (address) => isAddress(address) && peer.allowed_addresses.some((allowed) => isCidr(allowed) && contains(allowed, address))
531
+ );
532
+
533
+ const endpoint = peer.endpoint ? endpointHost(peer.endpoint) : undefined;
534
+ const isEndpoint = endpoint !== undefined && owners.get(keyOf(endpoint) ?? "")?.device === other;
535
+
536
+ return sendsFrom || isEndpoint;
537
+ });
538
+
539
+ const drawn = new Set<string>();
540
+
541
+ for (const { device, name, iface } of interfaces) {
542
+ const clients: WireGuardPeer[] = [];
543
+
544
+ for (const peer of iface.peers ?? []) {
545
+ const far = farEndOf(device, peer);
546
+
547
+ if (far) {
548
+ const ends = [`${device.name} ${name}`, `${far.device.name} ${far.name}`].sort().join("|");
549
+ if (drawn.has(ends)) continue;
550
+ drawn.add(ends);
551
+
552
+ graph.edge({
553
+ layer: "tunnels",
554
+ from: deviceId(device.name),
555
+ to: deviceId(far.device.name),
556
+ label: `WireGuard ${name} to ${far.name}`,
557
+ detail: [`${device.name} ${name} peer ${peer.name}`],
558
+ });
559
+ continue;
560
+ }
561
+
562
+ if (!peer.endpoint) {
563
+ clients.push(peer);
564
+ continue;
565
+ }
566
+
567
+ const endpoint = endpointHost(peer.endpoint);
568
+ const outside = graph.node(`address:${endpoint}`, "outside", peer.name, [`WireGuard endpoint ${peer.endpoint}`]);
569
+
570
+ graph.edge({
571
+ layer: "tunnels",
572
+ from: deviceId(device.name),
573
+ to: outside,
574
+ label: `WireGuard ${name}`,
575
+ detail: [`${device.name} ${name} peer ${peer.name}, allowed ${peer.allowed_addresses.join(", ")}`],
576
+ });
577
+ }
578
+
579
+ if (!clients.length) continue;
580
+
581
+ // Peers that dial in are drawn as one node, so a VPN with many clients stays readable.
582
+ const outside = graph.node(
583
+ `clients:${device.name}:${name}`,
584
+ "outside",
585
+ `${clients.length} ${clients.length === 1 ? "client" : "clients"}`,
586
+ clients.map((peer) => `${peer.name}: ${peer.allowed_addresses.join(", ")}`)
587
+ );
588
+
589
+ graph.edge({
590
+ layer: "tunnels",
591
+ from: deviceId(device.name),
592
+ to: outside,
593
+ label: `WireGuard ${name}`,
594
+ detail: [`peers that dial in to ${device.name} ${name}`],
595
+ });
596
+ }
597
+ }
598
+
599
+ /** The network as a graph. It reads the config only, never a device, and works on a config that does not validate. */
600
+ export function graphOf(devices: readonly Device[]): Graph {
601
+ const graph = new Builder();
602
+
603
+ for (const device of devices) graph.node(deviceId(device.name), "device", device.name, describeDevice(device));
604
+
605
+ const holders = holdersOf(devices);
606
+
607
+ // Who holds each address, for matching a session or tunnel to the device at its far end. The first holder wins.
608
+ const owners = new Map<string, Holder>();
609
+ for (const holder of holders) {
610
+ const key = keyOf(host(holder.address))!;
611
+ if (!owners.has(key)) owners.set(key, holder);
612
+ }
613
+
614
+ cables(devices, graph);
615
+ segments(holders, owners, graph);
616
+ sessions(devices, owners, graph);
617
+ adjacencies(devices, graph);
618
+ tunnels(devices, owners, graph);
619
+
620
+ return { nodes: [...graph.nodes.values()], edges: graph.edges };
621
+ }
622
+
623
+ /** Only the edges on these layers, and among those only the ones that touch these devices when any are named. Every device named stays. */
624
+ export function only(graph: Graph, layers: readonly Layer[], deviceNames: readonly string[] = []): Graph {
625
+ const named = new Set(deviceNames.map(deviceId));
626
+ const touches = (edge: GraphEdge) => !named.size || named.has(edge.from) || named.has(edge.to);
627
+
628
+ const edges = graph.edges.filter((edge) => layers.includes(edge.layer) && touches(edge));
629
+ const ends = new Set(edges.flatMap((edge) => [edge.from, edge.to]));
630
+
631
+ const nodes = graph.nodes.filter((node) => (named.size ? named.has(node.id) : node.kind === "device") || ends.has(node.id));
632
+
633
+ return { nodes, edges };
634
+ }