@takodotid/circuit 0.34.3 → 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 +11345 -9024
  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 -26
  211. package/src/adapters/devices/raisecom-ros/index.ts +0 -700
  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,577 +2,609 @@
2
2
  /// <reference lib="dom" />
3
3
  // Studio's tools, for finding out why something does or does not reach where it should: trace an address, find the VLAN path between two devices, test a packet against a firewall, and test a route against a route policy.
4
4
 
5
- import { go, h, link } from "./dom.js";
6
- import { filterPacket, routeThrough, trace, vlanPath } from "./evaluate.js";
7
- import { lightPath } from "./graph.js";
8
- import { deviceLink, empty, heading, prose, table } from "./tables.js";
5
+ import { Dom } from "./dom.js";
6
+ import { Evaluate } from "./evaluate.js";
7
+ import { Graph } from "./graph.js";
8
+ import { Tables } from "./tables.js";
9
9
 
10
10
  /** @typedef {import("../data").StudioData} StudioData */
11
11
  /** @typedef {import("../../schema").Device} Device */
12
12
  /** @typedef {import("./dom.js").Child} Child */
13
13
  /** @typedef {import("./evaluate.js").Check} Check */
14
14
 
15
- /** What Studio shows, set each time a tool is drawn. @type {StudioData} */
16
- let data;
17
-
18
- /** What was typed into each tool, kept while the page is open, so a tool drawn again shows its last answer. */
19
- const typed = {
20
- vlan: { vlan: "", from: "", to: "" },
21
- firewall: {
22
- device: "",
23
- chain: "input",
24
- protocol: "tcp",
25
- src: "",
26
- dst: "",
27
- srcPort: "",
28
- dstPort: "",
29
- inInterface: "",
30
- outInterface: "",
31
- state: "new",
32
- flags: /** @type {string[]} */ ([]),
33
- },
34
- policy: {
35
- device: "",
36
- policy: "",
37
- prefix: "",
38
- asPathLength: "",
39
- rpki: "",
40
- communities: "",
41
- largeCommunities: "",
42
- inRegistrySets: /** @type {string[]} */ ([]),
43
- },
44
- };
45
-
46
- const TOOLS = [
47
- ["trace", "Trace an address"],
48
- ["vlan", "VLAN path"],
49
- ["firewall", "Test a firewall"],
50
- ["policy", "Test a route policy"],
51
- ];
52
-
53
- /**
54
- * A labelled field.
55
- *
56
- * @param {string} label
57
- * @param {HTMLElement} control
58
- * @param {string} [hint]
59
- */
60
- const field = (label, control, hint) =>
61
- h("label", { class: "field" }, h("span", {}, label), control, hint ? h("small", { class: "muted" }, hint) : undefined);
62
-
63
- /**
64
- * A text box that keeps what is typed in `store[key]`.
65
- *
66
- * @param {Record<string, unknown>} store
67
- * @param {string} key
68
- * @param {string} [placeholder]
69
- */
70
- function textBox(store, key, placeholder = "") {
71
- const input = h("input", { type: "text", value: String(store[key] ?? ""), placeholder, spellcheck: "false" });
72
- input.addEventListener("input", () => (store[key] = input.value));
73
- return input;
74
- }
75
-
76
- /**
77
- * A choice that keeps what is chosen in `store[key]`.
78
- *
79
- * @param {Record<string, unknown>} store
80
- * @param {string} key
81
- * @param {[string, string][]} options value and text
82
- * @param {() => void} [changed]
83
- */
84
- function choice(store, key, options, changed) {
85
- const select = h(
86
- "select",
87
- {},
88
- ...options.map(([value, text]) => h("option", { value, ...(value === store[key] ? { selected: "" } : {}) }, text))
89
- );
90
- if (!options.some(([value]) => value === store[key])) store[key] = options[0]?.[0] ?? "";
91
- select.value = String(store[key]);
92
- select.addEventListener("change", () => {
93
- store[key] = select.value;
94
- changed?.();
95
- });
96
- return select;
97
- }
15
+ export class Tools {
16
+ /** What Studio shows, set each time a tool is drawn. @type {StudioData} */
17
+ static #data;
18
+
19
+ /** What was typed into each tool, kept while the page is open, so a tool drawn again shows its last answer. */
20
+ static #typed = {
21
+ vlan: { vlan: "", from: "", to: "" },
22
+ firewall: {
23
+ device: "",
24
+ chain: "input",
25
+ protocol: "tcp",
26
+ src: "",
27
+ dst: "",
28
+ srcPort: "",
29
+ dstPort: "",
30
+ inInterface: "",
31
+ outInterface: "",
32
+ state: "new",
33
+ flags: /** @type {string[]} */ ([]),
34
+ },
35
+ policy: {
36
+ device: "",
37
+ policy: "",
38
+ prefix: "",
39
+ asPathLength: "",
40
+ rpki: "",
41
+ communities: "",
42
+ largeCommunities: "",
43
+ inRegistrySets: /** @type {string[]} */ ([]),
44
+ },
45
+ };
98
46
 
99
- /**
100
- * What a check found, in words.
101
- *
102
- * @param {Check} check
103
- */
104
- const verdictOf = (check) =>
105
- check.answer === "yes"
106
- ? "matches"
107
- : check.answer === "no"
108
- ? `does not match: ${check.why.join("; ")}`
109
- : `cannot tell without ${check.why.join(", ")}`;
110
-
111
- /**
112
- * @param {string} text
113
- * @param {() => void} action
114
- */
115
- function button(text, action) {
116
- const element = h("button", { type: "submit", class: "primary" }, text);
117
- element.addEventListener("click", (event) => {
118
- event.preventDefault();
119
- action();
120
- });
121
- return element;
122
- }
47
+ static #TOOLS = [
48
+ ["trace", "Trace an address"],
49
+ ["vlan", "VLAN path"],
50
+ ["firewall", "Test a firewall"],
51
+ ["policy", "Test a route policy"],
52
+ ];
53
+
54
+ /**
55
+ * A labelled field.
56
+ *
57
+ * @param {string} label
58
+ * @param {HTMLElement} control
59
+ * @param {string} [hint]
60
+ */
61
+ static #field(label, control, hint) {
62
+ return Dom.h(
63
+ "label",
64
+ { class: "field" },
65
+ Dom.h("span", {}, label),
66
+ control,
67
+ hint ? Dom.h("small", { class: "muted" }, hint) : undefined
68
+ );
69
+ }
123
70
 
