@takodotid/circuit 0.42.1 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunks/{index-20kcjxjg.js → index-3b343xva.js} +3 -0
- package/dist/cli/index.js +21 -16
- 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/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/routeros/index.ts +11 -3
- package/src/platforms/routeros/render.ts +3 -2
- package/src/schema/routing.ts +4 -4
package/dist/cli/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import {
|
|
3
3
|
Utils2,
|
|
4
4
|
Secrets2
|
|
5
|
-
} from "../chunks/index-
|
|
5
|
+
} from "../chunks/index-3b343xva.js";
|
|
6
6
|
import {
|
|
7
7
|
Topology,
|
|
8
8
|
AddressSpace,
|
|
@@ -549,7 +549,7 @@ class Derive {
|
|
|
549
549
|
for (const route of routing?.static ?? []) {
|
|
550
550
|
if (route.bfd && !has("static-route-bfd"))
|
|
551
551
|
found.push(`routing.static ${route.prefix} bfd, BFD on a static route is ${unproved}`);
|
|
552
|
-
if (route.table && !has("routing-tables"))
|
|
552
|
+
if (Utils2.otherTable(route.table) && !has("routing-tables"))
|
|
553
553
|
found.push(`routing.static ${route.prefix} table, a routing table beside the main one is ${unproved}`);
|
|
554
554
|
}
|
|
555
555
|
if (!has("routing-tables")) {
|
|
@@ -560,11 +560,11 @@ class Derive {
|
|
|
560
560
|
}
|
|
561
561
|
const bgp = routing?.bgp;
|
|
562
562
|
for (const [name, group] of Object.entries(bgp?.groups ?? {})) {
|
|
563
|
-
if (group.table && !has("routing-tables"))
|
|
563
|
+
if (Utils2.otherTable(group.table) && !has("routing-tables"))
|
|
564
564
|
found.push(`routing.bgp.groups.${name}.table, a routing table beside the main one is ${unproved}`);
|
|
565
565
|
}
|
|
566
566
|
for (const [name, neighbor] of Object.entries(bgp?.neighbors ?? {})) {
|
|
567
|
-
if (neighbor.table && !has("routing-tables"))
|
|
567
|
+
if (Utils2.otherTable(neighbor.table) && !has("routing-tables"))
|
|
568
568
|
found.push(`routing.bgp.neighbors.${name}.table, a routing table beside the main one is ${unproved}`);
|
|
569
569
|
const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
|
|
570
570
|
const bfd = neighbor.bfd ?? group?.bfd;
|
|
@@ -8343,7 +8343,7 @@ class RouterOsRender {
|
|
|
8343
8343
|
distance: route.distance,
|
|
8344
8344
|
"dst-address": route.prefix,
|
|
8345
8345
|
gateway,
|
|
8346
|
-
"routing-table": route.vrf ?? route.table
|
|
8346
|
+
"routing-table": route.vrf ?? Utils2.otherTable(route.table)
|
|
8347
8347
|
});
|
|
8348
8348
|
}
|
|
8349
8349
|
}
|
|
@@ -8464,7 +8464,7 @@ class RouterOsRender {
|
|
|
8464
8464
|
"output.network": originates ? RouterOsRender.NETWORKS[family] : undefined,
|
|
8465
8465
|
"remote.address": `${neighbor.address}/${addressFamily === "ipv4" ? 32 : 128}`,
|
|
8466
8466
|
"remote.as": settings.remote_as,
|
|
8467
|
-
"routing-table": settings.table,
|
|
8467
|
+
"routing-table": Utils2.otherTable(settings.table),
|
|
8468
8468
|
"tcp-md5-key": settings.password && Secrets2.marker(settings.password),
|
|
8469
8469
|
"use-bfd": settings.bfd ? "yes" : undefined
|
|
8470
8470
|
});
|
|
@@ -8732,19 +8732,22 @@ class RouterOs extends Platform {
|
|
|
8732
8732
|
const findings = [];
|
|
8733
8733
|
for (const [name, neighbor] of Object.entries(bgp?.neighbors ?? {})) {
|
|
8734
8734
|
const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
|
|
8735
|
-
const table = neighbor.table ?? group?.table;
|
|
8735
|
+
const table = Utils2.otherTable(neighbor.table ?? group?.table);
|
|
8736
8736
|
if (!table)
|
|
8737
8737
|
continue;
|
|
8738
8738
|
const families = neighbor.families ?? group?.families ?? [Address2.family(neighbor.address)];
|
|
8739
8739
|
const networks = (bgp?.networks ?? []).filter((prefix) => families.includes(Address2.family(Address2.host(prefix))));
|
|
8740
8740
|
const inTable = (prefix) => statics.some((route) => route.table === table && Address2.canonical(route.prefix) === Address2.canonical(prefix));
|
|
8741
|
-
const
|
|
8741
|
+
const exportPolicy = neighbor.export ?? group?.export;
|
|
8742
|
+
const accepted = (exportPolicy ? device.policies?.[exportPolicy] ?? [] : []).filter((rule) => rule.action === "accept" && rule.match?.prefix).map((rule) => Address2.canonical(rule.match.prefix));
|
|
8743
|
+
const announced = accepted.length ? networks.filter((prefix) => accepted.includes(Address2.canonical(prefix))) : networks;
|
|
8744
|
+
const missing = announced.filter((prefix) => !inTable(prefix));
|
|
8742
8745
|
if (!missing.length)
|
|
8743
8746
|
continue;
|
|
8744
8747
|
const where = `routing.bgp.neighbors.${name}`;
|
|
8745
8748
|
const route = (prefix) => `{ prefix: "${prefix}", blackhole: true, table: "${table}" }`;
|
|
8746
8749
|
const fix = `On RouterOS a session announces from its own table, ${table}`;
|
|
8747
|
-
if (missing.length ===
|
|
8750
|
+
if (missing.length === announced.length) {
|
|
8748
8751
|
findings.push({
|
|
8749
8752
|
level: "error",
|
|
8750
8753
|
device: device.name,
|
|
@@ -11555,8 +11558,8 @@ class Validator {
|
|
|
11555
11558
|
report(`static route ${route.prefix} has bfd without via, the address BFD watches`);
|
|
11556
11559
|
names.expectInterface(`static route ${route.prefix} interface`, route.interface);
|
|
11557
11560
|
names.expect(`static route ${route.prefix} VRF`, route.vrf, route.vrf !== undefined && route.vrf in (device.vrfs ?? {}));
|
|
11558
|
-
names.expect(`static route ${route.prefix} table`, route.table, route.table !== undefined && route.table in (routing?.tables ?? {}));
|
|
11559
|
-
if (route.vrf && route.table)
|
|
11561
|
+
names.expect(`static route ${route.prefix} table`, route.table, route.table !== undefined && (route.table === "main" || (route.table in (routing?.tables ?? {}))));
|
|
11562
|
+
if (route.vrf && Utils2.otherTable(route.table))
|
|
11560
11563
|
report(`static route ${route.prefix} has both vrf and table; a route is in one of them`);
|
|
11561
11564
|
}
|
|
11562
11565
|
Validator.checkTables(device, names, report, cidr);
|
|
@@ -11564,7 +11567,7 @@ class Validator {
|
|
|
11564
11567
|
const isPolicy = (policy) => policy !== undefined && (policy in policies);
|
|
11565
11568
|
for (const prefix of bgp?.networks ?? [])
|
|
11566
11569
|
cidr("BGP network", prefix);
|
|
11567
|
-
const isTable = (table) => table !== undefined && (table in (routing?.tables ?? {}));
|
|
11570
|
+
const isTable = (table) => table !== undefined && (table === "main" || (table in (routing?.tables ?? {})));
|
|
11568
11571
|
for (const [name, group] of Object.entries(bgp?.groups ?? {})) {
|
|
11569
11572
|
for (const policy of [group.import, group.export]) {
|
|
11570
11573
|
names.expect(`BGP group ${name} policy`, policy, isPolicy(policy));
|
|
@@ -11597,11 +11600,13 @@ class Validator {
|
|
|
11597
11600
|
const tables = routing?.tables ?? {};
|
|
11598
11601
|
const bgp = routing?.bgp;
|
|
11599
11602
|
const installed = new Set;
|
|
11600
|
-
for (const route of routing?.static ?? [])
|
|
11601
|
-
|
|
11602
|
-
|
|
11603
|
+
for (const route of routing?.static ?? []) {
|
|
11604
|
+
const table = Utils2.otherTable(route.table);
|
|
11605
|
+
if (table)
|
|
11606
|
+
installed.add(table);
|
|
11607
|
+
}
|
|
11603
11608
|
for (const neighbor of Object.values(bgp?.neighbors ?? {})) {
|
|
11604
|
-
const table = neighbor.table ?? (neighbor.group ? bgp?.groups?.[neighbor.group]?.table : undefined);
|
|
11609
|
+
const table = Utils2.otherTable(neighbor.table ?? (neighbor.group ? bgp?.groups?.[neighbor.group]?.table : undefined));
|
|
11605
11610
|
if (table)
|
|
11606
11611
|
installed.add(table);
|
|
11607
11612
|
}
|
package/dist/helpers/index.js
CHANGED
|
@@ -147,22 +147,31 @@ class Bgp {
|
|
|
147
147
|
action: "accept"
|
|
148
148
|
}));
|
|
149
149
|
};
|
|
150
|
-
const
|
|
151
|
-
const
|
|
150
|
+
const asnOf = (neighbor) => typeof neighbor === "number" ? neighbor : neighbor.asn;
|
|
151
|
+
const passOnTo = (neighbor) => typeof neighbor === "number" ? undefined : neighbor.communities?.blackhole;
|
|
152
|
+
const blackholeExport = (neighbor) => {
|
|
152
153
|
if (scheme.blackhole === undefined)
|
|
153
154
|
return [];
|
|
154
|
-
const passOn = passOnTo(
|
|
155
|
+
const passOn = passOnTo(neighbor);
|
|
155
156
|
const match = { large_community: community(scheme.blackhole.function, 0) };
|
|
156
|
-
|
|
157
|
+
if (!passOn)
|
|
158
|
+
return [{ description: "Blackholed here, never announced", match, action: "reject" }];
|
|
159
|
+
const large = passOn.split(":").length === 3;
|
|
160
|
+
return [
|
|
157
161
|
{
|
|
158
|
-
description: `Blackhole passed on to AS${
|
|
162
|
+
description: `Blackhole passed on to AS${asnOf(neighbor)}`,
|
|
159
163
|
match,
|
|
160
|
-
set: {
|
|
164
|
+
set: {
|
|
165
|
+
remove_communities: ours.standard,
|
|
166
|
+
remove_large_communities: ours.large,
|
|
167
|
+
...large ? { add_large_communities: [passOn] } : { add_communities: [passOn] }
|
|
168
|
+
},
|
|
161
169
|
action: "accept"
|
|
162
170
|
}
|
|
163
|
-
]
|
|
171
|
+
];
|
|
164
172
|
};
|
|
165
|
-
const rejectDoNotAnnounce = (
|
|
173
|
+
const rejectDoNotAnnounce = (neighbor) => {
|
|
174
|
+
const neighborAs = asnOf(neighbor);
|
|
166
175
|
const fn = scheme.do_not_announce;
|
|
167
176
|
if (fn === undefined)
|
|
168
177
|
return [];
|
|
@@ -175,7 +184,8 @@ class Bgp {
|
|
|
175
184
|
}
|
|
176
185
|
];
|
|
177
186
|
};
|
|
178
|
-
const prepends = (
|
|
187
|
+
const prepends = (neighbor) => Bgp.PREPENDS.flatMap(([name, times]) => {
|
|
188
|
+
const neighborAs = asnOf(neighbor);
|
|
179
189
|
const fn = scheme.prepend?.[name];
|
|
180
190
|
if (fn === undefined)
|
|
181
191
|
return [];
|
|
@@ -191,7 +201,7 @@ class Bgp {
|
|
|
191
201
|
description: "Our communities stay inside our network",
|
|
192
202
|
set: { remove_communities: ours.standard, remove_large_communities: ours.large }
|
|
193
203
|
});
|
|
194
|
-
const actions = (
|
|
204
|
+
const actions = (neighbor) => passOnTo(neighbor) ? [...rejectDoNotAnnounce(neighbor), ...blackholeExport(neighbor), ...prepends(neighbor), stripOurs()] : [...blackholeExport(neighbor), ...rejectDoNotAnnounce(neighbor), ...prepends(neighbor), stripOurs()];
|
|
195
205
|
const catalogue = [
|
|
196
206
|
...Object.entries(scheme.learned_from?.classes ?? {}).map(([name, value]) => ({
|
|
197
207
|
community: written(scheme.learned_from.function, value),
|
|
@@ -216,7 +226,7 @@ class Bgp {
|
|
|
216
226
|
...scheme.blackhole !== undefined ? [
|
|
217
227
|
{
|
|
218
228
|
community: written(scheme.blackhole.function, 0),
|
|
219
|
-
description:
|
|
229
|
+
description: `Blackhole: dropped in AS${asn}, and by the upstreams that take it`
|
|
220
230
|
}
|
|
221
231
|
] : []
|
|
222
232
|
];
|
|
@@ -691,6 +701,28 @@ class Filters {
|
|
|
691
701
|
}
|
|
692
702
|
// src/helpers/upstreams.ts
|
|
693
703
|
class Upstreams {
|
|
704
|
+
static catalog(catalog) {
|
|
705
|
+
return catalog;
|
|
706
|
+
}
|
|
707
|
+
static doNotAnnounceTo(upstream, asns, description) {
|
|
708
|
+
if (!asns.length)
|
|
709
|
+
return [];
|
|
710
|
+
const community = upstream.communities?.do_not_announce_to;
|
|
711
|
+
if (!community)
|
|
712
|
+
throw new Error(`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")}`);
|
|
713
|
+
const communities = asns.map(community);
|
|
714
|
+
const large = communities.filter((value) => value.split(":").length === 3);
|
|
715
|
+
const standard = communities.filter((value) => value.split(":").length === 2);
|
|
716
|
+
return [
|
|
717
|
+
{
|
|
718
|
+
description,
|
|
719
|
+
set: {
|
|
720
|
+
...standard.length ? { add_communities: standard } : {},
|
|
721
|
+
...large.length ? { add_large_communities: large } : {}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
];
|
|
725
|
+
}
|
|
694
726
|
static define(catalog, options = {}) {
|
|
695
727
|
const all = Object.entries(catalog);
|
|
696
728
|
const nameOf = (key, upstream) => upstream.name ?? key;
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,8 @@ type Merged<T extends readonly object[]> = T extends readonly [infer Head, ...in
|
|
|
2
2
|
export declare class Utils {
|
|
3
3
|
/** Combine records into one, such as a device's own interfaces and those a module derives for it. Unlike spreading them, a name two of them hold is an error rather than one silently replacing the other. */
|
|
4
4
|
static merge<const T extends readonly Record<string, unknown>[]>(...records: T): Merged<T>;
|
|
5
|
+
/** The table a route or a BGP session is in, beside the main one: `undefined` for the main table, written `main` or left out. */
|
|
6
|
+
static otherTable(table: string | undefined): string | undefined;
|
|
5
7
|
/** Minutes east of UTC an IANA zone is at now, for platforms that take an offset rather than a zone. */
|
|
6
8
|
static timeZoneOffset(zone: string): number;
|
|
7
9
|
}
|
|
@@ -82,15 +82,24 @@ export type CommunityScheme<Asn extends number = number, LearnedFrom extends num
|
|
|
82
82
|
three_times?: number;
|
|
83
83
|
};
|
|
84
84
|
/**
|
|
85
|
-
* A neighbor asks: drop all traffic to this route, `asn:function:0`, usually one address under attack. Your router drops it
|
|
86
|
-
*
|
|
87
|
-
* `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.
|
|
85
|
+
* 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.
|
|
88
86
|
*/
|
|
89
87
|
blackhole?: {
|
|
90
88
|
function: number;
|
|
91
|
-
upstreams?: Readonly<Record<number, StandardCommunity>>;
|
|
92
89
|
};
|
|
93
90
|
};
|
|
91
|
+
/** The communities another network offers us, as its NOC gives them: what it does with a route we send it. */
|
|
92
|
+
export type NeighborCommunities = {
|
|
93
|
+
/** It drops traffic to a route we send it with this community. */
|
|
94
|
+
blackhole?: StandardCommunity | LargeCommunity;
|
|
95
|
+
/** 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`. */
|
|
96
|
+
do_not_announce_to?: (asn: number) => StandardCommunity | LargeCommunity;
|
|
97
|
+
};
|
|
98
|
+
/** A neighbor an export goes to: its AS, or its AS and the communities it offers. */
|
|
99
|
+
export type ExportNeighbor = number | {
|
|
100
|
+
asn: number;
|
|
101
|
+
communities?: NeighborCommunities;
|
|
102
|
+
};
|
|
94
103
|
/** The class names a scheme declares, or any name when it declares none. */
|
|
95
104
|
type ClassName<Classes extends CommunityClasses> = {} extends Classes ? string : Extract<keyof Classes, string>;
|
|
96
105
|
/** BGP policy: an import sanity policy, the shapes most imports and exports take, a community scheme, and helpers that read a device's BGP neighbors. */
|
|
@@ -153,10 +162,12 @@ export declare class Bgp {
|
|
|
153
162
|
* 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.
|
|
154
163
|
*
|
|
155
164
|
* - `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`.
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
* - `
|
|
159
|
-
* - `
|
|
165
|
+
* Each export helper takes the neighbor: its AS, or `{ asn, communities }` with the communities it offers, such as an `Upstreams.catalog` entry.
|
|
166
|
+
*
|
|
167
|
+
* - `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.
|
|
168
|
+
* - `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.
|
|
169
|
+
* - `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`.
|
|
170
|
+
* - `prepends(neighbor)`: prepends once, twice or three times toward this neighbor, where a route asks. Nothing without `prepend`.
|
|
160
171
|
* - `stripOurs()`: one rule removing every community of yours, `Our communities stay inside our network`.
|
|
161
172
|
* - `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.
|
|
162
173
|
* - `catalogue`: every community, for `communities` in `defineNetwork`.
|
|
@@ -170,10 +181,10 @@ export declare class Bgp {
|
|
|
170
181
|
site?: number;
|
|
171
182
|
trusted?: boolean;
|
|
172
183
|
}) => PolicyRule<never, never>;
|
|
173
|
-
actions: (
|
|
174
|
-
blackholeExport: (
|
|
175
|
-
rejectDoNotAnnounce: (
|
|
176
|
-
prepends: (
|
|
184
|
+
actions: (neighbor: ExportNeighbor) => PolicyRule<never, never>[];
|
|
185
|
+
blackholeExport: (neighbor: ExportNeighbor) => PolicyRule<never, never>[];
|
|
186
|
+
rejectDoNotAnnounce: (neighbor: ExportNeighbor) => PolicyRule<never, never>[];
|
|
187
|
+
prepends: (neighbor: ExportNeighbor) => PolicyRule<never, never>[];
|
|
177
188
|
stripOurs: () => PolicyRule<never, never>;
|
|
178
189
|
blackhole: <S extends string>(prefixSets: readonly S[]) => PolicyRule<never, S>[];
|
|
179
190
|
catalogue: Community[];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { Bgp } from "./bgp.js";
|
|
2
|
-
export type { AnnounceOptions, Announced, CommunityScheme, DefaultOnlyOptions, LearnOptions, SanityLengths } from "./bgp.js";
|
|
2
|
+
export type { AnnounceOptions, Announced, CommunityScheme, DefaultOnlyOptions, ExportNeighbor, LearnOptions, NeighborCommunities, SanityLengths, } from "./bgp.js";
|
|
3
3
|
export { Cables } from "./cables.js";
|
|
4
4
|
export type { FarEnd } from "./cables.js";
|
|
5
5
|
export { Checks } from "./checks.js";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Exchange as ExchangeLan, Family, FilterRule, Gre, InterfaceAddress, IP, Neighbor, NeighborSettings, PolicyRule, Port, Secret, Vlan, VlanInterface } from "../schema/index.js";
|
|
2
|
+
import type { NeighborCommunities } from "./bgp.js";
|
|
2
3
|
/** A route policy an upstream's sessions use: its name on the device, and its rules, in order. */
|
|
3
4
|
export type UpstreamPolicy = {
|
|
4
5
|
/** The policy's name, as the device carries it. */
|
|
@@ -67,7 +68,7 @@ export type Upstream = {
|
|
|
67
68
|
families?: readonly Family[];
|
|
68
69
|
password?: Secret;
|
|
69
70
|
};
|
|
70
|
-
/** The routing table its sessions install the routes they learn in
|
|
71
|
+
/** 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
72
|
table?: string;
|
|
72
73
|
/** 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
74
|
limits?: {
|
|
@@ -80,6 +81,8 @@ export type Upstream = {
|
|
|
80
81
|
export: UpstreamPolicy;
|
|
81
82
|
/** Its VLAN is listed by `privateDrops()`, for an ACL that drops RFC 1918 sources arriving from it. Nothing else is done with it. */
|
|
82
83
|
drop_private?: true;
|
|
84
|
+
/** 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. */
|
|
85
|
+
communities?: NeighborCommunities;
|
|
83
86
|
};
|
|
84
87
|
/** Upstreams by key. */
|
|
85
88
|
export type UpstreamCatalog = Readonly<Record<string, Upstream>>;
|
|
@@ -145,14 +148,22 @@ type GroupName<C> = Values<{
|
|
|
145
148
|
group: infer N extends string;
|
|
146
149
|
} ? N : never;
|
|
147
150
|
}>;
|
|
148
|
-
/** Each routing table name. */
|
|
149
|
-
type TableName<C> = Values<{
|
|
151
|
+
/** Each routing table name, other than main. */
|
|
152
|
+
type TableName<C> = Exclude<Values<{
|
|
150
153
|
[K in keyof C]: C[K] extends {
|
|
151
154
|
table: infer N extends string;
|
|
152
155
|
} ? N : never;
|
|
153
|
-
}>;
|
|
156
|
+
}>, "main">;
|
|
154
157
|
/** A network's upstreams written once, and each device's part built from them. */
|
|
155
158
|
export declare class Upstreams {
|
|
159
|
+
/**
|
|
160
|
+
* 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.
|
|
161
|
+
*/
|
|
162
|
+
static catalog<const C extends UpstreamCatalog>(catalog: C & UpstreamCatalog): C;
|
|
163
|
+
/**
|
|
164
|
+
* 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.
|
|
165
|
+
*/
|
|
166
|
+
static doNotAnnounceTo(upstream: Upstream, asns: readonly number[], description: string): PolicyRule<never, never>[];
|
|
156
167
|
/**
|
|
157
168
|
* 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:
|
|
158
169
|
*
|
|
@@ -15,8 +15,8 @@ export type StaticRoute<I extends string, F extends string, T extends string = n
|
|
|
15
15
|
distance?: number;
|
|
16
16
|
/** The VRF the route is in. The main table when absent. */
|
|
17
17
|
vrf?: F;
|
|
18
|
-
/** The table the route is in
|
|
19
|
-
table?: T;
|
|
18
|
+
/** The table the route is in: `main`, or one of `routing.tables`. The main table when absent; write `main` to say so. A route has a `vrf` or a `table`, not both. */
|
|
19
|
+
table?: T | "main";
|
|
20
20
|
/** Why the route exists. */
|
|
21
21
|
description?: string;
|
|
22
22
|
};
|
|
@@ -54,8 +54,8 @@ export type NeighborSettings<P extends string, T extends string = never> = {
|
|
|
54
54
|
passive?: boolean;
|
|
55
55
|
/** Detect a dead link in under a second with BFD. */
|
|
56
56
|
bfd?: boolean;
|
|
57
|
-
/** The table the routes learned are installed in
|
|
58
|
-
table?: T;
|
|
57
|
+
/** The table the routes learned are installed in: `main`, or one of `routing.tables`. The main table when absent; write `main` to say so. On RouterOS the session also announces from that table, so each prefix of `networks` it announces needs a route in the table too, and a running session takes a new table only when it restarts. */
|
|
58
|
+
table?: T | "main";
|
|
59
59
|
};
|
|
60
60
|
/** One BGP session. Settings it does not set come from its group. */
|
|
61
61
|
export type Neighbor<P extends string, G extends string, T extends string = never> = NeighborSettings<P, T> & {
|
package/package.json
CHANGED
package/src/core/utils.ts
CHANGED
|
@@ -19,6 +19,11 @@ export class Utils {
|
|
|
19
19
|
return result as Merged<T>;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** The table a route or a BGP session is in, beside the main one: `undefined` for the main table, written `main` or left out. */
|
|
23
|
+
static otherTable(table: string | undefined): string | undefined {
|
|
24
|
+
return table === "main" ? undefined : table;
|
|
25
|
+
}
|
|
26
|
+
|
|
22
27
|
/** Minutes east of UTC an IANA zone is at now, for platforms that take an offset rather than a zone. */
|
|
23
28
|
static timeZoneOffset(zone: string): number {
|
|
24
29
|
const name = new Intl.DateTimeFormat("en-US", {
|
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;
|
|
@@ -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,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The neutral model as a RouterOS 7 export, written in the form `/export` prints so the two compare line for line.
|
|
2
2
|
|
|
3
3
|
import { X509Certificate } from "node:crypto";
|
|
4
|
+
import { Utils } from "../../core/utils";
|
|
4
5
|
import { Address } from "../../core/address";
|
|
5
6
|
import { Secrets } from "../../core/secrets";
|
|
6
7
|
import type {
|
|
@@ -1147,7 +1148,7 @@ export class RouterOsRender {
|
|
|
1147
1148
|
distance: route.distance,
|
|
1148
1149
|
"dst-address": route.prefix,
|
|
1149
1150
|
gateway,
|
|
1150
|
-
"routing-table": route.vrf ?? route.table,
|
|
1151
|
+
"routing-table": route.vrf ?? Utils.otherTable(route.table),
|
|
1151
1152
|
});
|
|
1152
1153
|
}
|
|
1153
1154
|
}
|
|
@@ -1292,7 +1293,7 @@ export class RouterOsRender {
|
|
|
1292
1293
|
"output.network": originates ? RouterOsRender.NETWORKS[family] : undefined,
|
|
1293
1294
|
"remote.address": `${neighbor.address}/${addressFamily === "ipv4" ? 32 : 128}`,
|
|
1294
1295
|
"remote.as": settings.remote_as,
|
|
1295
|
-
"routing-table": settings.table,
|
|
1296
|
+
"routing-table": Utils.otherTable(settings.table),
|
|
1296
1297
|
"tcp-md5-key": settings.password && Secrets.marker(settings.password),
|
|
1297
1298
|
"use-bfd": settings.bfd ? "yes" : undefined,
|
|
1298
1299
|
});
|
package/src/schema/routing.ts
CHANGED
|
@@ -16,8 +16,8 @@ export type StaticRoute<I extends string, F extends string, T extends string = n
|
|
|
16
16
|
distance?: number;
|
|
17
17
|
/** The VRF the route is in. The main table when absent. */
|
|
18
18
|
vrf?: F;
|
|
19
|
-
/** The table the route is in
|
|
20
|
-
table?: T;
|
|
19
|
+
/** The table the route is in: `main`, or one of `routing.tables`. The main table when absent; write `main` to say so. A route has a `vrf` or a `table`, not both. */
|
|
20
|
+
table?: T | "main";
|
|
21
21
|
/** Why the route exists. */
|
|
22
22
|
description?: string;
|
|
23
23
|
};
|
|
@@ -57,8 +57,8 @@ export type NeighborSettings<P extends string, T extends string = never> = {
|
|
|
57
57
|
passive?: boolean;
|
|
58
58
|
/** Detect a dead link in under a second with BFD. */
|
|
59
59
|
bfd?: boolean;
|
|
60
|
-
/** The table the routes learned are installed in
|
|
61
|
-
table?: T;
|
|
60
|
+
/** The table the routes learned are installed in: `main`, or one of `routing.tables`. The main table when absent; write `main` to say so. On RouterOS the session also announces from that table, so each prefix of `networks` it announces needs a route in the table too, and a running session takes a new table only when it restarts. */
|
|
61
|
+
table?: T | "main";
|
|
62
62
|
};
|
|
63
63
|
|
|
64
64
|
/** One BGP session. Settings it does not set come from its group. */
|