@takodotid/circuit 0.25.0 → 0.26.1

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 (43) hide show
  1. package/README.md +7 -7
  2. package/dist/chunks/{index-wm9ypkjq.js → index-22sbskdx.js} +13 -5
  3. package/dist/chunks/{server-n2q4tzy4.js → server-1ftkqkx5.js} +1 -1
  4. package/dist/cli/index.js +2990 -264
  5. package/dist/types/adapters/devices/catalog.d.ts +14 -0
  6. package/dist/types/adapters/devices/fortios/firewall.d.ts +21 -0
  7. package/dist/types/adapters/devices/fortios/index.d.ts +55 -0
  8. package/dist/types/adapters/devices/fortios/models.d.ts +14 -0
  9. package/dist/types/adapters/devices/fortios/names.d.ts +32 -0
  10. package/dist/types/adapters/devices/fortios/plan.d.ts +26 -0
  11. package/dist/types/adapters/devices/fortios/render.d.ts +36 -0
  12. package/dist/types/adapters/devices/fortios/routing.d.ts +32 -0
  13. package/dist/types/adapters/devices/fortios/tree.d.ts +33 -0
  14. package/dist/types/schema/common.d.ts +1 -1
  15. package/dist/types/schema/device.d.ts +1 -1
  16. package/dist/types/schema/firewall.d.ts +3 -3
  17. package/dist/types/schema/interfaces.d.ts +13 -13
  18. package/dist/types/schema/policy.d.ts +3 -3
  19. package/dist/types/schema/routing.d.ts +7 -7
  20. package/dist/types/schema/system.d.ts +7 -7
  21. package/package.json +5 -2
  22. package/src/adapters/devices/catalog.ts +2 -1
  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 +2 -1
  33. package/src/adapters/devices/junos/TRAPS.md +2 -2
  34. package/src/adapters/devices/raisecom-ros/TRAPS.md +2 -2
  35. package/src/adapters/devices/routeros/TRAPS.md +5 -5
  36. package/src/adapters/devices/vrp/TRAPS.md +4 -4
  37. package/src/schema/common.ts +1 -1
  38. package/src/schema/device.ts +1 -1
  39. package/src/schema/firewall.ts +3 -3
  40. package/src/schema/interfaces.ts +13 -13
  41. package/src/schema/policy.ts +3 -3
  42. package/src/schema/routing.ts +7 -7
  43. package/src/schema/system.ts +7 -7
@@ -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,3 +1,4 @@
1
+ import { fortios } from "./fortios";
1
2
  import { junos } from "./junos";
2
3
  import { raisecom } from "./raisecom-ros";
3
4
  import { routeros } from "./routeros";
@@ -5,4 +6,4 @@ import type { DeviceAdapter } from "./types";
5
6
  import { vrp } from "./vrp";
6
7
 
7
8
  /** Every platform the framework can render and apply, by name. */