124
- // Trace an address.
125
-
126
- /**
127
- * @param {string | undefined} asked
128
- * @param {HTMLElement} container
129
- */
130
- function traceTool(asked, container) {
131
- const store = { address: asked ?? "" };
132
- const input = textBox(store, "address", "10.0.10.5");
133
-
134
- container.append(
135
- prose(
136
- "Where an address is in the network, how each device would reach it from what the files say, and every firewall rule and ACL that names it."
137
- ),
138
- h(
139
- "form",
140
- { class: "tool-form" },
141
- field("Address", input),
142
- button("Trace", () => store.address.trim() && go(`tools/trace/${encodeURIComponent(store.address.trim())}`))
143
- )
144
- );
145
-
146
- if (!asked) return;
147
-
148
- const found = trace(data, asked);
149
- if (!found) {
150
- container.append(empty(`${asked} is not an address, such as 10.0.10.5 or 2001:db8::1.`));
151
- return;
71
+ /**
72
+ * A text box that keeps what is typed in `store[key]`.
73
+ *
74
+ * @param {Record<string, unknown>} store
75
+ * @param {string} key
76
+ * @param {string} [placeholder]
77
+ */
78
+ static #textBox(store, key, placeholder = "") {
79
+ const input = Dom.h("input", { type: "text", value: String(store[key] ?? ""), placeholder, spellcheck: "false" });
80
+ input.addEventListener("input", () => (store[key] = input.value));
81
+ return input;
152
82
  }
153
83
 
154
- const holder = found.held.length
155
- ? h(
156
- "span",
157
- {},
158
- "It is held by ",
159
- ...found.held.flatMap((held, index) => [index ? ", " : "", deviceLink(held.device), ` ${held.where}`]),
160
- "."
161
- )
162
- : "No device holds it.";
163
- const where = found.segment
164
- ? `It is in ${found.segment.prefix}${found.space ? `, inside ${found.space.prefix}, ${found.space.owner}` : ""}.`
165
- : found.block
166
- ? `It is in ${found.block.prefix}: ${found.block.what}.`
167
- : found.space
168
- ? `It is in ${found.space.prefix}, ${found.space.owner}, where nothing uses it yet.`
169
- : "No segment or block of the network holds it.";
170
-
171
- container.append(heading(asked, "h2"), h("p", {}, holder, " ", where));
172
-
173
- if (found.uses.length) {
174
- container.append(
175
- heading("Used by"),
176
- table({
177
- head: [{ label: "Address" }, { label: "Used by" }],
178
- rows: found.uses.map((use) => ({ cells: [use.address, use.what] })),
179
- })
84
+ /**
85
+ * A choice that keeps what is chosen in `store[key]`.
86
+ *
87
+ * @param {Record<string, unknown>} store
88
+ * @param {string} key
89
+ * @param {[string, string][]} options value and text
90
+ * @param {() => void} [changed]
91
+ */
92
+ static #choice(store, key, options, changed) {
93
+ const select = Dom.h(
94
+ "select",
95
+ {},
96
+ ...options.map(([value, text]) => Dom.h("option", { value, ...(value === store[key] ? { selected: "" } : {}) }, text))
180
97
  );
98
+ if (!options.some(([value]) => value === store[key])) store[key] = options[0]?.[0] ?? "";
99
+ select.value = String(store[key]);
100
+ select.addEventListener("change", () => {
101
+ store[key] = select.value;
102
+ changed?.();
103
+ });
104
+ return select;
181
105
  }
182
106
 
183
- const kinds = {
184
- local: "it is its own address",
185
- connected: "an address of its own is in the same prefix",
186
- static: "a static route",
187
- none: "no route in the files",
188
- };
107
+ /**
108
+ * What a check found, in words.
109
+ *
110
+ * @param {Check} check
111
+ */
112
+ static #verdictOf(check) {
113
+ return check.answer === "yes"
114
+ ? "matches"
115
+ : check.answer === "no"
116
+ ? `does not match: ${check.why.join("; ")}`
117
+ : `cannot tell without ${check.why.join(", ")}`;
118
+ }
189
119
 
190
- container.append(
191
- heading("How each device reaches it"),
192
- prose("Routes a device learns from BGP or OSPF are not in the files, so a device with no route here may still reach it."),
193
- table({
194
- csv: `trace-${asked}`,
195
- head: ["Device", "Table", "By", "Prefix", "Next hop or interface", "Note"].map((label) => ({ label })),
196
- rows: found.routes.map((route) => ({
197
- muted: route.kind === "none",
198
- cells: [
199
- deviceLink(route.device),
200
- route.table,
201
- kinds[route.kind],
202
- route.prefix ?? "",
203
- [route.via, route.through].filter(Boolean).join(", "),
204
- route.note ?? "",
205
- ],
206
- })),
207
- })
208
- );
209
-
210
- container.append(heading("Rules that name it"));
211
- container.append(
212
- found.rules.length
213
- ? table({
214
- head: ["Device", "Where", "Rule", "Description", "Action", "Names it as"].map((label) => ({ label })),
215
- rows: found.rules.map((rule) => ({
216
- cells: [deviceLink(rule.device), rule.where, String(rule.rule), rule.description ?? "", rule.action, rule.side],
217
- })),
218
- })
219
- : empty("No firewall rule or ACL names it, by address, prefix or address set.")
220
- );
221
- }
120
+ /**
121
+ * @param {string} text
122
+ * @param {() => void} action
123
+ */
124
+ static #button(text, action) {
125
+ const element = Dom.h("button", { type: "submit", class: "primary" }, text);
126
+ element.addEventListener("click", (event) => {
127
+ event.preventDefault();
128
+ action();
129
+ });
130
+ return element;
131
+ }
222
132
 
