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