@takodotid/circuit 0.24.0 → 0.26.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 (42) hide show
  1. package/README.md +3 -3
  2. package/dist/chunks/{index-n4zpd25n.js → index-22sbskdx.js} +22 -6
  3. package/dist/chunks/{server-ckdy25w2.js → server-1ftkqkx5.js} +1 -1
  4. package/dist/cli/index.js +4621 -324
  5. package/dist/presets/index.js +5 -0
  6. package/dist/types/adapters/devices/catalog.d.ts +28 -0
  7. package/dist/types/adapters/devices/fortios/firewall.d.ts +21 -0
  8. package/dist/types/adapters/devices/fortios/index.d.ts +55 -0
  9. package/dist/types/adapters/devices/fortios/models.d.ts +14 -0
  10. package/dist/types/adapters/devices/fortios/names.d.ts +32 -0
  11. package/dist/types/adapters/devices/fortios/plan.d.ts +26 -0
  12. package/dist/types/adapters/devices/fortios/render.d.ts +36 -0
  13. package/dist/types/adapters/devices/fortios/routing.d.ts +32 -0
  14. package/dist/types/adapters/devices/fortios/tree.d.ts +33 -0
  15. package/dist/types/adapters/devices/junos/bgp.d.ts +11 -0
  16. package/dist/types/adapters/devices/junos/index.d.ts +39 -0
  17. package/dist/types/adapters/devices/junos/models.d.ts +14 -0
  18. package/dist/types/adapters/devices/junos/render.d.ts +52 -0
  19. package/dist/types/core/graph.d.ts +2 -0
  20. package/dist/types/presets/index.d.ts +1 -1
  21. package/package.json +7 -2
  22. package/src/adapters/devices/catalog.ts +5 -2
  23. package/src/adapters/devices/fortios/TRAPS.md +40 -0
  24. package/src/adapters/devices/fortios/firewall.ts +352 -0
  25. package/src/adapters/devices/fortios/index.ts +800 -0
  26. package/src/adapters/devices/fortios/models.ts +7 -0
  27. package/src/adapters/devices/fortios/names.ts +78 -0
  28. package/src/adapters/devices/fortios/plan.ts +840 -0
  29. package/src/adapters/devices/fortios/render.ts +734 -0
  30. package/src/adapters/devices/fortios/routing.ts +444 -0
  31. package/src/adapters/devices/fortios/tree.ts +121 -0
  32. package/src/adapters/devices/index.ts +3 -1
  33. package/src/adapters/devices/junos/TRAPS.md +31 -0
  34. package/src/adapters/devices/junos/bgp.ts +263 -0
  35. package/src/adapters/devices/junos/index.ts +784 -0
  36. package/src/adapters/devices/junos/models.ts +7 -0
  37. package/src/adapters/devices/junos/render.ts +868 -0
  38. package/src/core/across.ts +6 -3
  39. package/src/core/graph.ts +3 -1
  40. package/src/core/validate.ts +4 -3
  41. package/src/presets/index.ts +7 -1
  42. package/templates/colocation/checks.ts +8 -1