223
- // The VLAN path between two devices.
224
-
225
- /** @param {HTMLElement} container */
226
- function vlanTool(container) {
227
- const store = typed.vlan;
228
- const vlans = new Map();
229
- for (const edge of data.graph.edges) for (const vlan of edge.vlans ?? []) vlans.set(vlan.id, vlan.name);
230
-
231
- const devices = data.devices.map((device) => /** @type {[string, string]} */ ([device.name, device.name]));
232
- const vlanChoices = [...vlans]
233
- .sort(([a], [b]) => a - b)
234
- .map(([id, name]) => /** @type {[string, string]} */ ([String(id), `${name} (${id})`]));
235
-
236
- const result = h("div");
237
- const find = () => {
238
- result.replaceChildren();
239
-
240
- const path = vlanPath(data, Number(store.vlan), store.from, store.to);
241
- if (store.from === store.to) return result.append(empty("Pick two different devices."));
242
- if (!path) return result.append(empty(`No cables carry VLAN ${store.vlan} from ${store.from} to ${store.to}.`));
243
-
244
- const cableOf = (/** @type {import("../../core/graph").GraphEdge} */ edge) =>
245
- data.graph.edges.find(
246
- (other) =>
247
- other.layer === "l1" &&
248
- ((other.from === edge.from && other.to === edge.to) || (other.from === edge.to && other.to === edge.from))
249
- )?.label ?? "";
250
- const nameOf = (/** @type {string} */ id) => id.replace(/^device:/, "");
251
-
252
- result.append(
253
- h(
254
- "p",
255
- {},
256
- `${path.length} ${path.length === 1 ? "cable" : "cables"} carry it from ${store.from} to ${store.to}. `,
257
- link("Show in the graph", () => {
258
- lightPath(path);
259
- go("graph/l2");
260
- })
133
+ // Trace an address.
134
+
135
+ /**
136
+ * @param {string | undefined} asked
137
+ * @param {HTMLElement} container
138
+ */
139
+ static #trace(asked, container) {
140
+ const store = { address: asked ?? "" };
141
+ const input = Tools.#textBox(store, "address", "10.0.10.5");
142
+
143
+ container.append(
144
+ Tables.prose(
145
+ "Where an address is in the network, how each device would reach it from what the files say, and every firewall rule and ACL that names it."
261
146
  ),
262
- table({
263
- head: ["#", "From", "To", "Cable"].map((label) => ({ label })),
264
- rows: path.map((edge, index) => ({
265
- cells: [String(index + 1), deviceLink(nameOf(edge.from)), deviceLink(nameOf(edge.to)), cableOf(edge)],
266
- })),
267
- })
147
+ Dom.h(
148
+ "form",
149
+ { class: "tool-form" },
150
+ Tools.#field("Address", input),
151
+ Tools.#button("Trace", () => store.address.trim() && Dom.go(`tools/trace/${encodeURIComponent(store.address.trim())}`))
152
+ )
268
153
  );
269
- };
270
154
 
271
- if (!vlanChoices.length) return container.append(empty("No cable carries a VLAN."));
272
-
273
- container.append(
274
- prose("The fewest cables that carry a VLAN from one device to another, from the ports' VLANs and links."),
275
- h(
276
- "form",
277
- { class: "tool-form" },
278
- field("VLAN", choice(store, "vlan", vlanChoices)),
279
- field("From", choice(store, "from", devices)),
280
- field("To", choice(store, "to", devices)),
281
- button("Find the path", find)
282
- ),
283
- result
284
- );
285
- }
155
+ if (!asked) return;
286
156
 
287
- // Test a firewall.
288
-
289
- /** @param {HTMLElement} container */
290
- function firewallTool(container) {
291
- const store = typed.firewall;
292
- const devices = data.devices.filter((device) => Object.values(device.firewall?.filter ?? {}).some(Boolean));
293
- if (!devices.length) return container.append(empty("No device has a firewall filter."));
294
-
295
- const result = h("div");
296
- const form = h("form", { class: "tool-form" });
297
-
298
- const draw = () => {
299
- form.replaceChildren();
300
- const device = devices.find((candidate) => candidate.name === store.device) ?? /** @type {Device} */ (devices[0]);
301
- const chains = /** @type {[string, string][]} */ (Object.keys(device.firewall?.filter ?? {}).map((chain) => [chain, chain]));
302
- const names = /** @type {[string, string][]} */ ([
303
- ["", "not given"],
304
- ...Object.keys({ ...device.ports, ...device.interfaces }).map((name) => [name, name]),
305
- ]);
306
- const protocols = /** @type {[string, string][]} */ (
307
- ["tcp", "udp", "icmp", "gre", "esp", "ah", "ospf", "vrrp"].map((name) => [name, name])
308
- );
309
- const states = /** @type {[string, string][]} */ (
310
- ["new", "established", "related", "untracked", "invalid"].map((name) => [name, name])
311
- );
157
+ const found = Evaluate.trace(Tools.#data, asked);
158
+ if (!found) {
159
+ container.append(Tables.empty(`${asked} is not an address, such as 10.0.10.5 or 2001:db8::1.`));
160
+ return;
161
+ }
312
162
 
313
- const flags = h("span", { class: "flags" });
314
- for (const flag of ["syn", "ack", "fin", "rst", "psh", "urg"]) {
315
- const box = h("input", { type: "checkbox", ...(store.flags.includes(flag) ? { checked: "" } : {}) });
316
- box.addEventListener(
317
- "change",
318
- () => (store.flags = box.checked ? [...store.flags, flag] : store.flags.filter((other) => other !== flag))
163
+ const holder = found.held.length
164
+ ? Dom.h(
165
+ "span",
166
+ {},
167
+ "It is held by ",
168
+ ...found.held.flatMap((held, index) => [index ? ", " : "", Tables.deviceLink(held.device), ` ${held.where}`]),
169
+ "."
170
+ )
171
+ : "No device holds it.";
172
+ const where = found.segment
173
+ ? `It is in ${found.segment.prefix}${found.space ? `, inside ${found.space.prefix}, ${found.space.owner}` : ""}.`
174
+ : found.block
175
+ ? `It is in ${found.block.prefix}: ${found.block.what}.`
176
+ : found.space
177
+ ? `It is in ${found.space.prefix}, ${found.space.owner}, where nothing uses it yet.`
178
+ : "No segment or block of the network holds it.";
179
+
180
+ container.append(Tables.heading(asked, "h2"), Dom.h("p", {}, holder, " ", where));
181
+
182
+ if (found.uses.length) {
183
+ container.append(
184
+ Tables.heading("Used by"),
185
+ Tables.of({
186
+ head: [{ label: "Address" }, { label: "Used by" }],
187
+ rows: found.uses.map((use) => ({ cells: [use.address, use.what] })),
188
+ })
319
189
  );
320
- flags.append(h("label", { class: "flag" }, box, flag));
321
190
  }
322
191
 
323
- form.append(
324
- field(
325
- "Device",
326
- choice(
327
- store,
328
- "device",
329
- devices.map((candidate) => [candidate.name, candidate.name]),
330
- draw
331
- )
192
+ const kinds = {
193
+ local: "it is its own address",
194
+ connected: "an address of its own is in the same prefix",
195
+ static: "a static route",
196
+ none: "no route in the files",
197
+ };
198
+
199
+ container.append(
200
+ Tables.heading("How each device reaches it"),
201
+ Tables.prose(
202
+ "Routes a device learns from BGP or OSPF are not in the files, so a device with no route here may still reach it."
332
203
  ),
333
- field("Chain", choice(store, "chain", chains)),
334
- field("Protocol", choice(store, "protocol", protocols)),
335
- field("Source", textBox(store, "src", "203.0.113.9")),
336
- field("Destination", textBox(store, "dst", "192.0.2.1")),
337
- field("Source port", textBox(store, "srcPort")),
338
- field("Destination port", textBox(store, "dstPort", "22")),
339
- field("Arrives on", choice(store, "inInterface", names)),
340
- field("Leaves through", choice(store, "outInterface", names)),
341
- field("State", choice(store, "state", states)),
342
- field("TCP flags", flags, "Leave all unticked when not known."),
343
- button("Test", test)
204
+ Tables.of({
205
+ csv: `trace-${asked}`,
206
+ head: ["Device", "Table", "By", "Prefix", "Next hop or interface", "Note"].map((label) => ({ label })),
207
+ rows: found.routes.map((route) => ({
208
+ muted: route.kind === "none",
209
+ cells: [
210
+ Tables.deviceLink(route.device),
211
+ route.table,
212
+ kinds[route.kind],
213
+ route.prefix ?? "",
214
+ [route.via, route.through].filter(Boolean).join(", "),
215
+ route.note ?? "",
216
+ ],
217
+ })),
218
+ })
344
219
  );
345
- };
346
-
347
- const test = () => {
348
- result.replaceChildren();
349
- const device = devices.find((candidate) => candidate.name === store.device) ?? /** @type {Device} */ (devices[0]);
350
- const port = (/** @type {string} */ text) => (text.trim() === "" ? undefined : Number(text));
351
-
352
- const outcome = filterPacket(device, /** @type {"input" | "forward" | "output"} */ (store.chain), {
353
- protocol: store.protocol,
354
- src: store.src.trim() || undefined,
355
- dst: store.dst.trim() || undefined,
356
- srcPort: port(store.srcPort),
357
- dstPort: port(store.dstPort),
358
- inInterface: store.inInterface || undefined,
359
- outInterface: store.outInterface || undefined,
360
- state: store.state,
361
- flags: store.flags.length ? store.flags : undefined,
362
- });
363
220
 
364
- const decided = outcome.verdict
365
- ? h("p", { class: `verdict ${outcome.verdict}` }, `${outcome.verdict}, by ${outcome.by}.`)
366
- : h("p", { class: "verdict unknown" }, `Cannot tell: ${outcome.by} needs more about the packet.`);
367
- const reached = outcome.steps.length;
368
- const rules = device.firewall?.filter?.[/** @type {"input" | "forward" | "output"} */ (store.chain)]?.rules ?? [];
369
-
370
- result.append(
371
- decided,
372
- table({
373
- head: ["Rule", "Description", "Action", "This packet"].map((label) => ({ label })),
374
- rows: rules.map((rule, index) => {
375
- const step = outcome.steps[index];
376
- return {
377
- muted: index >= reached,
378
- cells: [String(index + 1), rule.description ?? "", rule.action, step ? verdictOf(step.check) : "not reached"],
379
- };
380
- }),
381
- })
221
+ container.append(Tables.heading("Rules that name it"));
222
+ container.append(
223
+ found.rules.length
224
+ ? Tables.of({
225
+ head: ["Device", "Where", "Rule", "Description", "Action", "Names it as"].map((label) => ({ label })),
226
+ rows: found.rules.map((rule) => ({
227
+ cells: [
228
+ Tables.deviceLink(rule.device),
229
+ rule.where,
230
+ String(rule.rule),
231
+ rule.description ?? "",
232
+ rule.action,
233
+ rule.side,
234
+ ],
235
+ })),
236
+ })
237
+ : Tables.empty("No firewall rule or ACL names it, by address, prefix or address set.")
382
238
  );
383
- };
239
+ }
384
240
 
385
- draw();
386
- container.append(
387
- prose("Which rule of a device's filter chain a packet meets, first match first, as the schema describes each condition."),
388
- form,
389
- result
390
- );
391
- }
241
+ // The VLAN path between two devices.
242
+
243
+ /** @param {HTMLElement} container */
244
+ static #vlan(container) {
245
+ const store = Tools.#typed.vlan;
246
+ const vlans = new Map();
247
+ for (const edge of Tools.#data.graph.edges) for (const vlan of edge.vlans ?? []) vlans.set(vlan.id, vlan.name);
248
+
249
+ const devices = Tools.#data.devices.map((device) => /** @type {[string, string]} */ ([device.name, device.name]));
250
+ const vlanChoices = [...vlans]
251
+ .sort(([a], [b]) => a - b)
252
+ .map(([id, name]) => /** @type {[string, string]} */ ([String(id), `${name} (${id})`]));
253
+
254
+ const result = Dom.h("div");
255
+ const find = () => {
256
+ result.replaceChildren();
257
+
258
+ const path = Evaluate.vlanPath(Tools.#data, Number(store.vlan), store.from, store.to);
259
+ if (store.from === store.to) return result.append(Tables.empty("Pick two different devices."));
260
+ if (!path) return result.append(Tables.empty(`No cables carry VLAN ${store.vlan} from ${store.from} to ${store.to}.`));
261
+
262
+ const cableOf = (/** @type {import("../../core/topology").GraphEdge} */ edge) =>
263
+ Tools.#data.graph.edges.find(
264
+ (other) =>
265
+ other.layer === "l1" &&
266
+ ((other.from === edge.from && other.to === edge.to) || (other.from === edge.to && other.to === edge.from))
267
+ )?.label ?? "";
268
+ const nameOf = (/** @type {string} */ id) => id.replace(/^device:/, "");
269
+
270
+ result.append(
271
+ Dom.h(
272
+ "p",
273
+ {},
274
+ `${path.length} ${path.length === 1 ? "cable" : "cables"} carry it from ${store.from} to ${store.to}. `,
275
+ Dom.link("Show in the graph", () => {
276
+ Graph.lightPath(path);
277
+ Dom.go("graph/l2");
278
+ })
279
+ ),
280
+ Tables.of({
281
+ head: ["#", "From", "To", "Cable"].map((label) => ({ label })),
282
+ rows: path.map((edge, index) => ({
283
+ cells: [String(index + 1), Tables.deviceLink(nameOf(edge.from)), Tables.deviceLink(nameOf(edge.to)), cableOf(edge)],
284
+ })),
285
+ })
286
+ );
287
+ };
392
288
 
393
- // Test a route policy.
394
-
395
- /** @param {HTMLElement} container */
396
- function policyTool(container) {
397
- const store = typed.policy;
398
- const devices = data.devices.filter((device) => Object.keys(device.policies ?? {}).length);
399
- if (!devices.length) return container.append(empty("No device has a route policy."));
400
-
401
- const result = h("div");
402
- const form = h("form", { class: "tool-form" });
403
-
404
- const draw = () => {
405
- form.replaceChildren();
406
- const device = devices.find((candidate) => candidate.name === store.device) ?? /** @type {Device} */ (devices[0]);
407
- const neighbors = Object.entries(device.routing?.bgp?.neighbors ?? {});
408
- const groups = device.routing?.bgp?.groups ?? {};
409
-
410
- // A policy is listed with the sessions that use it, so it can be picked by neighbor.
411
- const usedBy = (/** @type {string} */ policy) =>
412
- neighbors.flatMap(([name, neighbor]) => {
413
- const group = neighbor.group ? groups[neighbor.group] : undefined;
414
- return [
415
- (neighbor.import ?? group?.import) === policy ? `import from ${name}` : "",
416
- (neighbor.export ?? group?.export) === policy ? `export to ${name}` : "",
417
- ].filter(Boolean);
418
- });
289
+ if (!vlanChoices.length) return container.append(Tables.empty("No cable carries a VLAN."));
419
290
 
420
- const policies = /** @type {[string, string][]} */ (
421
- Object.keys(device.policies ?? {}).map((name) => {
422
- const uses = usedBy(name);
423
- return [name, uses.length ? `${name}: ${uses.join(", ")}` : name];
424
- })
291
+ container.append(
292
+ Tables.prose("The fewest cables that carry a VLAN from one device to another, from the ports' VLANs and links."),
293
+ Dom.h(
294
+ "form",
295
+ { class: "tool-form" },
296
+ Tools.#field("VLAN", Tools.#choice(store, "vlan", vlanChoices)),
297
+ Tools.#field("From", Tools.#choice(store, "from", devices)),
298
+ Tools.#field("To", Tools.#choice(store, "to", devices)),
299
+ Tools.#button("Find the path", find)
300
+ ),
301
+ result
425
302
  );
303
+ }
426
304
 
427
- const registrySets = Object.entries(device.prefix_sets ?? {}).filter(([, set]) => "source" in set);
428
- const sets = h("span", { class: "flags" });
429
- for (const [name] of registrySets) {
430
- const box = h("input", { type: "checkbox", ...(store.inRegistrySets.includes(name) ? { checked: "" } : {}) });
431
- box.addEventListener(
432
- "change",
433
- () =>
434
- (store.inRegistrySets = box.checked
435
- ? [...store.inRegistrySets, name]
436
- : store.inRegistrySets.filter((other) => other !== name))
305
+ // Test a firewall.
306
+
307
+ /** @param {HTMLElement} container */
308
+ static #firewall(container) {
309
+ const store = Tools.#typed.firewall;
310
+ const devices = Tools.#data.devices.filter((device) => Object.values(device.firewall?.filter ?? {}).some(Boolean));
311
+ if (!devices.length) return container.append(Tables.empty("No device has a firewall filter."));
312
+
313
+ const result = Dom.h("div");
314
+ const form = Dom.h("form", { class: "tool-form" });
315
+
316
+ const draw = () => {
317
+ form.replaceChildren();
318
+ const device = devices.find((candidate) => candidate.name === store.device) ?? /** @type {Device} */ (devices[0]);
319
+ const chains = /** @type {[string, string][]} */ (Object.keys(device.firewall?.filter ?? {}).map((chain) => [chain, chain]));
320
+ const names = /** @type {[string, string][]} */ ([
321
+ ["", "not given"],
322
+ ...Object.keys({ ...device.ports, ...device.interfaces }).map((name) => [name, name]),
323
+ ]);
324
+ const protocols = /** @type {[string, string][]} */ (
325
+ ["tcp", "udp", "icmp", "gre", "esp", "ah", "ospf", "vrrp"].map((name) => [name, name])
326
+ );
327
+ const states = /** @type {[string, string][]} */ (
328
+ ["new", "established", "related", "untracked", "invalid"].map((name) => [name, name])
437
329
  );
438
- sets.append(h("label", { class: "flag" }, box, name));
439
- }
440
330
 
441
- form.append(
442
- field(
443
- "Device",
444
- choice(
445
- store,
446
- "device",
447
- devices.map((candidate) => [candidate.name, candidate.name]),
448
- draw
449
- )
450
- ),
451
- field("Policy", choice(store, "policy", policies)),
452
- field("Prefix", textBox(store, "prefix", "198.51.100.0/24")),
453
- field("AS path length", textBox(store, "asPathLength"), "As the route arrives."),
454
- field(
455
- "RPKI",
456
- choice(store, "rpki", [
457
- ["", "not known"],
458
- ["valid", "valid"],
459
- ["invalid", "invalid"],
460
- ["not-found", "not found"],
461
- ])
331
+ const flags = Dom.h("span", { class: "flags" });
332
+ for (const flag of ["syn", "ack", "fin", "rst", "psh", "urg"]) {
333
+ const box = Dom.h("input", { type: "checkbox", ...(store.flags.includes(flag) ? { checked: "" } : {}) });
334
+ box.addEventListener(
335
+ "change",
336
+ () => (store.flags = box.checked ? [...store.flags, flag] : store.flags.filter((other) => other !== flag))
337
+ );
338
+ flags.append(Dom.h("label", { class: "flag" }, box, flag));
339
+ }
340
+
341
+ form.append(
342
+ Tools.#field(
343
+ "Device",
344
+ Tools.#choice(
345
+ store,
346
+ "device",
347
+ devices.map((candidate) => [candidate.name, candidate.name]),
348
+ draw
349
+ )
350
+ ),
351
+ Tools.#field("Chain", Tools.#choice(store, "chain", chains)),
352
+ Tools.#field("Protocol", Tools.#choice(store, "protocol", protocols)),
353
+ Tools.#field("Source", Tools.#textBox(store, "src", "203.0.113.9")),
354
+ Tools.#field("Destination", Tools.#textBox(store, "dst", "192.0.2.1")),
355
+ Tools.#field("Source port", Tools.#textBox(store, "srcPort")),
356
+ Tools.#field("Destination port", Tools.#textBox(store, "dstPort", "22")),
357
+ Tools.#field("Arrives on", Tools.#choice(store, "inInterface", names)),
358
+ Tools.#field("Leaves through", Tools.#choice(store, "outInterface", names)),
359
+ Tools.#field("State", Tools.#choice(store, "state", states)),
360
+ Tools.#field("TCP flags", flags, "Leave all unticked when not known."),
361
+ Tools.#button("Test", test)
362
+ );
363
+ };
364
+
365
+ const test = () => {
366
+ result.replaceChildren();
367
+ const device = devices.find((candidate) => candidate.name === store.device) ?? /** @type {Device} */ (devices[0]);
368
+ const port = (/** @type {string} */ text) => (text.trim() === "" ? undefined : Number(text));
369
+
370
+ const outcome = Evaluate.filterPacket(device, /** @type {"input" | "forward" | "output"} */ (store.chain), {
371
+ protocol: store.protocol,
372
+ src: store.src.trim() || undefined,
373
+ dst: store.dst.trim() || undefined,
374
+ srcPort: port(store.srcPort),
375
+ dstPort: port(store.dstPort),
376
+ inInterface: store.inInterface || undefined,
377
+ outInterface: store.outInterface || undefined,
378
+ state: store.state,
379
+ flags: store.flags.length ? store.flags : undefined,
380
+ });
381
+
382
+ const decided = outcome.verdict
383
+ ? Dom.h("p", { class: `verdict ${outcome.verdict}` }, `${outcome.verdict}, by ${outcome.by}.`)
384
+ : Dom.h("p", { class: "verdict unknown" }, `Cannot tell: ${outcome.by} needs more about the packet.`);
385
+ const reached = outcome.steps.length;
386
+ const rules = device.firewall?.filter?.[/** @type {"input" | "forward" | "output"} */ (store.chain)]?.rules ?? [];
387
+
388
+ result.append(
389
+ decided,
390
+ Tables.of({
391
+ head: ["Rule", "Description", "Action", "This packet"].map((label) => ({ label })),
392
+ rows: rules.map((rule, index) => {
393
+ const step = outcome.steps[index];
394
+ return {
395
+ muted: index >= reached,
396
+ cells: [
397
+ String(index + 1),
398
+ rule.description ?? "",
399
+ rule.action,
400
+ step ? Tools.#verdictOf(step.check) : "not reached",
401
+ ],
402
+ };
403
+ }),
404
+ })
405
+ );
406
+ };
407
+
408
+ draw();
409
+ container.append(
410
+ Tables.prose(
411
+ "Which rule of a device's filter chain a packet meets, first match first, as the schema describes each condition."
462
412
  ),
463
- field("Communities", textBox(store, "communities", "64500:100"), "Separated by spaces or commas."),
464
- field("Large communities", textBox(store, "largeCommunities", "64500:1:1")),
465
- ...(registrySets.length
466
- ? [field("In prefix sets from a registry", sets, "Their prefixes are fetched by circuit refresh and are not in the files.")]
467
- : []),
468
- button("Test", test)
413
+ form,
414
+ result
469
415
  );
470
- };
416
+ }
471
417
 
472
- const test = () => {
473
- result.replaceChildren();
474
- const device = devices.find((candidate) => candidate.name === store.device) ?? /** @type {Device} */ (devices[0]);
475
- const list = (/** @type {string} */ text) => text.split(/[\s,]+/).filter(Boolean);
418
+ // Test a route policy.
419
+
420
+ /** @param {HTMLElement} container */
421
+ static #policy(container) {
422
+ const store = Tools.#typed.policy;
423
+ const devices = Tools.#data.devices.filter((device) => Object.keys(device.policies ?? {}).length);
424
+ if (!devices.length) return container.append(Tables.empty("No device has a route policy."));
425
+
426
+ const result = Dom.h("div");
427
+ const form = Dom.h("form", { class: "tool-form" });
428
+
429
+ const draw = () => {
430
+ form.replaceChildren();
431
+ const device = devices.find((candidate) => candidate.name === store.device) ?? /** @type {Device} */ (devices[0]);
432
+ const neighbors = Object.entries(device.routing?.bgp?.neighbors ?? {});
433
+ const groups = device.routing?.bgp?.groups ?? {};
434
+
435
+ // A policy is listed with the sessions that use it, so it can be picked by neighbor.
436
+ const usedBy = (/** @type {string} */ policy) =>
437
+ neighbors.flatMap(([name, neighbor]) => {
438
+ const group = neighbor.group ? groups[neighbor.group] : undefined;
439
+ return [
440
+ (neighbor.import ?? group?.import) === policy ? `import from ${name}` : "",
441
+ (neighbor.export ?? group?.export) === policy ? `export to ${name}` : "",
442
+ ].filter(Boolean);
443
+ });
444
+
445
+ const policies = /** @type {[string, string][]} */ (
446
+ Object.keys(device.policies ?? {}).map((name) => {
447
+ const uses = usedBy(name);
448
+ return [name, uses.length ? `${name}: ${uses.join(", ")}` : name];
449
+ })
450
+ );
476
451
 
477
- if (!store.prefix.trim()) return result.append(empty("Give the route's prefix, such as 198.51.100.0/24."));
452
+ const registrySets = Object.entries(device.prefix_sets ?? {}).filter(([, set]) => "source" in set);
453
+ const sets = Dom.h("span", { class: "flags" });
454
+ for (const [name] of registrySets) {
455
+ const box = Dom.h("input", { type: "checkbox", ...(store.inRegistrySets.includes(name) ? { checked: "" } : {}) });
456
+ box.addEventListener(
457
+ "change",
458
+ () =>
459
+ (store.inRegistrySets = box.checked
460
+ ? [...store.inRegistrySets, name]
461
+ : store.inRegistrySets.filter((other) => other !== name))
462
+ );
463
+ sets.append(Dom.h("label", { class: "flag" }, box, name));
464
+ }
465
+
466
+ form.append(
467
+ Tools.#field(
468
+ "Device",
469
+ Tools.#choice(
470
+ store,
471
+ "device",
472
+ devices.map((candidate) => [candidate.name, candidate.name]),
473
+ draw
474
+ )
475
+ ),
476
+ Tools.#field("Policy", Tools.#choice(store, "policy", policies)),
477
+ Tools.#field("Prefix", Tools.#textBox(store, "prefix", "198.51.100.0/24")),
478
+ Tools.#field("AS path length", Tools.#textBox(store, "asPathLength"), "As the route arrives."),
479
+ Tools.#field(
480
+ "RPKI",
481
+ Tools.#choice(store, "rpki", [
482
+ ["", "not known"],
483
+ ["valid", "valid"],
484
+ ["invalid", "invalid"],
485
+ ["not-found", "not found"],
486
+ ])
487
+ ),
488
+ Tools.#field("Communities", Tools.#textBox(store, "communities", "64500:100"), "Separated by spaces or commas."),
489
+ Tools.#field("Large communities", Tools.#textBox(store, "largeCommunities", "64500:1:1")),
490
+ ...(registrySets.length
491
+ ? [
492
+ Tools.#field(
493
+ "In prefix sets from a registry",
494
+ sets,
495
+ "Their prefixes are fetched by circuit refresh and are not in the files."
496
+ ),
497
+ ]
498
+ : []),
499
+ Tools.#button("Test", test)
500
+ );
501
+ };
502
+
503
+ const test = () => {
504
+ result.replaceChildren();
505
+ const device = devices.find((candidate) => candidate.name === store.device) ?? /** @type {Device} */ (devices[0]);
506
+ const list = (/** @type {string} */ text) => text.split(/[\s,]+/).filter(Boolean);
507
+
508
+ if (!store.prefix.trim()) return result.append(Tables.empty("Give the route's prefix, such as 198.51.100.0/24."));
509
+
510
+ const outcome = Evaluate.routeThrough(device, store.policy, {
511
+ prefix: store.prefix.trim(),
512
+ asPathLength: store.asPathLength.trim() === "" ? undefined : Number(store.asPathLength),
513
+ rpki: store.rpki || undefined,
514
+ communities: list(store.communities),
515
+ largeCommunities: list(store.largeCommunities),
516
+ inRegistrySets: store.inRegistrySets,
517
+ });
478
518
 
479
- const outcome = routeThrough(device, store.policy, {
480
- prefix: store.prefix.trim(),
481
- asPathLength: store.asPathLength.trim() === "" ? undefined : Number(store.asPathLength),
482
- rpki: store.rpki || undefined,
483
- communities: list(store.communities),
484
- largeCommunities: list(store.largeCommunities),
485
- inRegistrySets: store.inRegistrySets,
486
- });
519
+ const decided =
520
+ outcome.decision === "unknown"
521
+ ? Dom.h(
522
+ "p",
523
+ { class: "verdict unknown" },
524
+ `Cannot tell: rule ${outcome.steps.at(-1)?.rule} of ${outcome.steps.at(-1)?.policy} needs more about the route.`
525
+ )
526
+ : Dom.h(
527
+ "p",
528
+ { class: `verdict ${outcome.decision}` },
529
+ `${outcome.decision}ed${outcome.byDefault ? ", since no rule accepted it" : ""}.`
530
+ );
531
+
532
+ const { attributes } = outcome;
533
+ const final = [
534
+ attributes.localPref !== undefined ? `local preference ${attributes.localPref}` : "",
535
+ attributes.med !== undefined ? `MED ${attributes.med}` : "",
536
+ attributes.prepend ? `our AS prepended ${attributes.prepend} ${attributes.prepend === 1 ? "time" : "times"}` : "",
537
+ `communities ${attributes.communities.join(", ") || "none"}`,
538
+ `large communities ${attributes.largeCommunities.join(", ") || "none"}`,
539
+ attributes.nextHop ? `next hop ${attributes.nextHop}` : "",
540
+ attributes.preferredSource ? `preferred source ${attributes.preferredSource}` : "",
541
+ attributes.blackhole ? "blackholed" : "",
542
+ ].filter(Boolean);
543
+
544
+ result.append(
545
+ decided,
546
+ Tables.of({
547
+ head: ["Policy", "Rule", "Description", "This route", "Changes", "Decision"].map((label) => ({ label })),
548
+ rows: outcome.steps.map((step) => ({
549
+ cells: [
550
+ step.policy,
551
+ String(step.rule),
552
+ step.description ?? (step.call ? `calls ${step.call}` : ""),
553
+ Tools.#verdictOf(step.check),
554
+ step.changed.join("; "),
555
+ step.decision ?? "",
556
+ ],
557
+ })),
558
+ }),
559
+ ...(outcome.decision === "accept" ? [Dom.h("p", {}, "The route leaves with ", final.join(", "), ".")] : [])
560
+ );
561
+ };
487
562
 
488
- const decided =
489
- outcome.decision === "unknown"
490
- ? h(
491
- "p",
492
- { class: "verdict unknown" },
493
- `Cannot tell: rule ${outcome.steps.at(-1)?.rule} of ${outcome.steps.at(-1)?.policy} needs more about the route.`
494
- )
495
- : h(
496
- "p",
497
- { class: `verdict ${outcome.decision}` },
498
- `${outcome.decision}ed${outcome.byDefault ? ", since no rule accepted it" : ""}.`
499
- );
500
-
501
- const { attributes } = outcome;
502
- const final = [
503
- attributes.localPref !== undefined ? `local preference ${attributes.localPref}` : "",
504
- attributes.med !== undefined ? `MED ${attributes.med}` : "",
505
- attributes.prepend ? `our AS prepended ${attributes.prepend} ${attributes.prepend === 1 ? "time" : "times"}` : "",
506
- `communities ${attributes.communities.join(", ") || "none"}`,
507
- `large communities ${attributes.largeCommunities.join(", ") || "none"}`,
508
- attributes.nextHop ? `next hop ${attributes.nextHop}` : "",
509
- attributes.preferredSource ? `preferred source ${attributes.preferredSource}` : "",
510
- attributes.blackhole ? "blackholed" : "",
511
- ].filter(Boolean);
512
-
513
- result.append(
514
- decided,
515
- table({
516
- head: ["Policy", "Rule", "Description", "This route", "Changes", "Decision"].map((label) => ({ label })),
517
- rows: outcome.steps.map((step) => ({
518
- cells: [
519
- step.policy,
520
- String(step.rule),
521
- step.description ?? (step.call ? `calls ${step.call}` : ""),
522
- verdictOf(step.check),
523
- step.changed.join("; "),
524
- step.decision ?? "",
525
- ],
526
- })),
527
- }),
528
- ...(outcome.decision === "accept" ? [h("p", {}, "The route leaves with ", final.join(", "), ".")] : [])
563
+ draw();
564
+ container.append(
565
+ Tables.prose(
566
+ "What a device's route policy does to a route: rule by rule, what matches, what changes, and the decision. A prefix set holds a route whose prefix is one of its own or inside one."
567
+ ),
568
+ form,
569
+ result
529
570
  );
530
- };
571
+ }
531
572
 
532
- draw();
533
- container.append(
534
- prose(
535
- "What a device's route policy does to a route: rule by rule, what matches, what changes, and the decision. A prefix set holds a route whose prefix is one of its own or inside one."
536
- ),
537
- form,
538
- result
539
- );
540
- }
573
+ /**
574
+ * Draw a tool, such as `["tools", "trace", "10.0.10.5"]`, into a container.
575
+ *
576
+ * @param {string[]} route
577
+ * @param {HTMLElement} container
578
+ * @param {StudioData} next
579
+ */
580
+ static show(route, container, next) {
581
+ Tools.#data = next;
582
+ container.replaceChildren();
583
+
584
+ const [, asked = "trace", argument] = route;
585
+ const tool = Tools.#TOOLS.find(([id]) => id === asked) ?? Tools.#TOOLS[0];
586
+ if (!tool) return;
587
+
588
+ const tabs = Dom.h("nav", { class: "tabs", "aria-label": "Tools" });
589
+ for (const [id, title] of Tools.#TOOLS) {
590
+ const tab = Dom.h("button", { "aria-pressed": String(id === tool[0]) }, title ?? id ?? "");
591
+ tab.addEventListener("click", () => Dom.go(`tools/${id}`));
592
+ tabs.append(tab);
593
+ }
541
594
 
542
- /**
543
- * Draw a tool, such as `["tools", "trace", "10.0.10.5"]`, into a container.
544
- *
545
- * @param {string[]} route
546
- * @param {HTMLElement} container
547
- * @param {StudioData} next
548
- */
549
- export function showTools(route, container, next) {
550
- data = next;
551
- container.replaceChildren();
552
-
553
- const [, asked = "trace", argument] = route;
554
- const tool = TOOLS.find(([id]) => id === asked) ?? TOOLS[0];
555
- if (!tool) return;
556
-
557
- const tabs = h("nav", { class: "tabs", "aria-label": "Tools" });
558
- for (const [id, title] of TOOLS) {
559
- const tab = h("button", { "aria-pressed": String(id === tool[0]) }, title ?? id ?? "");
560
- tab.addEventListener("click", () => go(`tools/${id}`));
561
- tabs.append(tab);
562
- }
595
+ const body = Dom.h("div", { class: "section" });
596
+ container.append(
597
+ Tables.heading("Tools", "h2"),
598
+ Tables.prose(
599
+ "They read the files as the schema describes each field. A device can still behave otherwise; these say what the config means."
600
+ ),
601
+ tabs,
602
+ body
603
+ );
563
604
 
564
- const body = h("div", { class: "section" });
565
- container.append(
566
- heading("Tools", "h2"),
567
- prose(
568
- "They read the files as the schema describes each field. A device can still behave otherwise; these say what the config means."
569
- ),
570
- tabs,
571
- body
572
- );
573
-
574
- if (tool[0] === "trace") traceTool(argument, body);
575
- else if (tool[0] === "vlan") vlanTool(body);
576
- else if (tool[0] === "firewall") firewallTool(body);
577
- else policyTool(body);
605
+ if (tool[0] === "trace") Tools.#trace(argument, body);
606
+ else if (tool[0] === "vlan") Tools.#vlan(body);
607
+ else if (tool[0] === "firewall") Tools.#firewall(body);
608
+ else Tools.#policy(body);
609
+ }
578
610
  }