@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
@@ -0,0 +1,648 @@
1
+ // @ts-check
2
+ // What Studio's tools work out from the config: where an address is and how each device would reach it, the VLAN path between two devices, which firewall rule a packet meets, and what a route policy does to a route.
3
+ // They follow the schema's own description of each field, read from the files: a device can still behave otherwise, so they say what the config means, not what the device did.
4
+
5
+ import { covers, familyOf, networkOf, parseRange, samePrefix } from "./net.js";
6
+
7
+ /** @typedef {import("../../schema").Device} Device */
8
+ /** @typedef {import("../../schema").FilterMatch<string, string>} FilterMatch */
9
+ /** @typedef {import("../../schema").FilterRule<string, string>} FilterRule */
10
+ /** @typedef {import("../../schema").PolicyRule<string, string>} PolicyRule */
11
+ /** @typedef {import("../../schema").Match<string>} RouteMatch */
12
+ /** @typedef {import("../../schema").Sets} Sets */
13
+ /** @typedef {import("../../schema").Ports} Ports */
14
+ /** @typedef {import("../../core/graph").GraphEdge} GraphEdge */
15
+ /** @typedef {import("../data").StudioData} StudioData */
16
+
17
+ /** Whether a condition holds: yes, no, or not known from what was given. @typedef {"yes" | "no" | "unknown"} Answer */
18
+ /** @typedef {{ answer: Answer, why: string[] }} Check */
19
+
20
+ /**
21
+ * Every condition must hold. One that does not decides; otherwise one not known leaves the whole not known.
22
+ *
23
+ * @param {Check[]} checks
24
+ * @returns {Check}
25
+ */
26
+ function all(checks) {
27
+ const no = checks.filter((check) => check.answer === "no");
28
+ if (no.length) return { answer: "no", why: no.flatMap((check) => check.why) };
29
+
30
+ const unknown = checks.filter((check) => check.answer === "unknown");
31
+ if (unknown.length) return { answer: "unknown", why: unknown.flatMap((check) => check.why) };
32
+
33
+ return { answer: "yes", why: [] };
34
+ }
35
+
36
+ /**
37
+ * @param {boolean} holds
38
+ * @param {string} why
39
+ * @returns {Check}
40
+ */
41
+ const test = (holds, why) => ({ answer: holds ? "yes" : "no", why: holds ? [] : [why] });
42
+
43
+ /**
44
+ * @param {string} why
45
+ * @returns {Check}
46
+ */
47
+ const unknown = (why) => ({ answer: "unknown", why: [why] });
48
+
49
+ // Where an address is.
50
+
51
+ /** @typedef {{ device: string, table: string, kind: "local" | "connected" | "static" | "none", prefix?: string, via?: string, through?: string, note?: string }} RouteFound */
52
+ /** @typedef {{ device: string, where: string, rule: number, description?: string, action: string, side: string }} RuleFound */
53
+
54
+ /**
55
+ * The addresses on a device's ports and interfaces, with where each is and the VRF it routes in.
56
+ *
57
+ * @param {Device} device
58
+ */
59
+ function addressesOf(device) {
60
+ const routed =
61
+ /** @type {[string, { addresses?: readonly (string | { address: string, peer: string })[], vrf?: string } | undefined][]} */ ([
62
+ ...Object.entries(device.ports ?? {}),
63
+ ...Object.entries(device.interfaces ?? {}),
64
+ ]);
65
+
66
+ return routed.flatMap(([name, settings]) =>
67
+ (settings?.addresses ?? []).map((entry) => ({
68
+ name,
69
+ address: typeof entry === "string" ? entry : entry.address,
70
+ peer: typeof entry === "string" ? undefined : entry.peer,
71
+ vrf: settings?.vrf,
72
+ }))
73
+ );
74
+ }
75
+
76
+ /**
77
+ * Where an address is in the network, how each device would reach it from what the files say, and every rule that names it.
78
+ *
79
+ * @param {StudioData} data
80
+ * @param {string} text an address, such as `10.0.10.5`
81
+ */
82
+ export function trace(data, text) {
83
+ const target = parseRange(text);
84
+ if (!target || target.first !== target.last) return undefined;
85
+
86
+ // Who holds it, and the segment or block it is in.
87
+ const held = data.devices.flatMap((device) =>
88
+ addressesOf(device)
89
+ .filter((entry) => samePrefix(entry.address.split("/")[0] ?? "", text))
90
+ .map((entry) => ({ device: device.name, where: entry.name, address: entry.address }))
91
+ );
92
+
93
+ const { spaces, elsewhere } = data.addresses;
94
+ const segments = [...spaces.flatMap((space) => space.segments), ...elsewhere.segments];
95
+ const blocks = [...spaces.flatMap((space) => space.blocks), ...elsewhere.blocks];
96
+ const segment = segments.find((candidate) => covers(candidate.prefix, text));
97
+ const block = blocks.find((candidate) => covers(candidate.prefix, text));
98
+ const space = spaces.find((candidate) => covers(candidate.prefix, text));
99
+ const uses = segment?.uses.filter((use) => {
100
+ const [first, last] = use.address.split("-");
101
+ if (!last) return samePrefix(first ?? "", text);
102
+ const from = parseRange(first ?? "");
103
+ const to = parseRange(last);
104
+ return Boolean(from && to && from.first <= target.first && target.first <= to.first);
105
+ });
106
+
107
+ // How each device would reach it: the longest prefix that covers it, in each routing table, among its own addresses and its static routes.
108
+ /** @type {RouteFound[]} */
109
+ const routes = [];
110
+
111
+ for (const device of data.devices) {
112
+ /** @type {Map<string, RouteFound & { length: number, rank: number }>} */
113
+ const best = new Map();
114
+
115
+ /** @param {RouteFound & { length: number, rank: number }} found */
116
+ const offer = (found) => {
117
+ const current = best.get(found.table);
118
+ const better = !current || found.length > current.length || (found.length === current.length && found.rank < current.rank);
119
+ if (better) best.set(found.table, found);
120
+ };
121
+
122
+ for (const entry of addressesOf(device)) {
123
+ const table = entry.vrf ? `VRF ${entry.vrf}` : "main";
124
+ const range = parseRange(entry.address);
125
+ if (!range) continue;
126
+
127
+ if (samePrefix(entry.address.split("/")[0] ?? "", text)) {
128
+ offer({ device: device.name, table, kind: "local", prefix: entry.address, through: entry.name, length: 129, rank: 0 });
129
+ } else if (entry.peer && samePrefix(entry.peer, text)) {
130
+ offer({
131
+ device: device.name,
132
+ table,
133
+ kind: "connected",
134
+ prefix: `${entry.peer} as the peer of ${entry.address}`,
135
+ through: entry.name,
136
+ length: 128,
137
+ rank: 1,
138
+ });
139
+ } else if (!entry.peer && covers(entry.address, text)) {
140
+ const network = networkOf(entry.address);
141
+ offer({
142
+ device: device.name,
143
+ table,
144
+ kind: "connected",
145
+ prefix: network,
146
+ through: entry.name,
147
+ length: range.length,
148
+ rank: 1,
149
+ });
150
+ }
151
+ }
152
+
153
+ for (const route of device.routing?.static ?? []) {
154
+ const range = parseRange(route.prefix);
155
+ if (!range || !covers(route.prefix, text)) continue;
156
+
157
+ const table = route.vrf ? `VRF ${route.vrf}` : "main";
158
+ offer({
159
+ device: device.name,
160
+ table,
161
+ kind: "static",
162
+ prefix: route.prefix,
163
+ via: route.via,
164
+ through: route.interface,
165
+ note: route.blackhole ? "discarded: a blackhole route" : route.description,
166
+ length: range.length,
167
+ rank: 2 + (route.distance ?? 1),
168
+ });
169
+ }
170
+
171
+ if (!best.size) routes.push({ device: device.name, table: "main", kind: "none" });
172
+ for (const found of best.values()) {
173
+ const { length: _length, rank: _rank, ...route } = found;
174
+ routes.push(route);
175
+ }
176
+ }
177
+
178
+ // Every rule that names it, by address, prefix or address set.
179
+ /** @type {RuleFound[]} */
180
+ const rules = [];
181
+
182
+ for (const device of data.devices) {
183
+ const sets = device.firewall?.address_sets ?? {};
184
+ const inSet = (/** @type {string | undefined} */ name) => Boolean(name && (sets[name] ?? []).some((entry) => covers(entry, text)));
185
+
186
+ /**
187
+ * @param {string} where
188
+ * @param {readonly { description?: string, match?: { src?: string, dst?: string, src_set?: string, dst_set?: string }, action: string }[]} list
189
+ */
190
+ const look = (where, list) => {
191
+ for (const [index, rule] of list.entries()) {
192
+ const match = rule.match ?? {};
193
+ const sides = [
194
+ (match.src && covers(match.src, text)) || inSet(match.src_set) ? "source" : "",
195
+ (match.dst && covers(match.dst, text)) || inSet(match.dst_set) ? "destination" : "",
196
+ ].filter(Boolean);
197
+ if (sides.length)
198
+ rules.push({
199
+ device: device.name,
200
+ where,
201
+ rule: index + 1,
202
+ description: rule.description,
203
+ action: rule.action,
204
+ side: sides.join(" and "),
205
+ });
206
+ }
207
+ };
208
+
209
+ for (const [chain, settings] of Object.entries(device.firewall?.filter ?? {})) look(`filter ${chain}`, settings?.rules ?? []);
210
+ for (const [chain, list] of Object.entries(device.firewall?.nat ?? {})) look(`NAT ${chain}`, list ?? []);
211
+ for (const [name, list] of Object.entries(device.acls ?? {})) look(`ACL ${name}`, list);
212
+ }
213
+
214
+ return { address: text, family: target.family, held, segment, uses: uses ?? [], block, space, routes, rules };
215
+ }
216
+
217
+ // The VLAN path between two devices.
218
+
219
+ /**
220
+ * The fewest cables that carry a VLAN from one device to another, from the graph's VLAN layer. Undefined when no such path exists.
221
+ *
222
+ * @param {StudioData} data
223
+ * @param {number} vlan
224
+ * @param {string} from device name
225
+ * @param {string} to device name
226
+ * @returns {GraphEdge[] | undefined}
227
+ */
228
+ export function vlanPath(data, vlan, from, to) {
229
+ const start = `device:${from}`;
230
+ const goal = `device:${to}`;
231
+ const cables = data.graph.edges.filter((edge) => edge.layer === "l2" && edge.vlans?.some((carried) => carried.id === vlan));
232
+
233
+ /** @type {Map<string, GraphEdge | null>} */
234
+ const cameBy = new Map([[start, null]]);
235
+ const queue = [start];
236
+
237
+ for (let at = queue.shift(); at !== undefined; at = queue.shift()) {
238
+ if (at === goal) break;
239
+
240
+ for (const cable of cables) {
241
+ const next = cable.from === at ? cable.to : cable.to === at ? cable.from : undefined;
242
+ if (next === undefined || cameBy.has(next)) continue;
243
+
244
+ cameBy.set(next, cable);
245
+ queue.push(next);
246
+ }
247
+ }
248
+
249
+ if (!cameBy.has(goal)) return undefined;
250
+
251
+ /** @type {GraphEdge[]} */
252
+ const path = [];
253
+ for (let at = goal, cable = cameBy.get(goal); cable; cable = cameBy.get(at)) {
254
+ path.unshift(cable);
255
+ at = cable.from === at ? cable.to : cable.from;
256
+ }
257
+
258
+ return path;
259
+ }
260
+
261
+ // Which firewall rule a packet meets.
262
+
263
+ /** @typedef {{ family?: "ipv4" | "ipv6", protocol?: string, src?: string, dst?: string, srcPort?: number, dstPort?: number, inInterface?: string, outInterface?: string, state?: string, flags?: string[] }} Packet */
264
+
265
+ /**
266
+ * @param {Ports} ports
267
+ * @param {number} port
268
+ */
269
+ function portIn(ports, port) {
270
+ const list = Array.isArray(ports) ? ports : [ports];
271
+ return list.some((entry) => (typeof entry === "number" ? entry === port : entry.min <= port && port <= entry.max));
272
+ }
273
+
274
+ /**
275
+ * Whether a packet meets a rule's conditions, as the schema describes each one.
276
+ *
277
+ * @param {FilterMatch} match
278
+ * @param {Packet} packet
279
+ * @param {Record<string, readonly string[]>} sets the device's address sets
280
+ * @returns {Check}
281
+ */
282
+ export function packetMeets(match, packet, sets) {
283
+ /** @type {Check[]} */
284
+ const checks = [];
285
+ const family = packet.family ?? familyOf(packet.src ?? "") ?? familyOf(packet.dst ?? "");
286
+
287
+ if (match.family) checks.push(family ? test(match.family === family, `it is for ${match.family} only`) : unknown("its family"));
288
+
289
+ // A rule with addresses of one family applies to that family only.
290
+ for (const address of [match.src, match.dst]) {
291
+ const own = address ? familyOf(address) : undefined;
292
+ if (own && family && own !== family) checks.push(test(false, `it names ${own} addresses`));
293
+ }
294
+
295
+ if (match.protocol)
296
+ checks.push(packet.protocol ? test(match.protocol === packet.protocol, `it is for ${match.protocol}`) : unknown("the protocol"));
297
+
298
+ /**
299
+ * @param {string | undefined} rule
300
+ * @param {string | undefined} given
301
+ * @param {string} side
302
+ */
303
+ const address = (rule, given, side) => {
304
+ if (!rule) return;
305
+ checks.push(given ? test(covers(rule, given), `its ${side} is ${rule}`) : unknown(`the ${side} address`));
306
+ };
307
+
308
+ address(match.src, packet.src, "source");
309
+ address(match.dst, packet.dst, "destination");
310
+
311
+ /**
312
+ * @param {string | undefined} name
313
+ * @param {string | undefined} given
314
+ * @param {string} side
315
+ */
316
+ const set = (name, given, side) => {
317
+ if (!name) return;
318
+ const members = sets[name] ?? [];
319
+ checks.push(
320
+ given
321
+ ? test(
322
+ members.some((entry) => covers(entry, given)),
323
+ `its ${side} is in the set ${name}`
324
+ )
325
+ : unknown(`the ${side} address`)
326
+ );
327
+ };
328
+
329
+ set(match.src_set, packet.src, "source");
330
+ set(match.dst_set, packet.dst, "destination");
331
+
332
+ /**
333
+ * @param {Ports | undefined} rule
334
+ * @param {number | undefined} given
335
+ * @param {string} side
336
+ */
337
+ const port = (rule, given, side) => {
338
+ if (rule === undefined) return;
339
+ if (packet.protocol && packet.protocol !== "tcp" && packet.protocol !== "udp")
340
+ return checks.push(test(false, "it names ports, which only TCP and UDP have"));
341
+ checks.push(
342
+ given !== undefined ? test(portIn(rule, given), `its ${side} port is ${JSON.stringify(rule)}`) : unknown(`the ${side} port`)
343
+ );
344
+ };
345
+
346
+ port(match.src_port, packet.srcPort, "source");
347
+ port(match.dst_port, packet.dstPort, "destination");
348
+
349
+ if (match.in_interface)
350
+ checks.push(
351
+ packet.inInterface
352
+ ? test(match.in_interface === packet.inInterface, `it is for packets arriving on ${match.in_interface}`)
353
+ : unknown("where the packet arrives")
354
+ );
355
+ if (match.out_interface)
356
+ checks.push(
357
+ packet.outInterface
358
+ ? test(match.out_interface === packet.outInterface, `it is for packets leaving through ${match.out_interface}`)
359
+ : unknown("where the packet leaves")
360
+ );
361
+ if (match.state)
362
+ checks.push(
363
+ packet.state
364
+ ? test(match.state.includes(/** @type {never} */ (packet.state)), `it is for ${match.state.join(" or ")} packets`)
365
+ : unknown("the connection state")
366
+ );
367
+
368
+ if (match.tcp_flags) {
369
+ if (packet.protocol && packet.protocol !== "tcp") checks.push(test(false, "it names TCP flags"));
370
+ else if (!packet.flags) checks.push(unknown("the TCP flags"));
371
+ else {
372
+ const flags = packet.flags;
373
+ const set = match.tcp_flags.set ?? [];
374
+ const unset = match.tcp_flags.unset ?? [];
375
+ checks.push(
376
+ test(set.every((flag) => flags.includes(flag)) && unset.every((flag) => !flags.includes(flag)), "its TCP flags differ")
377
+ );
378
+ }
379
+ }
380
+
381
+ return all(checks);
382
+ }
383
+
384
+ /**
385
+ * A packet through one of a device's filter chains: each rule in order until one matches, then the chain's default, accept when it has none.
386
+ *
387
+ * @param {Device} device
388
+ * @param {"input" | "forward" | "output"} chain
389
+ * @param {Packet} packet
390
+ */
391
+ export function filterPacket(device, chain, packet) {
392
+ const settings = device.firewall?.filter?.[chain];
393
+ const rules = settings?.rules ?? [];
394
+ const sets = device.firewall?.address_sets ?? {};
395
+
396
+ /** @type {{ rule: number, description?: string, action: string, check: Check }[]} */
397
+ const steps = [];
398
+
399
+ for (const [index, rule] of rules.entries()) {
400
+ const check = packetMeets(rule.match ?? {}, packet, sets);
401
+ steps.push({ rule: index + 1, description: rule.description, action: rule.action, check });
402
+
403
+ if (check.answer === "yes") return { steps, verdict: rule.action, by: `rule ${index + 1}` };
404
+ if (check.answer === "unknown") return { steps, verdict: undefined, by: `rule ${index + 1}` };
405
+ }
406
+
407
+ return {
408
+ steps,
409
+ verdict: settings?.default ?? "accept",
410
+ by: settings?.default ? "the chain's default" : "the chain's default, accept when none is written",
411
+ };
412
+ }
413
+
414
+ // What a route policy does to a route.
415
+
416
+ /** @typedef {{ prefix: string, asPathLength?: number, rpki?: string, communities: string[], largeCommunities: string[], inRegistrySets: string[] }} Route */
417
+ /** @typedef {{ localPref?: number, med?: number, prepend: number, communities: string[], largeCommunities: string[], nextHop?: string, preferredSource?: string, blackhole?: boolean }} Attributes */
418
+ /** @typedef {{ policy: string, rule: number, description?: string, check: Check, changed: string[], decision?: string, call?: string }} PolicyStep */
419
+
420
+ /**
421
+ * Whether a route meets a policy rule's conditions, as the schema describes each one. A prefix set holds a route whose prefix is one of its own or inside one.
422
+ *
423
+ * @param {RouteMatch} match
424
+ * @param {Route} route
425
+ * @param {Device} device
426
+ * @returns {Check}
427
+ */
428
+ export function routeMeets(match, route, device) {
429
+ /** @type {Check[]} */
430
+ const checks = [];
431
+ const range = parseRange(route.prefix);
432
+ const family = range?.family;
433
+
434
+ if (match.family) checks.push(test(match.family === family, `it is for ${match.family} only`));
435
+ if (match.prefix) checks.push(test(samePrefix(match.prefix, route.prefix), `it is for ${match.prefix} exactly`));
436
+
437
+ if (match.prefix_set) {
438
+ const set = device.prefix_sets?.[match.prefix_set];
439
+ if (set && "source" in set)
440
+ checks.push(
441
+ test(
442
+ route.inRegistrySets.includes(match.prefix_set),
443
+ `the route is not in ${match.prefix_set}, fetched from ${set.source.registry}`
444
+ )
445
+ );
446
+ else
447
+ checks.push(
448
+ test(
449
+ Boolean(set?.prefixes.some((prefix) => covers(prefix, route.prefix))),
450
+ `the route is not in the prefix set ${match.prefix_set}`
451
+ )
452
+ );
453
+ }
454
+
455
+ const length = range?.length ?? 0;
456
+ if (match.prefix_length?.min !== undefined)
457
+ checks.push(test(length >= match.prefix_length.min, `it is for /${match.prefix_length.min} or longer`));
458
+ if (match.prefix_length?.max !== undefined)
459
+ checks.push(test(length <= match.prefix_length.max, `it is for /${match.prefix_length.max} or shorter`));
460
+
461
+ if (match.as_path_length) {
462
+ const { min, max } = match.as_path_length;
463
+ if (route.asPathLength === undefined) checks.push(unknown("the AS path length"));
464
+ else {
465
+ if (min !== undefined) checks.push(test(route.asPathLength >= min, `it is for AS paths of ${min} or more`));
466
+ if (max !== undefined) checks.push(test(route.asPathLength <= max, `it is for AS paths of ${max} or fewer`));
467
+ }
468
+ }
469
+
470
+ if (match.rpki)
471
+ checks.push(route.rpki ? test(match.rpki === route.rpki, `it is for routes RPKI finds ${match.rpki}`) : unknown("the RPKI state"));
472
+ if (match.community) checks.push(test(route.communities.includes(match.community), `the route does not carry ${match.community}`));
473
+ if (match.large_community)
474
+ checks.push(test(route.largeCommunities.includes(match.large_community), `the route does not carry ${match.large_community}`));
475
+
476
+ return all(checks);
477
+ }
478
+
479
+ /**
480
+ * Whether a community matches a pattern in which `*` stands for any value of a field.
481
+ *
482
+ * @param {string} pattern
483
+ * @param {string} community
484
+ */
485
+ const fits = (pattern, community) => {
486
+ const want = pattern.split(":");
487
+ const have = community.split(":");
488
+ return want.length === have.length && want.every((field, index) => field === "*" || field === have[index]);
489
+ };
490
+
491
+ /**
492
+ * Apply a rule's changes to a route's attributes, and say what changed.
493
+ *
494
+ * @param {Sets} set
495
+ * @param {Attributes} attributes
496
+ */
497
+ function apply(set, attributes) {
498
+ /** @type {string[]} */
499
+ const changed = [];
500
+
501
+ if (set.local_pref !== undefined) changed.push(`local preference ${(attributes.localPref = set.local_pref)}`);
502
+ if (set.med !== undefined) changed.push(`MED ${(attributes.med = set.med)}`);
503
+ if (set.prepend)
504
+ changed.push(
505
+ `prepend our AS ${set.prepend} ${set.prepend === 1 ? "time" : "times"} (${(attributes.prepend += set.prepend)} in all)`
506
+ );
507
+
508
+ /**
509
+ * @param {"communities" | "largeCommunities"} key
510
+ * @param {readonly string[] | undefined} replace
511
+ * @param {readonly string[] | undefined} add
512
+ * @param {readonly string[] | undefined} remove
513
+ * @param {string} name
514
+ */
515
+ const communities = (key, replace, add, remove, name) => {
516
+ if (replace) changed.push(`${name} set to ${(attributes[key] = [...replace]).join(", ") || "none"}`);
517
+ if (remove?.length) {
518
+ const before = attributes[key].length;
519
+ attributes[key] = attributes[key].filter((community) => !remove.some((pattern) => fits(pattern, community)));
520
+ changed.push(`${before - attributes[key].length} ${name} removed by ${remove.join(", ")}`);
521
+ }
522
+ if (add?.length) {
523
+ attributes[key] = [...new Set([...attributes[key], ...add])];
524
+ changed.push(`${name} added: ${add.join(", ")}`);
525
+ }
526
+ };
527
+
528
+ communities("communities", set.communities, set.add_communities, set.remove_communities, "communities");
529
+ communities("largeCommunities", set.large_communities, set.add_large_communities, set.remove_large_communities, "large communities");
530
+
531
+ if (set.next_hop) changed.push(`next hop ${(attributes.nextHop = set.next_hop)}`);
532
+ if (set.preferred_source) changed.push(`preferred source ${(attributes.preferredSource = set.preferred_source)}`);
533
+ if (set.blackhole) {
534
+ attributes.blackhole = true;
535
+ changed.push("blackholed: traffic to it is dropped here");
536
+ }
537
+
538
+ return changed;
539
+ }
540
+
541
+ /**
542
+ * A route through one of a device's route policies: `call`, then `match`, then `set`, then `action`, rule by rule, until a decision. A route the policy itself never accepts is rejected; one a called policy leaves undecided goes on in the caller.
543
+ *
544
+ * @param {Device} device
545
+ * @param {string} policy
546
+ * @param {Route} route
547
+ */
548
+ export function routeThrough(device, policy, route) {
549
+ /** @type {PolicyStep[]} */
550
+ const steps = [];
551
+ /** @type {Attributes} */
552
+ const attributes = { prepend: 0, communities: [...route.communities], largeCommunities: [...route.largeCommunities] };
553
+
554
+ /**
555
+ * @param {string} name
556
+ * @param {number} depth
557
+ * @returns {"accept" | "reject" | "unknown" | undefined}
558
+ */
559
+ const run = (name, depth) => {
560
+ const rules = device.policies?.[name] ?? [];
561
+ if (depth > 16) return "unknown";
562
+
563
+ for (const [index, rule] of rules.entries()) {
564
+ const step = { policy: name, rule: index + 1, description: rule.description };
565
+
566
+ if (rule.call) {
567
+ const called = run(rule.call, depth + 1);
568
+ if (called) {
569
+ steps.push({ ...step, call: rule.call, check: { answer: "yes", why: [] }, changed: [], decision: called });
570
+ return called;
571
+ }
572
+ }
573
+
574
+ const current = { ...route, communities: attributes.communities, largeCommunities: attributes.largeCommunities };
575
+ const check = routeMeets(rule.match ?? {}, current, device);
576
+
577
+ if (check.answer === "unknown") {
578
+ steps.push({ ...step, ...(rule.call ? { call: rule.call } : {}), check, changed: [] });
579
+ return "unknown";
580
+ }
581
+
582
+ if (check.answer === "no") {
583
+ steps.push({ ...step, ...(rule.call ? { call: rule.call } : {}), check, changed: [] });
584
+ continue;
585
+ }
586
+
587
+ const changed = apply(rule.set ?? {}, attributes);
588
+ steps.push({
589
+ ...step,
590
+ ...(rule.call ? { call: rule.call } : {}),
591
+ check,
592
+ changed,
593
+ ...(rule.action ? { decision: rule.action } : {}),
594
+ });
595
+ if (rule.action) return rule.action;
596
+ }
597
+
598
+ return undefined;
599
+ };
600
+
601
+ const decided = run(policy, 0);
602
+ return { steps, decision: decided ?? "reject", byDefault: decided === undefined, attributes };
603
+ }
604
+
605
+ // What changed between two networks.
606
+
607
+ /**
608
+ * An edge's place in the graph, the same whichever way round it was written, to match it across two networks.
609
+ *
610
+ * @param {GraphEdge} edge
611
+ */
612
+ export const changeKey = (edge) => `${edge.layer} ${[edge.from, edge.to].sort().join(" ")} ${edge.label}`;
613
+
614
+ /**
615
+ * What differs between the network before and after: devices added, removed or rendered differently; lines of the graph added or gone; and what validate finds newly or no longer.
616
+ *
617
+ * @param {StudioData} before
618
+ * @param {StudioData} after
619
+ */
620
+ export function compareNetworks(before, after) {
621
+ const names = (/** @type {StudioData} */ network) => new Set(network.devices.map((device) => device.name));
622
+ const was = names(before);
623
+ const is = names(after);
624
+
625
+ const added = [...is].filter((name) => !was.has(name));
626
+ const removed = [...was].filter((name) => !is.has(name));
627
+ const changed = [...is].filter((name) => was.has(name) && before.rendered[name] !== after.rendered[name]);
628
+
629
+ const keys = (/** @type {StudioData} */ network) => new Set(network.graph.edges.map(changeKey));
630
+ const edgesBefore = keys(before);
631
+ const edgesAfter = keys(after);
632
+
633
+ const finding = (/** @type {import("../../core/define").Finding} */ one) => `${one.level} ${one.device ?? ""} ${one.message}`;
634
+ const findingsBefore = new Set(before.graph.findings.map(finding));
635
+ const findingsAfter = new Set(after.graph.findings.map(finding));
636
+
637
+ return {
638
+ devices: { added, removed, changed },
639
+ edges: {
640
+ added: after.graph.edges.filter((edge) => !edgesBefore.has(changeKey(edge))),
641
+ removed: before.graph.edges.filter((edge) => !edgesAfter.has(changeKey(edge))),
642
+ },
643
+ findings: {
644
+ added: after.graph.findings.filter((one) => !findingsBefore.has(finding(one))),
645
+ resolved: before.graph.findings.filter((one) => !findingsAfter.has(finding(one))),
646
+ },
647
+ };
648
+ }