@takodotid/circuit 0.42.1 → 0.43.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.
- package/dist/chunks/{index-20kcjxjg.js → index-3b343xva.js} +3 -0
- package/dist/cli/index.js +109 -54
- package/dist/helpers/index.js +43 -11
- package/dist/index.js +1 -1
- package/dist/types/core/utils.d.ts +2 -0
- package/dist/types/helpers/bgp.d.ts +23 -12
- package/dist/types/helpers/index.d.ts +1 -1
- package/dist/types/helpers/upstreams.d.ts +15 -4
- package/dist/types/platforms/fortios/plan.d.ts +15 -5
- package/dist/types/schema/routing.d.ts +4 -4
- package/package.json +1 -1
- package/src/core/utils.ts +5 -0
- package/src/core/validator.ts +10 -5
- package/src/helpers/bgp.ts +50 -30
- package/src/helpers/index.ts +10 -1
- package/src/helpers/upstreams.ts +43 -4
- package/src/platforms/derive.ts +4 -3
- package/src/platforms/fortios/plan.ts +132 -44
- package/src/platforms/routeros/index.ts +11 -3
- package/src/platforms/routeros/render.ts +3 -2
- package/src/schema/routing.ts +4 -4
package/src/core/validator.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Rules every network has to satisfy, whatever its design. Types catch most of this at compile time; these run again at load, because config is data and may not come from TypeScript.
|
|
2
2
|
|
|
3
3
|
import { X509Certificate } from "node:crypto";
|
|
4
|
+
import { Utils } from "./utils";
|
|
4
5
|
import { Platforms } from "../platforms";
|
|
5
6
|
import { Catalog } from "../platforms/catalog";
|
|
6
7
|
import type { Device, Switched } from "../schema";
|
|
@@ -297,9 +298,10 @@ export class Validator {
|
|
|
297
298
|
names.expect(
|
|
298
299
|
`static route ${route.prefix} table`,
|
|
299
300
|
route.table,
|
|
300
|
-
route.table !== undefined && route.table in (routing?.tables ?? {})
|
|
301
|
+
route.table !== undefined && (route.table === "main" || route.table in (routing?.tables ?? {}))
|
|
301
302
|
);
|
|
302
|
-
if (route.vrf &&
|
|
303
|
+
if (route.vrf && Utils.otherTable(route.table))
|
|
304
|
+
report(`static route ${route.prefix} has both vrf and table; a route is in one of them`);
|
|
303
305
|
}
|
|
304
306
|
|
|
305
307
|
Validator.checkTables(device, names, report, cidr);
|
|
@@ -309,7 +311,7 @@ export class Validator {
|
|
|
309
311
|
|
|
310
312
|
for (const prefix of bgp?.networks ?? []) cidr("BGP network", prefix);
|
|
311
313
|
|
|
312
|
-
const isTable = (table: string | undefined) => table !== undefined && table in (routing?.tables ?? {});
|
|
314
|
+
const isTable = (table: string | undefined) => table !== undefined && (table === "main" || table in (routing?.tables ?? {}));
|
|
313
315
|
|
|
314
316
|
for (const [name, group] of Object.entries(bgp?.groups ?? {})) {
|
|
315
317
|
for (const policy of [group.import, group.export]) {
|
|
@@ -351,9 +353,12 @@ export class Validator {
|
|
|
351
353
|
const bgp = routing?.bgp;
|
|
352
354
|
|
|
353
355
|
const installed = new Set<string>();
|
|
354
|
-
for (const route of routing?.static ?? [])
|
|
356
|
+
for (const route of routing?.static ?? []) {
|
|
357
|
+
const table = Utils.otherTable(route.table);
|
|
358
|
+
if (table) installed.add(table);
|
|
359
|
+
}
|
|
355
360
|
for (const neighbor of Object.values(bgp?.neighbors ?? {})) {
|
|
356
|
-
const table = neighbor.table ?? (neighbor.group ? bgp?.groups?.[neighbor.group]?.table : undefined);
|
|
361
|
+
const table = Utils.otherTable(neighbor.table ?? (neighbor.group ? bgp?.groups?.[neighbor.group]?.table : undefined));
|
|
357
362
|
if (table) installed.add(table);
|
|
358
363
|
}
|
|
359
364
|
|
package/src/helpers/bgp.ts
CHANGED
|
@@ -80,13 +80,22 @@ export type CommunityScheme<
|
|
|
80
80
|
/** A neighbor asks: prepend toward an AS once, twice or three times, `asn:function:asn`. */
|
|
81
81
|
prepend?: { once?: number; twice?: number; three_times?: number };
|
|
82
82
|
/**
|
|
83
|
-
* A neighbor asks: drop all traffic to this route, `asn:function:0`, usually one address under attack. Your router drops it
|
|
84
|
-
*
|
|
85
|
-
* `upstreams` maps an upstream's AS to its own blackhole community, as its NOC gives it. The route is passed to that upstream with it, so the traffic stops before it reaches your links. An upstream not listed never receives the route.
|
|
83
|
+
* A neighbor asks: drop all traffic to this route, `asn:function:0`, usually one address under attack. Your router drops it. An upstream whose own blackhole community you know (`communities.blackhole` on the neighbor you pass to `actions`) receives the route with it and drops the traffic too; any other never receives the route.
|
|
86
84
|
*/
|
|
87
|
-
blackhole?: { function: number
|
|
85
|
+
blackhole?: { function: number };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** The communities another network offers us, as its NOC gives them: what it does with a route we send it. */
|
|
89
|
+
export type NeighborCommunities = {
|
|
90
|
+
/** It drops traffic to a route we send it with this community. */
|
|
91
|
+
blackhole?: StandardCommunity | LargeCommunity;
|
|
92
|
+
/** It does not pass a route we send it with this community to the AS given, such as an exchange's route servers' `rs-asn:0:peer-asn`. */
|
|
93
|
+
do_not_announce_to?: (asn: number) => StandardCommunity | LargeCommunity;
|
|
88
94
|
};
|
|
89
95
|
|
|
96
|
+
/** A neighbor an export goes to: its AS, or its AS and the communities it offers. */
|
|
97
|
+
export type ExportNeighbor = number | { asn: number; communities?: NeighborCommunities };
|
|
98
|
+
|
|
90
99
|
/** The class names a scheme declares, or any name when it declares none. */
|
|
91
100
|
type ClassName<Classes extends CommunityClasses> = {} extends Classes ? string : Extract<keyof Classes, string>;
|
|
92
101
|
|
|
@@ -248,10 +257,12 @@ export class Bgp {
|
|
|
248
257
|
* Policy rules and a catalogue from one community scheme, so the communities your routers set, the ones they act on, and the ones you publish never disagree.
|
|
249
258
|
*
|
|
250
259
|
* - `tag(neighborClass, neighborAs, { site, trusted })`: one rule for an import policy, `{ description: "Learned from AS<n>", set }`. Its `set` removes your standard communities and your large ones (a `trusted` neighbor loses only the "where it came from" functions, so it keeps its requests: do not announce, prepend, blackhole), then adds `learned_from`, `learned_at` and `learned_from_as` where the scheme has them. `site` is needed when the scheme has `learned_at`.
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
* - `
|
|
254
|
-
* - `
|
|
260
|
+
* Each export helper takes the neighbor: its AS, or `{ asn, communities }` with the communities it offers, such as an `Upstreams.catalog` entry.
|
|
261
|
+
*
|
|
262
|
+
* - `actions(neighbor)`: the rules for an export policy toward one neighbor, before the rules that accept. They are `[...blackholeExport(n), ...rejectDoNotAnnounce(n), ...prepends(n), stripOurs()]`; toward a neighbor that takes blackholes (`communities.blackhole`), `rejectDoNotAnnounce(n)` comes first, so a route asked not to go there is never passed on as a blackhole.
|
|
263
|
+
* - `blackholeExport(neighbor)`: a blackholed route goes no further, `Blackholed here, never announced`, unless the neighbor has `communities.blackhole`: then it is passed on with it, so the neighbor drops the traffic too. Nothing without `blackhole` in the scheme.
|
|
264
|
+
* - `rejectDoNotAnnounce(neighbor)`: rejects a route asked not to be announced anywhere, then one asked not to be announced to this neighbor. Nothing without `do_not_announce`.
|
|
265
|
+
* - `prepends(neighbor)`: prepends once, twice or three times toward this neighbor, where a route asks. Nothing without `prepend`.
|
|
255
266
|
* - `stripOurs()`: one rule removing every community of yours, `Our communities stay inside our network`.
|
|
256
267
|
* - `blackhole(prefixSets)`: rules for the import policy of a neighbor you trust with blackholes, before anything that rejects long prefixes. They take a route the neighbor asked to blackhole, but only inside `prefixSets`, its own space, and drop traffic to it here. One rule per set, so one call covers both families.
|
|
257
268
|
* - `catalogue`: every community, for `communities` in `defineNetwork`.
|
|
@@ -331,28 +342,38 @@ export class Bgp {
|
|
|
331
342
|
}));
|
|
332
343
|
};
|
|
333
344
|
|
|
345
|
+
/** The neighbor's AS. */
|
|
346
|
+
const asnOf = (neighbor: ExportNeighbor) => (typeof neighbor === "number" ? neighbor : neighbor.asn);
|
|
347
|
+
|
|
334
348
|
/** The neighbor's own blackhole community, when it takes blackholes from us. */
|
|
335
|
-
const passOnTo = (
|
|
349
|
+
const passOnTo = (neighbor: ExportNeighbor) => (typeof neighbor === "number" ? undefined : neighbor.communities?.blackhole);
|
|
336
350
|
|
|
337
|
-
const blackholeExport = (
|
|
351
|
+
const blackholeExport = (neighbor: ExportNeighbor): PolicyRule<never, never>[] => {
|
|
338
352
|
if (scheme.blackhole === undefined) return [];
|
|
339
353
|
|
|
340
|
-
const passOn = passOnTo(
|
|
354
|
+
const passOn = passOnTo(neighbor);
|
|
341
355
|
const match = { large_community: community(scheme.blackhole.function, 0) };
|
|
356
|
+
if (!passOn) return [{ description: "Blackholed here, never announced", match, action: "reject" }];
|
|
342
357
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
358
|
+
const large = passOn.split(":").length === 3;
|
|
359
|
+
return [
|
|
360
|
+
{
|
|
361
|
+
description: `Blackhole passed on to AS${asnOf(neighbor)}`,
|
|
362
|
+
match,
|
|
363
|
+
set: {
|
|
364
|
+
remove_communities: ours.standard,
|
|
365
|
+
remove_large_communities: ours.large,
|
|
366
|
+
...(large
|
|
367
|
+
? { add_large_communities: [passOn as LargeCommunity] }
|
|
368
|
+
: { add_communities: [passOn as StandardCommunity] }),
|
|
369
|
+
},
|
|
370
|
+
action: "accept",
|
|
371
|
+
},
|
|
372
|
+
];
|
|
353
373
|
};
|
|
354
374
|
|
|
355
|
-
const rejectDoNotAnnounce = (
|
|
375
|
+
const rejectDoNotAnnounce = (neighbor: ExportNeighbor): PolicyRule<never, never>[] => {
|
|
376
|
+
const neighborAs = asnOf(neighbor);
|
|
356
377
|
const fn = scheme.do_not_announce;
|
|
357
378
|
if (fn === undefined) return [];
|
|
358
379
|
|
|
@@ -366,8 +387,9 @@ export class Bgp {
|
|
|
366
387
|
];
|
|
367
388
|
};
|
|
368
389
|
|
|
369
|
-
const prepends = (
|
|
390
|
+
const prepends = (neighbor: ExportNeighbor): PolicyRule<never, never>[] =>
|
|
370
391
|
Bgp.PREPENDS.flatMap(([name, times]) => {
|
|
392
|
+
const neighborAs = asnOf(neighbor);
|
|
371
393
|
const fn = scheme.prepend?.[name];
|
|
372
394
|
if (fn === undefined) return [];
|
|
373
395
|
|
|
@@ -386,10 +408,10 @@ export class Bgp {
|
|
|
386
408
|
});
|
|
387
409
|
|
|
388
410
|
// A route asked not to be announced to a neighbor is never passed on to it as a blackhole either; where nothing is passed on, both only reject.
|
|
389
|
-
const actions = (
|
|
390
|
-
passOnTo(
|
|
391
|
-
? [...rejectDoNotAnnounce(
|
|
392
|
-
: [...blackholeExport(
|
|
411
|
+
const actions = (neighbor: ExportNeighbor): PolicyRule<never, never>[] =>
|
|
412
|
+
passOnTo(neighbor)
|
|
413
|
+
? [...rejectDoNotAnnounce(neighbor), ...blackholeExport(neighbor), ...prepends(neighbor), stripOurs()]
|
|
414
|
+
: [...blackholeExport(neighbor), ...rejectDoNotAnnounce(neighbor), ...prepends(neighbor), stripOurs()];
|
|
393
415
|
|
|
394
416
|
const catalogue: Community[] = [
|
|
395
417
|
...Object.entries<number>(scheme.learned_from?.classes ?? {}).map(([name, value]) => ({
|
|
@@ -424,9 +446,7 @@ export class Bgp {
|
|
|
424
446
|
? [
|
|
425
447
|
{
|
|
426
448
|
community: written(scheme.blackhole.function, 0),
|
|
427
|
-
description:
|
|
428
|
-
? `Blackhole: dropped in AS${asn} and by its upstreams that take it`
|
|
429
|
-
: `Blackhole: dropped in AS${asn}, not announced further`,
|
|
449
|
+
description: `Blackhole: dropped in AS${asn}, and by the upstreams that take it`,
|
|
430
450
|
},
|
|
431
451
|
]
|
|
432
452
|
: []),
|
package/src/helpers/index.ts
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
// Building blocks most networks write the same way: address lists, cables written once for both ends, edge filters, a BGP import sanity policy, an exchange's member ports and route servers, upstreams written once, checks, and helpers for checks of your own. Each returns plain config to place where it belongs, so nothing here applies itself.
|
|
2
2
|
|
|
3
3
|
export { Bgp } from "./bgp";
|
|
4
|
-
export type {
|
|
4
|
+
export type {
|
|
5
|
+
AnnounceOptions,
|
|
6
|
+
Announced,
|
|
7
|
+
CommunityScheme,
|
|
8
|
+
DefaultOnlyOptions,
|
|
9
|
+
ExportNeighbor,
|
|
10
|
+
LearnOptions,
|
|
11
|
+
NeighborCommunities,
|
|
12
|
+
SanityLengths,
|
|
13
|
+
} from "./bgp";
|
|
5
14
|
export { Cables } from "./cables";
|
|
6
15
|
export type { FarEnd } from "./cables";
|
|
7
16
|
export { Checks } from "./checks";
|
package/src/helpers/upstreams.ts
CHANGED
|
@@ -5,6 +5,8 @@ import type {
|
|
|
5
5
|
FilterRule,
|
|
6
6
|
Gre,
|
|
7
7
|
InterfaceAddress,
|
|
8
|
+
StandardCommunity,
|
|
9
|
+
LargeCommunity,
|
|
8
10
|
IP,
|
|
9
11
|
Neighbor,
|
|
10
12
|
NeighborSettings,
|
|
@@ -15,6 +17,7 @@ import type {
|
|
|
15
17
|
Vlan,
|
|
16
18
|
VlanInterface,
|
|
17
19
|
} from "../schema";
|
|
20
|
+
import type { NeighborCommunities } from "./bgp";
|
|
18
21
|
|
|
19
22
|
/** A route policy an upstream's sessions use: its name on the device, and its rules, in order. */
|
|
20
23
|
export type UpstreamPolicy = {
|
|
@@ -67,7 +70,7 @@ export type Upstream = {
|
|
|
67
70
|
group?: string;
|
|
68
71
|
/** The sessions are more than one hop away, from this address of ours. */
|
|
69
72
|
multihop?: { local_address: IP; families?: readonly Family[]; password?: Secret };
|
|
70
|
-
/** The routing table its sessions install the routes they learn in
|
|
73
|
+
/** The routing table its sessions install the routes they learn in: `main`, or one of the router's `routing.tables`, for a transit that carries only one block's traffic out. The main table when absent; write `main` to say so. On RouterOS the sessions also announce from that table, so what they announce needs a route there too. */
|
|
71
74
|
table?: string;
|
|
72
75
|
/** The most prefixes a session may send, by its family: about twice what it sends. Over it, the session closes until it is cleared. */
|
|
73
76
|
limits?: { ipv4?: number; ipv6?: number };
|
|
@@ -77,6 +80,8 @@ export type Upstream = {
|
|
|
77
80
|
export: UpstreamPolicy;
|
|
78
81
|
/** Its VLAN is listed by `privateDrops()`, for an ACL that drops RFC 1918 sources arriving from it. Nothing else is done with it. */
|
|
79
82
|
drop_private?: true;
|
|
83
|
+
/** The communities it offers, as its NOC gives them: its blackhole community, and how to keep a route from one AS. `communityScheme().actions(upstream)` passes blackholes on with it, and `Upstreams.doNotAnnounceTo` uses the other. */
|
|
84
|
+
communities?: NeighborCommunities;
|
|
80
85
|
};
|
|
81
86
|
|
|
82
87
|
/** Upstreams by key. */
|
|
@@ -107,11 +112,45 @@ type PolicyName<C> = Values<{
|
|
|
107
112
|
}>;
|
|
108
113
|
/** Each BGP group name. */
|
|
109
114
|
type GroupName<C> = Values<{ [K in keyof C]: C[K] extends { group: infer N extends string } ? N : never }>;
|
|
110
|
-
/** Each routing table name. */
|
|
111
|
-
type TableName<C> = Values<{ [K in keyof C]: C[K] extends { table: infer N extends string } ? N : never }>;
|
|
115
|
+
/** Each routing table name, other than main. */
|
|
116
|
+
type TableName<C> = Exclude<Values<{ [K in keyof C]: C[K] extends { table: infer N extends string } ? N : never }>, "main">;
|
|
112
117
|
|
|
113
118
|
/** A network's upstreams written once, and each device's part built from them. */
|
|
114
119
|
export class Upstreams {
|
|
120
|
+
/**
|
|
121
|
+
* A catalog of upstreams, by key, written once and checked: a key the shape doesn't have is refused by its name. It gives the catalog back as written, so its keys and entries can be read, and is what `define` takes.
|
|
122
|
+
*/
|
|
123
|
+
static catalog<const C extends UpstreamCatalog>(catalog: C & UpstreamCatalog): C {
|
|
124
|
+
return catalog;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Export rules asking an upstream not to pass our routes to these ASes, with its own `communities.do_not_announce_to`: one rule, `description`, adding one community per AS. Nothing for no AS. An upstream without `do_not_announce_to` is an error, since it can't be asked.
|
|
129
|
+
*/
|
|
130
|
+
static doNotAnnounceTo(upstream: Upstream, asns: readonly number[], description: string): PolicyRule<never, never>[] {
|
|
131
|
+
if (!asns.length) return [];
|
|
132
|
+
|
|
133
|
+
const community = upstream.communities?.do_not_announce_to;
|
|
134
|
+
if (!community)
|
|
135
|
+
throw new Error(
|
|
136
|
+
`Upstreams: ${upstream.name ?? `AS${upstream.asn}`} has no communities.do_not_announce_to, so it can't be asked to keep our routes from AS${asns.join(", AS")}`
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const communities = asns.map(community);
|
|
140
|
+
const large = communities.filter((value) => value.split(":").length === 3) as LargeCommunity[];
|
|
141
|
+
const standard = communities.filter((value) => value.split(":").length === 2) as StandardCommunity[];
|
|
142
|
+
|
|
143
|
+
return [
|
|
144
|
+
{
|
|
145
|
+
description,
|
|
146
|
+
set: {
|
|
147
|
+
...(standard.length ? { add_communities: standard } : {}),
|
|
148
|
+
...(large.length ? { add_large_communities: large } : {}),
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
];
|
|
152
|
+
}
|
|
153
|
+
|
|
115
154
|
/**
|
|
116
155
|
* Each upstream written once: its AS, where it lands, its sessions, limits and policies, and every name it puts on a device. Everything a device needs is built from it:
|
|
117
156
|
*
|
|
@@ -229,7 +268,7 @@ export class Upstreams {
|
|
|
229
268
|
role: upstream.role,
|
|
230
269
|
...(upstream.import ? { import: upstream.import.name as PolicyName<C> } : {}),
|
|
231
270
|
export: upstream.export.name as PolicyName<C>,
|
|
232
|
-
...(upstream.table ? { table: upstream.table as TableName<C> } : {}),
|
|
271
|
+
...(upstream.table ? { table: upstream.table as TableName<C> | "main" } : {}),
|
|
233
272
|
...(multihop
|
|
234
273
|
? {
|
|
235
274
|
local_address: multihop.local_address,
|
package/src/platforms/derive.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Values more than one adapter derives from a device in the same way.
|
|
2
2
|
|
|
3
3
|
import { Address } from "../core/address";
|
|
4
|
+
import { Utils } from "../core/utils";
|
|
4
5
|
import type { Device } from "../schema";
|
|
5
6
|
|
|
6
7
|
/** A feature a platform renders once proved on one of its devices. */
|
|
@@ -54,7 +55,7 @@ export class Derive {
|
|
|
54
55
|
for (const route of routing?.static ?? []) {
|
|
55
56
|
if (route.bfd && !has("static-route-bfd"))
|
|
56
57
|
found.push(`routing.static ${route.prefix} bfd, BFD on a static route is ${unproved}`);
|
|
57
|
-
if (route.table && !has("routing-tables"))
|
|
58
|
+
if (Utils.otherTable(route.table) && !has("routing-tables"))
|
|
58
59
|
found.push(`routing.static ${route.prefix} table, a routing table beside the main one is ${unproved}`);
|
|
59
60
|
}
|
|
60
61
|
|
|
@@ -66,12 +67,12 @@ export class Derive {
|
|
|
66
67
|
const bgp = routing?.bgp;
|
|
67
68
|
|
|
68
69
|
for (const [name, group] of Object.entries(bgp?.groups ?? {})) {
|
|
69
|
-
if (group.table && !has("routing-tables"))
|
|
70
|
+
if (Utils.otherTable(group.table) && !has("routing-tables"))
|
|
70
71
|
found.push(`routing.bgp.groups.${name}.table, a routing table beside the main one is ${unproved}`);
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
for (const [name, neighbor] of Object.entries(bgp?.neighbors ?? {})) {
|
|
74
|
-
if (neighbor.table && !has("routing-tables"))
|
|
75
|
+
if (Utils.otherTable(neighbor.table) && !has("routing-tables"))
|
|
75
76
|
found.push(`routing.bgp.neighbors.${name}.table, a routing table beside the main one is ${unproved}`);
|
|
76
77
|
|
|
77
78
|
const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
|
|
@@ -12,13 +12,17 @@ type Table = {
|
|
|
12
12
|
factory?: readonly string[];
|
|
13
13
|
/** The device keeps the entries in an order of their own, which `move` changes. */
|
|
14
14
|
ordered?: true;
|
|
15
|
+
/**
|
|
16
|
+
* The render numbers the entries by their place, but on the device each keeps the number it was created with. An entry is known by what it holds, or else by the settings of the first of `keys` that only it holds, such as its description. A new entry takes a number the device does not use, after `prefix` when the render names entries so.
|
|
17
|
+
*/
|
|
18
|
+
numbered?: { keys: readonly (readonly string[])[]; prefix?: string };
|
|
15
19
|
/** An entry is known by what it holds, not by its number. */
|
|
16
20
|
byContent?: true;
|
|
17
21
|
/** Settings the device gives an entry it creates. They are unset when the render does not hold them. */
|
|
18
22
|
created?: (entry: FortiOsBlock) => readonly string[];
|
|
19
23
|
/** Settings an entry keeps for life. A change deletes the entry and creates it again. */
|
|
20
24
|
fixed?: readonly string[];
|
|
21
|
-
/** The device refuses
|
|
25
|
+
/** Every entry that changes or goes is deleted before any is created. The device refuses a static route the same as another, `Duplicated entry`, even for a moment. */
|
|
22
26
|
distinct?: true;
|
|
23
27
|
};
|
|
24
28
|
|
|
@@ -154,7 +158,13 @@ export class FortiOsPlan {
|
|
|
154
158
|
"Fortinet_SSL_ED448",
|
|
155
159
|
];
|
|
156
160
|
|
|
157
|
-
|
|
161
|
+
/** An entry known by what it holds, or else by its description. */
|
|
162
|
+
private static readonly BY_COMMENTS: Table["numbered"] = { keys: [["comments"]] };
|
|
163
|
+
|
|
164
|
+
private static readonly ORDERED: Table = { ordered: true, numbered: FortiOsPlan.BY_COMMENTS };
|
|
165
|
+
|
|
166
|
+
/** A static route known by what it holds, or else by its description. Many routes share a destination, so that is no key. */
|
|
167
|
+
private static readonly ROUTES: Table = { numbered: { keys: [["comment"]] }, distinct: true };
|
|
158
168
|
|
|
159
169
|
/** Tables whose entries need more than a plain comparison, by section. */
|
|
160
170
|
private static readonly TABLES: Record<string, Table> = {
|
|
@@ -180,8 +190,12 @@ export class FortiOsPlan {
|
|
|
180
190
|
"firewall local-in-policy": FortiOsPlan.ORDERED,
|
|
181
191
|
"firewall local-in-policy6": FortiOsPlan.ORDERED,
|
|
182
192
|
"firewall central-snat-map": FortiOsPlan.ORDERED,
|
|
183
|
-
"
|
|
184
|
-
"
|
|
193
|
+
"firewall acl": { numbered: FortiOsPlan.BY_COMMENTS },
|
|
194
|
+
"firewall acl6": { numbered: FortiOsPlan.BY_COMMENTS },
|
|
195
|
+
// A virtual IP that changes is also known by the address it answers on.
|
|
196
|
+
"firewall vip": { numbered: { keys: [["comment"], ["extintf", "extip", "protocol", "extport"]], prefix: "dnat-" } },
|
|
197
|
+
"router static": FortiOsPlan.ROUTES,
|
|
198
|
+
"router static6": FortiOsPlan.ROUTES,
|
|
185
199
|
};
|
|
186
200
|
|
|
187
201
|
/** Settings the device keeps for itself: an object's UUID and an interface's SNMP index. */
|
|
@@ -399,8 +413,8 @@ export class FortiOsPlan {
|
|
|
399
413
|
|
|
400
414
|
const entries = table?.byContent
|
|
401
415
|
? FortiOsPlan.byContent(path, want, have)
|
|
402
|
-
: table?.
|
|
403
|
-
? FortiOsPlan.
|
|
416
|
+
: table?.numbered
|
|
417
|
+
? FortiOsPlan.numbered(path, want, have, table, table.numbered, context)
|
|
404
418
|
: FortiOsPlan.byId(path, want, have, table, context);
|
|
405
419
|
send.push(...entries.send);
|
|
406
420
|
show.push(...entries.show);
|
|
@@ -416,7 +430,7 @@ export class FortiOsPlan {
|
|
|
416
430
|
return { send, show, removals, removed };
|
|
417
431
|
}
|
|
418
432
|
|
|
419
|
-
/** Entries matched by name: new ones created, changed ones edited, gone ones deleted
|
|
433
|
+
/** Entries matched by name: new ones created, changed ones edited, gone ones deleted. */
|
|
420
434
|
private static byId(path: string[], want: FortiOsBlock, have: FortiOsBlock, table: Table | undefined, context: Context): Diff {
|
|
421
435
|
const send: string[] = [];
|
|
422
436
|
const show: string[] = [];
|
|
@@ -456,70 +470,144 @@ export class FortiOsPlan {
|
|
|
456
470
|
removed.push(...FortiOsPlan.shown("-", [...path, FortiOsTree.bare(entry.id)], entry.block));
|
|
457
471
|
}
|
|
458
472
|
|
|
459
|
-
if (table?.ordered) {
|
|
460
|
-
const moved = FortiOsPlan.moves(want, have);
|
|
461
|
-
send.push(...moved);
|
|
462
|
-
show.push(...moved.map((line) => `+ ${FortiOsPlan.labelOf(path)}${line}`));
|
|
463
|
-
}
|
|
464
|
-
|
|
465
473
|
return { send, show, removals, removed };
|
|
466
474
|
}
|
|
467
475
|
|
|
468
|
-
/**
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
476
|
+
/**
|
|
477
|
+
* Entries the render numbers by their place, while on the device each keeps the number it was created with, so they are matched by what they are.
|
|
478
|
+
*
|
|
479
|
+
* An entry that holds the same as one on the device is kept. One that changes is found by the first of the table's keys that no other entry left to match shares, such as its description, and edited under its own number. Any other is new and takes a number the device does not use; in a table the device keeps in order, it is created at the end and moved into place. An entry on the device that nothing matched is deleted last, so every entry the render keeps stays on the device through the whole change.
|
|
480
|
+
*
|
|
481
|
+
* In a `distinct` table, an entry that changes is deleted and created again under its own number, and every deletion comes before any creation.
|
|
482
|
+
*/
|
|
483
|
+
private static numbered(
|
|
484
|
+
path: string[],
|
|
485
|
+
want: FortiOsBlock,
|
|
486
|
+
have: FortiOsBlock,
|
|
487
|
+
table: Table,
|
|
488
|
+
numbered: NonNullable<Table["numbered"]>,
|
|
489
|
+
context: Context
|
|
490
|
+
): Diff {
|
|
491
|
+
const send: string[] = [];
|
|
472
492
|
const show: string[] = [];
|
|
473
493
|
|
|
494
|
+
// The number in a name the render gives, such as 7 in `dnat-7`. An entry named otherwise is never kept.
|
|
495
|
+
const pattern = new RegExp(`^${numbered.prefix ?? ""}(\\d+)$`);
|
|
496
|
+
const numberOf = (entry: Entry) => Number(pattern.exec(FortiOsTree.bare(entry.id))?.[1] ?? NaN);
|
|
497
|
+
|
|
498
|
+
const held = [...have.entries.values()].filter((entry) => !FortiOsPlan.isFactory(table, entry));
|
|
499
|
+
const kept = new Map<string, Entry>();
|
|
500
|
+
const unmatched = new Set(held.filter((entry) => Number.isInteger(numberOf(entry))));
|
|
501
|
+
|
|
502
|
+
const keep = (key: string, old: Entry) => {
|
|
503
|
+
kept.set(key, old);
|
|
504
|
+
unmatched.delete(old);
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
// Only entries with the same signature can hold the same, so an entry is compared with those alone.
|
|
508
|
+
const alike = Map.groupBy(unmatched, (old) => FortiOsPlan.signature(old.block, context.section));
|
|
509
|
+
const same = (entry: Entry, old: Entry) => !FortiOsPlan.compare([], entry.block, old.block, undefined, false, context).send.length;
|
|
510
|
+
|
|
474
511
|
for (const [key, entry] of want.entries) {
|
|
475
|
-
const
|
|
476
|
-
const
|
|
512
|
+
const candidates = alike.get(FortiOsPlan.signature(entry.block, context.section)) ?? [];
|
|
513
|
+
const index = candidates.findIndex((old) => same(entry, old));
|
|
514
|
+
if (index >= 0) keep(key, candidates.splice(index, 1)[0]!);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
for (const settings of numbered.keys) {
|
|
518
|
+
const valueOf = (entry: Entry) => {
|
|
519
|
+
const parts = settings.map((setting) => FortiOsPlan.canonical(setting, entry.block.settings.get(setting) ?? ""));
|
|
520
|
+
return parts.some(Boolean) ? parts.join("\n") : undefined;
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
const changed = Map.groupBy(
|
|
524
|
+
[...want.entries].filter(([key]) => !kept.has(key)),
|
|
525
|
+
([, entry]) => valueOf(entry)
|
|
526
|
+
);
|
|
527
|
+
const left = Map.groupBy(unmatched, valueOf);
|
|
528
|
+
|
|
529
|
+
for (const [value, entries] of changed) {
|
|
530
|
+
const old = left.get(value);
|
|
531
|
+
if (value !== undefined && entries.length === 1 && old?.length === 1) keep(entries[0]![0], old[0]!);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// A number no entry on the device holds, not even one deleted at the end.
|
|
536
|
+
let next = Math.max(0, ...[...have.entries.values()].map(numberOf).filter(Number.isInteger)) + 1;
|
|
537
|
+
const wanted: string[] = [];
|
|
538
|
+
const replaced: string[] = [];
|
|
539
|
+
|
|
540
|
+
for (const [key, entry] of want.entries) {
|
|
541
|
+
const old = kept.get(key);
|
|
542
|
+
|
|
543
|
+
if (!old) {
|
|
544
|
+
const name = `${numbered.prefix ?? ""}${next++}`;
|
|
545
|
+
const id = numbered.prefix ? FortiOsTree.quote(name) : name;
|
|
546
|
+
wanted.push(id);
|
|
547
|
+
send.push(...FortiOsPlan.creation(entry, table, id));
|
|
548
|
+
show.push(...FortiOsPlan.shown("+", [...path, name], entry.block));
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
wanted.push(old.id);
|
|
477
553
|
|
|
478
|
-
|
|
479
|
-
|
|
554
|
+
const where = [...path, FortiOsTree.bare(old.id)];
|
|
555
|
+
const diff = FortiOsPlan.compare(where, entry.block, old.block, undefined, false, context);
|
|
556
|
+
if (!diff.send.length) continue;
|
|
480
557
|
|
|
481
|
-
|
|
482
|
-
|
|
558
|
+
if (table.distinct) {
|
|
559
|
+
replaced.push(`delete ${old.id}`);
|
|
560
|
+
send.push(...FortiOsPlan.creation(entry, table, old.id));
|
|
561
|
+
show.push(...FortiOsPlan.shown("-", where, old.block), ...FortiOsPlan.shown("+", where, entry.block));
|
|
562
|
+
continue;
|
|
483
563
|
}
|
|
484
564
|
|
|
485
|
-
|
|
486
|
-
show.push(...
|
|
565
|
+
send.push(`edit ${old.id}`, ...diff.send, "next");
|
|
566
|
+
show.push(...diff.show);
|
|
487
567
|
}
|
|
488
568
|
|
|
489
|
-
|
|
490
|
-
if (want.entries.has(key) || FortiOsPlan.isFactory(table, entry)) continue;
|
|
569
|
+
const keptEntries = new Set(kept.values());
|
|
491
570
|
|
|
492
|
-
|
|
493
|
-
|
|
571
|
+
if (table.ordered) {
|
|
572
|
+
const order = held.filter((entry) => keptEntries.has(entry)).map((entry) => entry.id);
|
|
573
|
+
const moved = FortiOsPlan.moves(wanted, order);
|
|
574
|
+
send.push(...moved);
|
|
575
|
+
show.push(...moved.map((line) => `+ ${FortiOsPlan.labelOf(path)}${line}`));
|
|
494
576
|
}
|
|
495
577
|
|
|
496
|
-
|
|
497
|
-
|
|
578
|
+
const gone = held.filter((entry) => !keptEntries.has(entry));
|
|
579
|
+
const removals = gone.map((entry) => `delete ${entry.id}`);
|
|
580
|
+
const removed = gone.flatMap((entry) => FortiOsPlan.shown("-", [...path, FortiOsTree.bare(entry.id)], entry.block));
|
|
498
581
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
const order = [...have.entries.keys()].filter((key) => want.entries.has(key));
|
|
503
|
-
order.push(...wanted.filter((key) => !have.entries.has(key)));
|
|
582
|
+
if (table.distinct) return { send: [...replaced, ...removals, ...send], show: [...removed, ...show], removals: [], removed: [] };
|
|
583
|
+
return { send, show, removals, removed };
|
|
584
|
+
}
|
|
504
585
|
|
|
586
|
+
/** The `move` commands that put a table's entries in the render's order, by the device's numbers: `wanted` in the render's order, `held` the kept ones in the device's. Kept entries hold their place on the device and new ones are added at the end. */
|
|
587
|
+
private static moves(wanted: readonly string[], held: readonly string[]): string[] {
|
|
588
|
+
const order = [...held, ...wanted.filter((id) => !held.includes(id))];
|
|
505
589
|
const send: string[] = [];
|
|
506
|
-
const idOf = (key: string) => want.entries.get(key)!.id;
|
|
507
590
|
|
|
508
591
|
for (let index = 0; index < wanted.length; index++) {
|
|
509
|
-
|
|
592
|
+
const id = wanted[index]!;
|
|
593
|
+
if (order[index] === id) continue;
|
|
510
594
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
order.splice(
|
|
514
|
-
order.splice(index, 0, key);
|
|
595
|
+
send.push(index === 0 ? `move ${id} before ${order[0]}` : `move ${id} after ${wanted[index - 1]}`);
|
|
596
|
+
order.splice(order.indexOf(id), 1);
|
|
597
|
+
order.splice(index, 0, id);
|
|
515
598
|
}
|
|
516
599
|
|
|
517
600
|
return send;
|
|
518
601
|
}
|
|
519
602
|
|
|
520
|
-
/** An entry's settings in the form two entries compare in, whatever their numbers. */
|
|
521
|
-
private static signature(block: FortiOsBlock): string {
|
|
603
|
+
/** An entry's settings in the form two entries compare in, whatever their numbers. A setting at its section's default is left out, as the device does not print it. */
|
|
604
|
+
private static signature(block: FortiOsBlock, section = ""): string {
|
|
605
|
+
const defaults = FortiOsPlan.DEFAULTS[section] ?? {};
|
|
606
|
+
const isDefault = (key: string, value: string) =>
|
|
607
|
+
defaults[key] !== undefined && FortiOsPlan.canonical(key, defaults[key]) === FortiOsPlan.canonical(key, value);
|
|
608
|
+
|
|
522
609
|
return [...block.settings]
|
|
610
|
+
.filter(([key, value]) => !isDefault(key, value))
|
|
523
611
|
.map(([key, value]) => `${key} ${FortiOsPlan.canonical(key, value)}`)
|
|
524
612
|
.sort()
|
|
525
613
|
.join("\n");
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// The platform applies every command the moment it arrives and has no candidate configuration, so a run is guarded three ways. Every command is first compiled on the device without running, so a syntax error or an unknown property costs nothing. A backup is saved and a scheduler armed to restore it, and the run must log in again in a fresh session before the scheduler is removed. A command the device refuses restores the backup at once, rather than leaving the device half applied.
|
|
4
4
|
|
|
5
5
|
import type { Client } from "ssh2";
|
|
6
|
+
import { Utils } from "../../core/utils";
|
|
6
7
|
import { Address } from "../../core/address";
|
|
7
8
|
import type { Neighbor } from "../../core/cabling";
|
|
8
9
|
import type { Finding } from "../../core/define";
|
|
@@ -152,21 +153,28 @@ export class RouterOs extends Platform {
|
|
|
152
153
|
|
|
153
154
|
for (const [name, neighbor] of Object.entries(bgp?.neighbors ?? {})) {
|
|
154
155
|
const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
|
|
155
|
-
const table = neighbor.table ?? group?.table;
|
|
156
|
+
const table = Utils.otherTable(neighbor.table ?? group?.table);
|
|
156
157
|
if (!table) continue;
|
|
157
158
|
|
|
158
159
|
const families = neighbor.families ?? group?.families ?? [Address.family(neighbor.address)];
|
|
159
160
|
const networks = (bgp?.networks ?? []).filter((prefix) => families.includes(Address.family(Address.host(prefix))));
|
|
160
161
|
const inTable = (prefix: string) =>
|
|
161
162
|
statics.some((route) => route.table === table && Address.canonical(route.prefix) === Address.canonical(prefix));
|
|
162
|
-
|
|
163
|
+
// What its export accepts by prefix: a network it would reject anyway needs no route in the table. Every network when the export names none.
|
|
164
|
+
const exportPolicy = neighbor.export ?? group?.export;
|
|
165
|
+
const accepted = (exportPolicy ? (device.policies?.[exportPolicy] ?? []) : [])
|
|
166
|
+
.filter((rule) => rule.action === "accept" && rule.match?.prefix)
|
|
167
|
+
.map((rule) => Address.canonical(rule.match!.prefix!));
|
|
168
|
+
const announced = accepted.length ? networks.filter((prefix) => accepted.includes(Address.canonical(prefix))) : networks;
|
|
169
|
+
|
|
170
|
+
const missing = announced.filter((prefix) => !inTable(prefix));
|
|
163
171
|
if (!missing.length) continue;
|
|
164
172
|
|
|
165
173
|
const where = `routing.bgp.neighbors.${name}`;
|
|
166
174
|
const route = (prefix: string) => `{ prefix: "${prefix}", blackhole: true, table: "${table}" }`;
|
|
167
175
|
const fix = `On RouterOS a session announces from its own table, ${table}`;
|
|
168
176
|
|
|
169
|
-
if (missing.length ===
|
|
177
|
+
if (missing.length === announced.length) {
|
|
170
178
|
findings.push({
|
|
171
179
|
level: "error",
|
|
172
180
|
device: device.name,
|