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