8
- export const adapters: Record<string, DeviceAdapter> = { routeros, vrp, "raisecom-ros": raisecom, junos };
9
+ export const adapters: Record<string, DeviceAdapter> = { routeros, vrp, "raisecom-ros": raisecom, junos, fortios };
@@ -17,10 +17,10 @@ Behaviour that looks like something else. Each one has been hit on a QFX5100-48S
17
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
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
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`.
20
+ - There is no `protocols stp`. Plain STP is RSTP limited with `force-version stp`.
21
21
  - sFlow interfaces print with their unit, `xe-0/0/0.0`.
22
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.
23
+ - BGP without a licence commits with only a warning, `requires 'bgp' license`, and sessions establish. The same is true for the other licensed features.
24
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
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
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.
@@ -6,7 +6,7 @@ Behaviour that looks like something else. Each one has been hit on a RAX721 runn
6
6
  - The SSH server offers only an `ssh-rsa` host key. The adapter allows it for this platform alone; key exchange, cipher and MAC still negotiate modern ones.
7
7
  - `show running-config` stops about halfway with no error unless `terminal page-break disable` is sent first and the read waits several seconds of silence.
8
8
  - A `?` sent by a script is executed. Once it applied an address with a /8 mask.
9
- - An access-list cannot change while any service holds it: `ACL 1000 is in use`. Every holder is released first and bound again after. `no ssh2 access-list` needs the list number.
9
+ - An access-list cannot change while any service uses it: `ACL 1000 is in use`. Every service that uses it is unbound first and bound again after. `no ssh2 access-list` needs the list number.
10
10
  - `portswitch`, `switchport trunk allowed vlan` and `switchport mode` ask for `y` and do nothing without it, except when they need no confirming: `switchport mode trunk` on a port leaving access mode answers `Set successfully` at once, and a `y` sent after it is refused, `Error input`.
11
11
  - A VLAN is removed with `no vlan <id>`, not `no create vlan`.
12
12
  - The factory account `raisecom` cannot be removed and its service type cannot change. Only its password is ours.
@@ -18,5 +18,5 @@ Behaviour that looks like something else. Each one has been hit on a RAX721 runn
18
18
  - An SNMPv3 user prints as localized keys, `authkey sha <hex> privkey aes128 <hex>`, which authenticate as well as the passphrase. The read redacts them. An access group prints a `notify internet` it was not given. A user's group is removed with `no snmp-server group user <user> usm`.
19
19
  - A VRRP group is enabled once it has an address and preempts unless `no vrrp N preempt`. `no vrrp N ip <address>` removes the whole group.
20
20
  - OSPF settings for an interface sit on the interface, `ip ospf cost`, `ip ospf network ptp`, `ip ospf passive-interface enable`; passive is undone with `... disable`, not `no`.
21
- - `create vlan <list> active` holds every VLAN on one line. A change creates only the new VLANs and removes each gone one with `no vlan <id>`.
21
+ - `create vlan <list> active` has every VLAN on one line. A change creates only the new VLANs and removes each one that is gone with `no vlan <id>`.
22
22
  - An access-list rule is not replaced by one with the same number: the device answers `Set unsuccessfully.` and keeps the old rule. The old one is removed first. `no rule N` answers slowly, after a second or more.
@@ -3,11 +3,11 @@
3
3
  Behaviour that looks like something else. Each one has been hit on a real device.
4
4
 
5
5
  - Every command takes effect as it arrives; there is no candidate configuration. `apply` saves a backup, arms a scheduler that restores it, and restores at once if a command is refused.
6
- - A `/32` address gives no connected route, so a session over it never opens. State the far end: `{ address, peer }` renders `network=<peer>`.
6
+ - A `/32` address gives no connected route, so a session over it never opens. Give the far end: `{ address, peer }` renders `network=<peer>`.
7
7
  - `[find address=X/Y]` unquoted matches nothing, silently. Every selector the plan builds is quoted.
8
8
  - `~` in `find` has no anchors. Match exactly.
9
9
  - A property of the same group is printed as a bare suffix, `output.filter-chain=X .network=Y`, which only reads back inside that command. The parser expands it before building a `set`.
10
- - `/routing bgp connection add` without `local.role` is refused, so the render always states one. On 7.23 one without `instance` is refused too, `missing value(s) of argument(s) instance`, although the export never prints `instance=default`.
10
+ - `/routing bgp connection add` without `local.role` is refused, so the render always writes one. On 7.23 one without `instance` is refused too, `missing value(s) of argument(s) instance`, although the export never prints `instance=default`.
11
11
  - A connection without `input.filter` accepts everything. A neighbor without a policy is given `REJECT-ALL`.
12
12
  - A session edited repeatedly can stick: SYN both ways, no RST, no log. Remove the connection and add it again.
13
13
  - A session that never established is missing from `/routing bgp session print`; look at `/routing bgp connection print`.
@@ -27,17 +27,17 @@ Behaviour that looks like something else. Each one has been hit on a real device
27
27
  - `mac-server` and `mac-winbox` work at layer 2 and ignore the IP firewall; they are limited by interface list.
28
28
  - `time-zone-autodetect` is on by default and wins after a restart. The render turns it off.
29
29
  - The SSH client drops a password sent together with its newline. Send the password, wait, then the newline.
30
- - A serial console login redraws lines unless the user name carries `+cwt`.
30
+ - A serial console login redraws lines unless the user name has `+cwt`.
31
31
  - Narrowing a subnet is a change in several places: the address, the DHCP network, and every switch on the LAN. A mismatch breaks only the return path, so a test from the router passes.
32
32
  - In `find`, a quoted `"yes"` is a string and matches no boolean, silently. Plain words go bare; only values with a slash or a space are quoted.
33
- - `:put [:parse "..."]` compiles a command without running it and names a syntax error or an unknown property. `apply` checks every command this way before the backup is taken.
33
+ - `:put [:parse "..."]` compiles a command without running it and reports a syntax error or an unknown property. `apply` checks every command this way before the backup is taken.
34
34
  - A refused command restores the backup, which restarts the router. Expect every session to re-establish over a minute or two.
35
35
  - `strong-crypto=yes` also hardens the device's own SSH client, which then fails silently toward devices that only speak older algorithms.
36
36
  - SNMPv3 authenticates with MD5 or SHA1 only on 7.23.
37
37
  - In a quoted string `$` starts a variable. The adapter escapes it.
38
38
  - A script sent over SSH exec runs as one line; a multi-line script fails with `expected closing brace`.
39
39
  - `:find` on an array returns a number or `nil`. Globals persist between exec commands of one login.
40
- - A fixed port speed is named by medium, `speed=10G-baseSR-LR` for an optic or `speed=1G-baseT-full` for copper, and only holds with `auto-negotiation=no`. With auto-negotiation back on, the export no longer prints the speed, so the plan resets only auto-negotiation. `speed=""` is refused as ambiguous and `unset speed` does not exist.
40
+ - A fixed port speed is named by medium, `speed=10G-baseSR-LR` for an optic or `speed=1G-baseT-full` for copper, and only stays set with `auto-negotiation=no`. With auto-negotiation back on, the export no longer prints the speed, so the plan resets only auto-negotiation. `speed=""` is refused as ambiguous and `unset speed` does not exist.
41
41
  - `bgp-path-len` counts every AS in the path, prepends included, and takes the same strict comparisons as `dst-len`.
42
42
  - `:parse` checks the command, not the text inside `rule=`. A routing filter rule is only checked when it is added.
43
43
  - `/routing/filter/filter-wizard` adds the rule it builds; it is not a dry run. `set blackhole yes` was proved this way on 7.23, in an unused chain removed right after.