@@ -0,0 +1,444 @@
1
+ // OSPF, OSPFv3 and BGP, with the route maps, prefix lists and community lists BGP's policies become.
2
+
3
+ import { familyOf, host, split } from "../../../core/addr";
4
+ import { marker } from "../../../core/secrets";
5
+ import type { Device, Family, Neighbor, NeighborSettings, PolicyRule } from "../../../schema";
6
+ import { addressesOf, familyOfPrefix, holderOf, nameOf, network, settingsOf, subnet } from "./names";
7
+ import { Block, quote, quoteAll, UNSET } from "./tree";
8
+
9
+ type AnyRule = PolicyRule<string, string>;
10
+ type AnyNeighbor = Neighbor<string, string>;
11
+
12
+ /** The route map a session takes when its config states none, so it learns or announces nothing. */
13
+ export const REJECT_ALL = "circuit-reject-all";
14
+
15
+ /** The start of the comment on a prefix list `refresh` fills. The plan keeps the entries the device holds in one. */
16
+ export const FILLED = "Filled by circuit refresh";
17
+
18
+ /** The table a prefix list of one family sits in. */
19
+ export const PREFIX_LIST = { ipv4: "router prefix-list", ipv6: "router prefix-list6" } as const;
20
+
21
+ /** The longest prefix of each family. */
22
+ export const BITS = { ipv4: 32, ipv6: 128 } as const;
23
+
24
+ /** A neighbor's settings with its group's beneath them. */
25
+ export function neighborSettings(device: Device, neighbor: AnyNeighbor): NeighborSettings<string> {
26
+ const group = neighbor.group ? device.routing?.bgp?.groups?.[neighbor.group] : undefined;
27
+ return { ...group, ...neighbor };
28
+ }
29
+
30
+ /** The families a session carries: its own, or the family of its address. */
31
+ export function familiesOfNeighbor(device: Device, neighbor: AnyNeighbor): Family[] {
32
+ return [...(neighborSettings(device, neighbor).families ?? [familyOf(neighbor.address)])];
33
+ }
34
+
35
+ /** Whether OSPF or BGP detects a dead neighbor with BFD, which the device then runs. */
36
+ export function usesBfd(device: Device): boolean {
37
+ const areas = Object.values(device.routing?.ospf?.areas ?? {});
38
+ const ospf = areas.some((area) => Object.values(area.interfaces).some((settings) => settings?.bfd));
39
+ const bgp = Object.values(device.routing?.bgp?.neighbors ?? {}).some((neighbor) => neighborSettings(device, neighbor).bfd);
40
+ return ospf || bgp;
41
+ }
42
+
43
+ export function routing(root: Block, device: Device): void {
44
+ prefixLists(root, device);
45
+ ospf(root, device);
46
+ ospf6(root, device);
47
+ bgp(root, device);
48
+ }
49
+
50
+ /** A prefix as the platform writes it in a prefix list of its family. */
51
+ const listPrefix = (prefix: string, family: Family) => (family === "ipv4" ? subnet(network(prefix)) : network(prefix));
52
+
53
+ /** One rule of a prefix list: a prefix, and the lengths it takes within it. The device prints a bound the rule does not hold as `unset`. */
54
+ function listRule(rule: Block, family: Family, prefix: string, lengths: { ge?: number; le?: number } = {}): void {
55
+ rule.set(family === "ipv4" ? "prefix" : "prefix6", listPrefix(prefix, family));
56
+ rule.set("ge", lengths.ge ?? UNSET);
57
+ rule.set("le", lengths.le ?? UNSET);
58
+ }
59
+
60
+ /** The lengths a rule takes within a prefix to match lengths from `min` to `max`, or none when no length fits. A bound equal to the prefix's own length is left out. */
61
+ function bounds(prefix: string, family: Family, min = 0, max: number = BITS[family]): { ge?: number; le?: number } | undefined {
62
+ const own = split(network(prefix)).len;
63
+ const low = Math.max(min, own);
64
+ if (max < low) return undefined;
65
+ if (low === own && max === own) return {};
66
+
67
+ return { ...(low > own ? { ge: low } : {}), le: max };
68
+ }
69
+
70
+ /** Each prefix set as a prefix list matching its prefixes and any more specific. One a registry fills starts with a rule that denies every prefix, so it matches nothing until `refresh` fills it. */
71
+ function prefixLists(root: Block, device: Device): void {
72
+ for (const [name, set] of Object.entries(device.prefix_sets ?? {})) {
73
+ if ("source" in set) {
74
+ const { registry, query, family } = set.source;
75
+ const entry = root
76
+ .block(PREFIX_LIST[family])
77
+ .entry(name)
78
+ .set("comments", quote(`${FILLED} from ${registry} ${query}`));
79
+ // Until `refresh` fills it, the list refuses every prefix, so a rule matching it matches nothing.
80
+ const rule = entry.block("rule").entry(1);
81
+ listRule(rule, family, family === "ipv4" ? "0.0.0.0/0" : "::/0", { le: BITS[family] });
82
+ rule.set("action", "deny");
83
+ continue;
84
+ }
85
+
86
+ const family = set.prefixes[0] ? familyOfPrefix(set.prefixes[0]) : "ipv4";
87
+ const entry = root.block(PREFIX_LIST[family]).entry(name);
88
+ set.prefixes.forEach((prefix, index) => listRule(entry.block("rule").entry(index + 1), family, prefix, bounds(prefix, family)));
89
+ }
90
+ }
91
+
92
+ /** The networks of an interface's addresses of one family, for an OSPF area to announce. */
93
+ function networksOf(device: Device, name: string, family: Family): string[] {
94
+ return addressesOf(settingsOf(device, name))
95
+ .filter((address) => familyOfPrefix(address) === family)
96
+ .map(network);
97
+ }
98
+
99
+ /** OSPF for IPv4. An area runs on the networks of its interfaces; an interface named with a cost, a link type or BFD has settings of its own; a passive one forms no adjacency. */
100
+ function ospf(root: Block, device: Device): void {
101
+ const ospf = device.routing?.ospf;
102
+ if (!ospf || !(ospf.families ?? ["ipv4"]).includes("ipv4")) return;
103
+
104
+ const block = root.block("router ospf");
105
+ if (device.routing?.router_id) block.set("router-id", device.routing.router_id);
106
+
107
+ const passive: string[] = [];
108
+ let networks = 0;
109
+
110
+ for (const [areaId, area] of Object.entries(ospf.areas)) {
111
+ block.block("area").entry(areaId, true);
112
+
113
+ for (const [name, settings] of Object.entries(area.interfaces)) {
114
+ const iface = nameOf(device, name);
115
+ if (settings?.passive) passive.push(iface);
116
+
117
+ if (settings?.cost !== undefined || settings?.network === "point-to-point" || settings?.bfd) {
118
+ const entry = block.block("ospf-interface").entry(iface);
119
+ entry.set("interface", quote(iface));
120
+ if (settings.cost !== undefined) entry.set("cost", settings.cost);
121
+ if (settings.network === "point-to-point") entry.set("network-type", "point-to-point");
122
+ if (settings.bfd) entry.set("bfd", "enable");
123
+ }
124
+
125
+ for (const prefix of networksOf(device, name, "ipv4")) {
126
+ const entry = block.block("network").entry(++networks);
127
+ entry.set("prefix", subnet(prefix));
128
+ // The backbone is the platform's default area and is not printed.
129
+ if (areaId !== "0.0.0.0") entry.set("area", areaId);
130
+ }
131
+ }
132
+ }
133
+
134
+ if (passive.length) block.set("passive-interface", quoteAll(passive));
135
+ for (const source of ospf.redistribute ?? []) block.block(`redistribute "${source}"`).set("status", "enable");
136
+ }
137
+
138
+ /** OSPFv3 for IPv6. Every interface of an area runs it in that area. */
139
+ function ospf6(root: Block, device: Device): void {
140
+ const ospf = device.routing?.ospf;
141
+ if (!ospf?.families?.includes("ipv6")) return;
142
+
143
+ const block = root.block("router ospf6");
144
+ if (device.routing?.router_id) block.set("router-id", device.routing.router_id);
145
+
146
+ const passive: string[] = [];
147
+
148
+ for (const [areaId, area] of Object.entries(ospf.areas)) {
149
+ block.block("area").entry(areaId, true);
150
+
151
+ for (const [name, settings] of Object.entries(area.interfaces)) {
152
+ const iface = nameOf(device, name);
153
+ if (settings?.passive) passive.push(iface);
154
+
155
+ const entry = block.block("ospf6-interface").entry(iface);
156
+ entry.set("interface", quote(iface));
157
+ if (areaId !== "0.0.0.0") entry.set("area-id", areaId);
158
+ if (settings?.cost !== undefined) entry.set("cost", settings.cost);
159
+ if (settings?.network === "point-to-point") entry.set("network-type", "point-to-point");
160
+ if (settings?.bfd) entry.set("bfd", "enable");
161
+ }
162
+ }
163
+
164
+ if (passive.length) block.set("passive-interface", quoteAll(passive));
165
+ for (const source of ospf.redistribute ?? []) block.block(`redistribute "${source}"`).set("status", "enable");
166
+ }
167
+
168
+ /** BGP: each session with its settings and group's, the route maps its policies become for each family it carries, and the prefixes the device originates. */
169
+ function bgp(root: Block, device: Device): void {
170
+ const bgp = device.routing?.bgp;
171
+ if (!bgp) return;
172
+
173
+ const block = root.block("router bgp");
174
+ block.set("as", bgp.asn);
175
+ if (device.routing?.router_id) block.set("router-id", device.routing.router_id);
176
+
177
+ const maps = new RouteMaps(root, device);
178
+
179
+ for (const neighbor of Object.values(bgp.neighbors ?? {})) {
180
+ const settings = neighborSettings(device, neighbor);
181
+ const families = familiesOfNeighbor(device, neighbor);
182
+ const entry = block.block("neighbor").entry(neighbor.address);
183
+
184
+ // Both families are active unless told otherwise.
185
+ if (!families.includes("ipv4")) entry.set("activate", "disable");
186
+ if (!families.includes("ipv6")) entry.set("activate6", "disable");
187
+ if (settings.bfd) entry.set("bfd", "enable");
188
+ if (settings.multihop) entry.set("ebgp-enforce-multihop", "enable");
189
+ if (settings.passive) entry.set("passive", "enable");
190
+ if (settings.description) entry.set("description", quote(settings.description));
191
+ if (settings.remote_as !== undefined) entry.set("remote-as", settings.remote_as);
192
+
193
+ for (const family of families) {
194
+ const suffix = family === "ipv4" ? "" : "6";
195
+ if (settings.max_prefixes !== undefined) entry.set(`maximum-prefix${suffix}`, settings.max_prefixes);
196
+ entry.set(`route-map-in${suffix}`, quote(maps.of(settings.import, family)));
197
+ entry.set(`route-map-out${suffix}`, quote(maps.of(settings.export, family)));
198
+ }
199
+
200
+ if (settings.keepalive !== undefined) entry.set("keep-alive-timer", settings.keepalive);
201
+ if (settings.hold_time !== undefined) entry.set("holdtime-timer", settings.hold_time);
202
+
203
+ const source = settings.local_address ? holderOf(device, settings.local_address) : undefined;
204
+ if (source) entry.set("update-source", quote(nameOf(device, source)));
205
+
206
+ if (settings.password) entry.set("password", quote(marker(settings.password)));
207
+ }
208
+
209
+ const networks = { ipv4: 0, ipv6: 0 };
210
+
211
+ for (const prefix of bgp.networks ?? []) {
212
+ const family = familyOfPrefix(prefix);
213
+ const entry = block.block(family === "ipv4" ? "network" : "network6").entry(++networks[family]);
214
+ entry.set(family === "ipv4" ? "prefix" : "prefix6", listPrefix(prefix, family));
215
+ }
216
+ }
217
+
218
+ /** The family a rule is limited to, by its match: a family, a prefix, or a prefix set of one family. */
219
+ export function familyOfRule(device: Device, rule: AnyRule): Family | undefined {
220
+ const match = rule.match;
221
+ if (match?.family) return match.family;
222
+ if (match?.prefix) return familyOfPrefix(match.prefix);
223
+
224
+ const set = match?.prefix_set ? device.prefix_sets?.[match.prefix_set] : undefined;
225
+ if (set && "source" in set) return set.source.family;
226
+ if (set?.prefixes[0]) return familyOfPrefix(set.prefixes[0]);
227
+
228
+ return undefined;
229
+ }
230
+
231
+ /** A policy's rules with each call replaced by the rules it calls, in order. A called policy that decides nothing hands back to the caller. */
232
+ export function flatten(device: Device, policy: string, seen: string[] = [policy]): AnyRule[] {
233
+ const flat: AnyRule[] = [];
234
+
235
+ for (const rule of device.policies?.[policy] ?? []) {
236
+ if (rule.call && !seen.includes(rule.call)) flat.push(...flatten(device, rule.call, [...seen, rule.call]));
237
+ if (rule.match || rule.set || rule.action) flat.push(rule);
238
+ }
239
+
240
+ return flat;
241
+ }
242
+
243
+ /** A name made of a value's words, for a list named after what it matches: `64500:*` becomes `64500-any`. */
244
+ const slug = (value: string) => value.replace(/\*/g, "any").replace(/[^A-Za-z0-9]+/g, "-");
245
+
246
+ /** A community pattern as a regular expression, `*` standing for any value of its field. */
247
+ const patternOf = (pattern: string) => `^${pattern.replace(/\*/g, "[0-9]+")}$`;
248
+
249
+ /** Each policy as a route map for each family, built once, with the lists its rules match. */
250
+ class RouteMaps {
251
+ private readonly built = new Set<string>();
252
+
253
+ constructor(
254
+ private readonly root: Block,
255
+ private readonly device: Device
256
+ ) {}
257
+
258
+ /** The route map for a policy in one family; one that rejects everything when there is no policy. */
259
+ of(policy: string | undefined, family: Family): string {
260
+ if (!policy) {
261
+ if (!this.built.has(REJECT_ALL)) {
262
+ this.built.add(REJECT_ALL);
263
+ this.root.block("router route-map").entry(REJECT_ALL).block("rule").entry(1).set("action", "deny");
264
+ }
265
+
266
+ return REJECT_ALL;
267
+ }
268
+
269
+ const name = family === "ipv4" ? policy : `${policy}-v6`;
270
+ if (this.built.has(name)) return name;
271
+ this.built.add(name);
272
+
273
+ const map = this.root.block("router route-map").entry(name);
274
+ let number = 0;
275
+
276
+ for (const rule of flatten(this.device, policy)) {
277
+ const only = familyOfRule(this.device, rule);
278
+ if (only && only !== family) continue;
279
+
280
+ this.rule(map.block("rule").entry(++number), rule, family, `${name}-${number}`);
281
+ }
282
+
283
+ return name;
284
+ }
285
+
286
+ private rule(entry: Block, rule: AnyRule, family: Family, label: string): void {
287
+ const match = rule.match ?? {};
288
+ const set = rule.set ?? {};
289
+ const address = family === "ipv4" ? "match-ip-address" : "match-ip6-address";
290
+
291
+ if (match.prefix) {
292
+ this.list(label, family, [[match.prefix, {}]]);
293
+ entry.set(address, quote(label));
294
+ } else if (match.prefix_set && match.prefix_length) {
295
+ const name = `${match.prefix_set}-${match.prefix_length.min ?? 0}-${match.prefix_length.max ?? BITS[family]}`;
296
+ const prefixes = this.prefixesOf(match.prefix_set);
297
+ this.list(
298
+ name,
299
+ family,
300
+ prefixes.map((prefix) => [prefix, bounds(prefix, family, match.prefix_length?.min, match.prefix_length?.max)])
301
+ );
302
+ entry.set(address, quote(name));
303
+ } else if (match.prefix_set) {
304
+ entry.set(address, quote(match.prefix_set));
305
+ } else if (match.prefix_length) {
306
+ const { min = 0, max = BITS[family] } = match.prefix_length;
307
+ const name = `circuit-length-${min}-${max}`;
308
+ const everything = family === "ipv4" ? "0.0.0.0/0" : "::/0";
309
+ this.list(name, family, [[everything, bounds(everything, family, min, max)]]);
310
+ entry.set(address, quote(name));
311
+ }
312
+
313
+ if (match.community)
314
+ entry.set("match-community", quote(this.communities(`circuit-match-${slug(match.community)}`, [match.community], false)));
315
+
316
+ if (set.local_pref !== undefined) entry.set("set-local-preference", set.local_pref);
317
+ if (set.med !== undefined) entry.set("set-metric", set.med);
318
+ if (set.prepend !== undefined) entry.set("set-aspath", quote(Array(set.prepend).fill(this.device.routing!.bgp!.asn).join(" ")));
319
+
320
+ // Replacing the communities makes removing some needless; added ones join the new ones.
321
+ if (set.communities) {
322
+ entry.set("set-community", quoteAll([...set.communities, ...(set.add_communities ?? [])]));
323
+ } else {
324
+ if (set.add_communities) entry.set("set-community", quoteAll(set.add_communities)).set("set-community-additive", "enable");
325
+
326
+ if (set.remove_communities) {
327
+ const name = `circuit-remove-${slug(set.remove_communities.join(" "))}`;
328
+ entry.set("set-community-delete", quote(this.communities(name, set.remove_communities, true)));
329
+ }
330
+ }
331
+
332
+ if (set.next_hop && familyOf(set.next_hop) === family)
333
+ entry.set(family === "ipv4" ? "set-ip-nexthop" : "set-ip6-nexthop", set.next_hop);
334
+
335
+ // Permit is the default and is not printed.
336
+ if (rule.action === "reject") entry.set("action", "deny");
337
+ }
338
+
339
+ /** The prefixes of a set the config lists. */
340
+ private prefixesOf(set: string): string[] {
341
+ const prefixes = this.device.prefix_sets?.[set];
342
+ return prefixes && "prefixes" in prefixes ? [...prefixes.prefixes] : [];
343
+ }
344
+
345
+ /** A prefix list of its own, rule by rule; a prefix no length fits is left out. */
346
+ private list(name: string, family: Family, rules: [string, { ge?: number; le?: number } | undefined][]): void {
347
+ const rulesBlock = this.root.block(PREFIX_LIST[family]).entry(name).block("rule");
348
+ let number = 0;
349
+
350
+ for (const [prefix, lengths] of rules) if (lengths) listRule(rulesBlock.entry(++number), family, prefix, lengths);
351
+ }
352
+
353
+ /** A community list matching these communities, or, expanded, these patterns. */
354
+ private communities(name: string, values: readonly string[], patterns: boolean): string {
355
+ const list = this.root.block("router community-list").entry(name);
356
+ if (patterns) list.set("type", "expanded");
357
+
358
+ values.forEach((value, index) => {
359
+ const rule = list
360
+ .block("rule")
361
+ .entry(index + 1)
362
+ .set("action", "permit");
363
+ if (patterns) rule.set("regexp", quote(patternOf(value)));
364
+ else rule.set("match", quote(value));
365
+ });
366
+
367
+ return name;
368
+ }
369
+ }
370
+
371
+ /** Routing the platform cannot express, or that this adapter does not yet write. */
372
+ export function routingGaps(device: Device): string[] {
373
+ const found: string[] = [];
374
+ const routing = device.routing;
375
+
376
+ if (routing?.rpki) found.push("routing.rpki, the platform has no route origin validation");
377
+ if (routing?.router_id && !routing.ospf && !routing.bgp) found.push("routing.router_id without OSPF or BGP, which each hold it");
378
+
379
+ const ospf = routing?.ospf;
380
+ if (ospf?.families?.includes("ipv6") && !routing?.router_id) found.push("routing.ospf for IPv6 without routing.router_id");
381
+
382
+ for (const area of Object.values(ospf?.areas ?? {})) {
383
+ for (const name of Object.keys(area.interfaces)) {
384
+ if (settingsOf(device, name)?.vrf) found.push(`routing.ospf on ${name}, which routes in a VRF: OSPF runs in the default table`);
385
+
386
+ const ipv4 = addressesOf(settingsOf(device, name)).some((address) => familyOf(host(address)) === "ipv4");
387
+ if ((ospf?.families ?? ["ipv4"]).includes("ipv4") && !ipv4)
388
+ found.push(`routing.ospf on ${name}, which has no IPv4 address for OSPF to run on`);
389
+ }
390
+ }
391
+
392
+ for (const [name, neighbor] of Object.entries(routing?.bgp?.neighbors ?? {})) {
393
+ const where = `routing.bgp.neighbors.${name}`;
394
+ const settings = neighborSettings(device, neighbor);
395
+
396
+ if (settings.role) found.push(`${where}.role, the platform has no BGP roles`);
397
+ if (settings.local_address && !holderOf(device, settings.local_address)) {
398
+ found.push(`${where}.local_address, no interface holds ${settings.local_address}`);
399
+ }
400
+
401
+ if (familyOf(neighbor.address) === "ipv6" && familiesOfNeighbor(device, neighbor).includes("ipv4")) {
402
+ found.push(`${where}.families ipv4 over an IPv6 session`);
403
+ }
404
+ }
405
+
406
+ for (const [name, rules] of Object.entries(device.policies ?? {})) {
407
+ rules.forEach((rule, index) => found.push(...ruleGaps(device, `policies.${name}[${index}]`, rule)));
408
+ }
409
+
410
+ return found;
411
+ }
412
+
413
+ function ruleGaps(device: Device, where: string, rule: AnyRule): string[] {
414
+ const found: string[] = [];
415
+ const match = rule.match ?? {};
416
+ const set = rule.set ?? {};
417
+
418
+ if ((rule.match || rule.set) && !rule.action)
419
+ found.push(`${where}, a rule without action: the platform stops at the first rule that matches`);
420
+
421
+ for (const field of ["as_path_length", "rpki", "large_community"] as const) {
422
+ if (match[field] !== undefined) found.push(`${where}.match.${field}`);
423
+ }
424
+
425
+ for (const field of [
426
+ "large_communities",
427
+ "add_large_communities",
428
+ "remove_large_communities",
429
+ "preferred_source",
430
+ "blackhole",
431
+ ] as const) {
432
+ if (set[field] !== undefined) found.push(`${where}.set.${field}`);
433
+ }
434
+
435
+ if (match.prefix && match.prefix_length) found.push(`${where}.match.prefix with prefix_length`);
436
+ if (match.prefix && match.prefix_set) found.push(`${where}.match.prefix with prefix_set`);
437
+
438
+ const prefixSet = match.prefix_set ? device.prefix_sets?.[match.prefix_set] : undefined;
439
+ if (prefixSet && "source" in prefixSet && match.prefix_length) {
440
+ found.push(`${where}.match.prefix_length with a prefix set a registry fills`);
441
+ }
442
+
443
+ return found;
444
+ }
@@ -0,0 +1,121 @@
1
+ // FortiOS configuration as a tree. A `config` block holds settings, nested blocks and, in a table, entries opened with `edit`. The render builds a tree and prints it in the form `show` prints, and the read parses what `show` printed, so the plan compares two trees.
2
+
3
+ /** A setting the device prints as `unset <key>`, a key it holds empty on purpose, such as an ICMP service's type. */
4
+ export const UNSET = "\u0000unset";
5
+
6
+ /** One `edit` of a table: its name as written, and what it holds. */
7
+ export type Entry = { id: string; block: Block };
8
+
9
+ export class Block {
10
+ readonly settings = new Map<string, string>();
11
+ readonly blocks = new Map<string, Block>();
12
+ readonly entries = new Map<string, Entry>();
13
+
14
+ /** Set a key. A value of several words is written as given. */
15
+ set(key: string, value: string | number): this {
16
+ this.settings.set(key, String(value));
17
+ return this;
18
+ }
19
+
20
+ /** The nested `config` block with this header, created when absent. */
21
+ block(header: string): Block {
22
+ if (!this.blocks.has(header)) this.blocks.set(header, new Block());
23
+ return this.blocks.get(header)!;
24
+ }
25
+
26
+ /** The table entry with this name, created when absent. A name is quoted unless it is a number, or the table prints it bare, such as an OSPF area or an IPv6 prefix. */
27
+ entry(id: string | number, plain = false): Block {
28
+ const written = typeof id === "number" || plain ? String(id) : quote(id);
29
+ const key = bare(written);
30
+ if (!this.entries.has(key)) this.entries.set(key, { id: written, block: new Block() });
31
+ return this.entries.get(key)!.block;
32
+ }
33
+
34
+ isEmpty(): boolean {
35
+ return !this.settings.size && !this.blocks.size && !this.entries.size;
36
+ }
37
+ }
38
+
39
+ /** A string as the CLI takes it, in double quotes. */
40
+ export function quote(text: string): string {
41
+ return `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
42
+ }
43
+
44
+ /** Each of several strings quoted, as a list setting takes them. */
45
+ export const quoteAll = (texts: readonly string[]) => texts.map(quote).join(" ");
46
+
47
+ /** A value's words; a quoted string is one word with its quotes. */
48
+ export function wordsOf(value: string): string[] {
49
+ return value.match(/"(?:[^"\\]|\\.)*"|'[^']*'|\S+/g) ?? [];
50
+ }
51
+
52
+ /** A word as the device means it, without quotes or escapes. */
53
+ export function bare(word: string): string {
54
+ if (word.startsWith('"')) return word.slice(1, -1).replace(/\\(.)/g, "$1");
55
+ if (word.startsWith("'")) return word.slice(1, -1);
56
+ return word;
57
+ }
58
+
59
+ /** The lines that build a block, at an indent, in the form `show` prints and the CLI takes. */
60
+ export function linesOf(block: Block, indent = ""): string[] {
61
+ const lines: string[] = [];
62
+
63
+ for (const [key, value] of block.settings) lines.push(value === UNSET ? `${indent}unset ${key}` : `${indent}set ${key} ${value}`);
64
+
65
+ for (const [header, child] of block.blocks) lines.push(`${indent}config ${header}`, ...linesOf(child, indent + " "), `${indent}end`);
66
+
67
+ for (const { id, block: entry } of block.entries.values()) {
68
+ lines.push(`${indent}edit ${id}`, ...linesOf(entry, indent + " "), `${indent}next`);
69
+ }
70
+
71
+ return lines;
72
+ }
73
+
74
+ /** The whole configuration: each top-level block as `config <section>` to `end`. */
75
+ export function print(root: Block): string {
76
+ const lines: string[] = [];
77
+ for (const [section, block] of root.blocks) lines.push(`config ${section}`, ...linesOf(block, " "), "end");
78
+ return lines.join("\n") + "\n";
79
+ }
80
+
81
+ /** Whether a line opens a quoted value it does not close, so the value goes on over the next lines. */
82
+ const unclosed = (line: string) => (line.replace(/\\./g, "").match(/"/g)?.length ?? 0) % 2 === 1;
83
+
84
+ /** The tree of what `show` printed. Lines that are not configuration, such as the version header, are skipped. */
85
+ export function parse(text: string): Block {
86
+ const root = new Block();
87
+ const stack: { block: Block; isEntry: boolean }[] = [{ block: root, isEntry: false }];
88
+ const lines = text.replace(/\r/g, "").split("\n");
89
+
90
+ for (let index = 0; index < lines.length; index++) {
91
+ let line = lines[index]!.trim();
92
+ if (!line || line.startsWith("#")) continue;
93
+
94
+ while (unclosed(line) && index + 1 < lines.length) line += "\n" + lines[++index];
95
+
96
+ const current = stack.at(-1)!.block;
97
+ const [verb] = line.split(/\s+/, 1);
98
+ const rest = line.slice(verb!.length).trim();
99
+
100
+ if (verb === "config") {
101
+ const child = current.block(rest);
102
+ stack.push({ block: child, isEntry: false });
103
+ } else if (verb === "edit") {
104
+ const key = bare(rest);
105
+ if (!current.entries.has(key)) current.entries.set(key, { id: rest, block: new Block() });
106
+ stack.push({ block: current.entries.get(key)!.block, isEntry: true });
107
+ } else if (verb === "next") {
108
+ if (stack.at(-1)!.isEntry) stack.pop();
109
+ } else if (verb === "end") {
110
+ if (stack.at(-1)!.isEntry) stack.pop();
111
+ if (stack.length > 1) stack.pop();
112
+ } else if (verb === "set") {
113
+ const [key] = rest.split(/\s+/, 1);
114
+ current.settings.set(key!, rest.slice(key!.length).trim());
115
+ } else if (verb === "unset") {
116
+ current.settings.set(rest, UNSET);
117
+ }
118
+ }
119
+
120
+ return root;
121
+ }
@@ -1,7 +1,9 @@
1
+ import { fortios } from "./fortios";
2
+ import { junos } from "./junos";
1
3
  import { raisecom } from "./raisecom-ros";
2
4
  import { routeros } from "./routeros";
3
5
  import type { DeviceAdapter } from "./types";
4
6
  import { vrp } from "./vrp";
5
7
 
6
8
  /** Every platform the framework can render and apply, by name. */
7
- export const adapters: Record<string, DeviceAdapter> = { routeros, vrp, "raisecom-ros": raisecom };
9
+ export const adapters: Record<string, DeviceAdapter> = { routeros, vrp, "raisecom-ros": raisecom, junos, fortios };
@@ -0,0 +1,31 @@
1
+ # Junos traps
2
+
3
+ Behaviour that looks like something else. Each one has been hit on a QFX5100-48S-6Q running Junos 21.4R3.
4
+
5
+ - Changes stage in a candidate and apply at `commit`. `commit confirmed N` applies them and rolls them back after N minutes unless another `commit` confirms them. `commit` also saves: there is no separate save.
6
+ - The device counts ports from 0, and the 40G ports follow the 10G ones: `10g-1` is `xe-0/0/0`, `10g-48` is `xe-0/0/47`, `40g-1` is `et-0/0/48`. The two management ports, `em0` and `em1`, are `1g-1` and `1g-2`.
7
+ - `load set terminal` reports a refused statement as `terminal:12:(8) syntax error: ...`, then says `load complete` and keeps every statement it accepted. Circuit discards the whole candidate on any refusal.
8
+ - A value is cut at `(`, `&` or `|` unless it is quoted: `description a|b` is stored as `a`, with an error. The render quotes every value that is not a plain word.
9
+ - The device decides for itself which values it prints in quotes: `noc@example.com` comes back quoted, `"Router"` comes back plain. The plan compares values without their quotes.
10
+ - The device prints only the longest form of a statement. `set protocols rstp interface xe-0/0/0` disappears once `... xe-0/0/0 edge` is set, so the render leaves it out too.
11
+ - A delete cannot stop at a list whose entries are named: `delete interfaces xe-0/0/1 unit` is refused with `syntax error, expecting <identifier>`, while `unit 0` works. `show` refuses the same way. A list of plain values, such as `vlan members` or `ciphers`, can go whole.
12
+ - `delete` with a value removes the statement only when the value matches; otherwise it says `warning: statement not found` and changes nothing.
13
+ - A `?` shows help and is not executed, but the Enter after it sends the line typed so far, which may be a complete command.
14
+ - `operator`, `daemon`, `bind`, `kmem`, `nobody`, `sshd`, `tty` and `uucp` are system accounts. A user with one of those names fails the commit check: `user name is used by a system account`.
15
+ - Root logs in to a shell, `root@:RE:0%`, and reaches the CLI with `cli`. Every other user lands in the CLI.
16
+ - A password set with `plain-text-password-value` prints as a `$6$` hash. SNMPv3 passwords print as `$9$` keys, which can be turned back into the password. The read redacts both.
17
+ - An SNMP community prints in clear, and its value is also its name, so a new value is a second community. A new value is sent after the old communities are deleted, each by its name, which Circuit looks up during the apply and never prints.
18
+ - A firewall filter name is unique across families. The filter guarding the device's services is `circuit-services` for IPv4 and `circuit-services-v6` for IPv6.
19
+ - A VLAN interface's MTU above what its ports carry commits with only a warning, `mtu 9000 is bigger than member link`.
20
+ - There is no `protocols stp`. Plain STP is RSTP held back with `force-version stp`.
21
+ - sFlow interfaces print with their unit, `xe-0/0/0.0`.
22
+ - The factory VLAN `default` can be deleted.
23
+ - BGP without a licence commits with only a warning, `requires 'bgp' license`, and sessions establish. The same holds for the other licensed features.
24
+ - Junos accepts and announces every route over eBGP unless a policy says otherwise: `Default eBGP mode: advertise - accept, receive - accept`. A session without a policy is given `circuit-reject-all`.
25
+ - An export policy sees every active route, not only BGP's. Every export starts with `circuit-bgp-origin`, which passes on BGP's routes and the prefixes in `routing.bgp.networks` and rejects the rest.
26
+ - A policy's or a filter's terms are kept in order, and `set` adds a new term at the end. A change to one rewrites it whole.
27
+ - One policy term cannot filter IPv4 and IPv6 prefixes together: `invalid prefix ::/0 for family inet`. A rule for both families becomes a term for each.
28
+ - A port filter cannot match the VLAN a frame is switched in: `learn-vlan-id not supported on ingress interface`.
29
+ - IPv4 routes over an IPv6 session need a local IPv4 address under the family: `Local IPv4 address must be configured`.
30
+ - A DHCP server and a DHCP relay cannot share an interface: `Interface irb.10 already configured`.
31
+ - A port's MTU counts the Ethernet header. `mtu 9018` carries 9000-byte packets with a VLAN tag, and a VLAN interface's 9000 then commits without a warning.