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