@takodotid/circuit 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/chunks/index-jtzfw679.js +936 -0
- package/dist/chunks/{index-dczpxzzm.js → index-xm9nma2t.js} +20 -1
- package/dist/chunks/server-5shmsmse.js +246 -0
- package/dist/cli/index.js +544 -487
- package/dist/index.js +1 -1
- package/dist/presets/index.js +1 -1
- package/dist/types/adapters/devices/derive.d.ts +2 -0
- package/dist/types/core/across.d.ts +4 -0
- package/dist/types/core/addr.d.ts +10 -0
- package/dist/types/core/addresses.d.ts +46 -0
- package/dist/types/core/files.d.ts +6 -0
- package/dist/types/core/graph.d.ts +31 -1
- package/dist/types/core/schema-docs.d.ts +16 -0
- package/package.json +1 -1
- package/src/adapters/devices/derive.ts +32 -0
- package/src/adapters/devices/raisecom-ros/index.ts +23 -3
- package/src/adapters/devices/routeros/index.ts +10 -0
- package/src/adapters/devices/vrp/index.ts +10 -1
- package/src/cli/graph.ts +1 -15
- package/src/cli/index.ts +154 -20
- package/src/cli/rename.ts +129 -0
- package/src/core/across.ts +322 -0
- package/src/core/addr.ts +29 -0
- package/src/core/addresses.ts +316 -0
- package/src/core/files.ts +30 -0
- package/src/core/graph.ts +27 -6
- package/src/core/schema-docs.ts +166 -0
- package/src/core/validate.ts +2 -52
- package/src/studio/data.ts +255 -0
- package/src/studio/git.ts +119 -0
- package/src/studio/public/app.js +147 -0
- package/src/studio/public/compare.js +243 -0
- package/src/studio/public/diff.js +97 -0
- package/src/studio/public/dom.js +108 -0
- package/src/studio/public/evaluate.js +648 -0
- package/src/studio/public/export.js +122 -0
- package/src/studio/public/favicon.svg +6 -0
- package/src/studio/public/graph.js +914 -0
- package/src/studio/public/index.html +67 -0
- package/src/studio/public/net.js +128 -0
- package/src/studio/public/style.css +693 -0
- package/src/studio/public/tables.js +1223 -0
- package/src/studio/public/tools.js +578 -0
- package/src/studio/server.ts +218 -0
- package/templates/project/AGENTS.md +2 -0
- package/dist/chunks/studio-femhydvd.js +0 -764
- package/src/cli/studio-page.ts +0 -876
- package/src/cli/studio.ts +0 -126
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/// <reference lib="dom" />
|
|
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
|
+
|
|
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";
|
|
9
|
+
|
|
10
|
+
/** @typedef {import("../data").StudioData} StudioData */
|
|
11
|
+
/** @typedef {import("../../schema").Device} Device */
|
|
12
|
+
/** @typedef {import("./dom.js").Child} Child */
|
|
13
|
+
/** @typedef {import("./evaluate.js").Check} Check */
|
|
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
|
+
}
|
|
98
|
+
|
|
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
|
+
}
|
|
123
|
+
|
|
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;
|
|
152
|
+
}
|
|
153
|
+
|
|
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
|
+
})
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
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
|
+
};
|
|
189
|
+
|
|
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
|
+
}
|
|
222
|
+
|
|
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
|
+
})
|
|
261
|
+
),
|
|
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
|
+
})
|
|
268
|
+
);
|
|
269
|
+
};
|
|
270
|
+
|
|
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
|
+
}
|
|
286
|
+
|
|
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
|
+
);
|
|
312
|
+
|
|
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))
|
|
319
|
+
);
|
|
320
|
+
flags.append(h("label", { class: "flag" }, box, flag));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
form.append(
|
|
324
|
+
field(
|
|
325
|
+
"Device",
|
|
326
|
+
choice(
|
|
327
|
+
store,
|
|
328
|
+
"device",
|
|
329
|
+
devices.map((candidate) => [candidate.name, candidate.name]),
|
|
330
|
+
draw
|
|
331
|
+
)
|
|
332
|
+
),
|
|
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)
|
|
344
|
+
);
|
|
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
|
+
|
|
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
|
+
})
|
|
382
|
+
);
|
|
383
|
+
};
|
|
384
|
+
|
|
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
|
+
}
|
|
392
|
+
|
|
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
|
+
});
|
|
419
|
+
|
|
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
|
+
})
|
|
425
|
+
);
|
|
426
|
+
|
|
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))
|
|
437
|
+
);
|
|
438
|
+
sets.append(h("label", { class: "flag" }, box, name));
|
|
439
|
+
}
|
|
440
|
+
|
|
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
|
+
])
|
|
462
|
+
),
|
|
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)
|
|
469
|
+
);
|
|
470
|
+
};
|
|
471
|
+
|
|
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);
|
|
476
|
+
|
|
477
|
+
if (!store.prefix.trim()) return result.append(empty("Give the route's prefix, such as 198.51.100.0/24."));
|
|
478
|
+
|
|
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
|
+
});
|
|
487
|
+
|
|
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(", "), ".")] : [])
|
|
529
|
+
);
|
|
530
|
+
};
|
|
531
|
+
|
|
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
|
+
}
|
|
541
|
+
|
|
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
|
+
}
|
|
563
|
+
|
|
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);
|
|
578
|
+
}
|