@takodotid/circuit 0.42.0 → 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 +123 -21
- 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/bgp-wait.d.ts +2 -2
- package/dist/types/platforms/platform.d.ts +3 -0
- package/dist/types/platforms/routeros/index.d.ts +14 -1
- package/dist/types/platforms/routeros/plan.d.ts +4 -0
- 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 +14 -6
- package/src/helpers/bgp.ts +50 -30
- package/src/helpers/index.ts +10 -1
- package/src/helpers/upstreams.ts +43 -4
- package/src/platforms/bgp-wait.ts +11 -11
- package/src/platforms/derive.ts +4 -3
- package/src/platforms/platform.ts +3 -0
- package/src/platforms/routeros/TRAPS.md +3 -1
- package/src/platforms/routeros/index.ts +130 -2
- package/src/platforms/routeros/plan.ts +17 -1
- 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;
|
|
@@ -6822,13 +6822,13 @@ class BgpWait {
|
|
|
6822
6822
|
static AFTER_APPLY = { seconds: 180, every: 10 };
|
|
6823
6823
|
static async until(read, enough, limits, waiting = () => {}) {
|
|
6824
6824
|
const deadline = Date.now() + limits.seconds * 1000;
|
|
6825
|
-
let
|
|
6826
|
-
while (!enough(
|
|
6827
|
-
waiting(
|
|
6825
|
+
let value = await read();
|
|
6826
|
+
while (!enough(value) && Date.now() < deadline) {
|
|
6827
|
+
waiting(value);
|
|
6828
6828
|
await sleep(Math.min(limits.every * 1000, Math.max(0, deadline - Date.now())));
|
|
6829
|
-
|
|
6829
|
+
value = await read();
|
|
6830
6830
|
}
|
|
6831
|
-
return
|
|
6831
|
+
return value;
|
|
6832
6832
|
}
|
|
6833
6833
|
static upCount(sessions) {
|
|
6834
6834
|
return sessions.filter((session) => session.established).length;
|
|
@@ -7176,6 +7176,7 @@ class RouterOsPlan {
|
|
|
7176
7176
|
"/interface bridge vlan"
|
|
7177
7177
|
]);
|
|
7178
7178
|
static CHANGED_IN_PLACE = new Set(["/interface vlan"]);
|
|
7179
|
+
static RESTART_ON = { "/routing bgp connection": ["routing-table"] };
|
|
7179
7180
|
static OLD_RULES = "frameworkOldRules";
|
|
7180
7181
|
problems = [];
|
|
7181
7182
|
constructor(sendSecrets) {
|
|
@@ -7346,8 +7347,16 @@ class RouterOsPlan {
|
|
|
7346
7347
|
const update = this.changes(entry, current, `${menu} ${entryKey}`);
|
|
7347
7348
|
if (!Object.keys(update).length)
|
|
7348
7349
|
continue;
|
|
7350
|
+
const selector = RouterOsPlan.find(menu, RouterOsExport.identity(current));
|
|
7351
|
+
const restartsOn = (RouterOsPlan.RESTART_ON[menu] ?? []).filter((property) => (property in update));
|
|
7352
|
+
if (restartsOn.length) {
|
|
7353
|
+
changeStep.show.push(`~ ${entryKey} ${RouterOsPlan.describe(update)}, restarting the session: it takes a new ${restartsOn.join(" and ")} only when it restarts`);
|
|
7354
|
+
changeStep.send.push(RouterOsPlan.line(menu, "set", selector, [], { disabled: "yes", ...update }));
|
|
7355
|
+
changeStep.send.push(RouterOsPlan.line(menu, "set", selector, [], { disabled: "no" }));
|
|
7356
|
+
continue;
|
|
7357
|
+
}
|
|
7349
7358
|
changeStep.show.push(`~ ${entryKey} ${RouterOsPlan.describe(update)}`);
|
|
7350
|
-
changeStep.send.push(RouterOsPlan.line(menu, "set",
|
|
7359
|
+
changeStep.send.push(RouterOsPlan.line(menu, "set", selector, [], update));
|
|
7351
7360
|
}
|
|
7352
7361
|
const removed = existingEntries.filter((entry) => !kept.has(key(entry)));
|
|
7353
7362
|
const wholeLists = new Set;
|
|
@@ -8334,7 +8343,7 @@ class RouterOsRender {
|
|
|
8334
8343
|
distance: route.distance,
|
|
8335
8344
|
"dst-address": route.prefix,
|
|
8336
8345
|
gateway,
|
|
8337
|
-
"routing-table": route.vrf ?? route.table
|
|
8346
|
+
"routing-table": route.vrf ?? Utils2.otherTable(route.table)
|
|
8338
8347
|
});
|
|
8339
8348
|
}
|
|
8340
8349
|
}
|
|
@@ -8455,7 +8464,7 @@ class RouterOsRender {
|
|
|
8455
8464
|
"output.network": originates ? RouterOsRender.NETWORKS[family] : undefined,
|
|
8456
8465
|
"remote.address": `${neighbor.address}/${addressFamily === "ipv4" ? 32 : 128}`,
|
|
8457
8466
|
"remote.as": settings.remote_as,
|
|
8458
|
-
"routing-table": settings.table,
|
|
8467
|
+
"routing-table": Utils2.otherTable(settings.table),
|
|
8459
8468
|
"tcp-md5-key": settings.password && Secrets2.marker(settings.password),
|
|
8460
8469
|
"use-bfd": settings.bfd ? "yes" : undefined
|
|
8461
8470
|
});
|
|
@@ -8687,6 +8696,10 @@ class RouterOs extends Platform {
|
|
|
8687
8696
|
':put ("disabled|" . [/routing bgp connection get $c name] . "|" . [/routing bgp connection get $c remote.address])',
|
|
8688
8697
|
"}"
|
|
8689
8698
|
].join(" ");
|
|
8699
|
+
static listAdvertisements(prefixes) {
|
|
8700
|
+
const where = prefixes.map((prefix) => `dst=${prefix}`).join(" or ");
|
|
8701
|
+
return `:foreach a in=[/routing bgp advertisements print as-value where ${where}] do={ :put ("ADV|" . ($a->"peer") . "|" . ($a->"dst")) }`;
|
|
8702
|
+
}
|
|
8690
8703
|
static clearLimit(address) {
|
|
8691
8704
|
return `/routing bgp session clear [find remote.address=${address} and limit-exceeded] flag=limit-exceeded`;
|
|
8692
8705
|
}
|
|
@@ -8713,6 +8726,45 @@ class RouterOs extends Platform {
|
|
|
8713
8726
|
};
|
|
8714
8727
|
extension = ".rsc";
|
|
8715
8728
|
console = RouterOs.CONSOLE;
|
|
8729
|
+
checks(device) {
|
|
8730
|
+
const bgp = device.routing?.bgp;
|
|
8731
|
+
const statics = device.routing?.static ?? [];
|
|
8732
|
+
const findings = [];
|
|
8733
|
+
for (const [name, neighbor] of Object.entries(bgp?.neighbors ?? {})) {
|
|
8734
|
+
const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
|
|
8735
|
+
const table = Utils2.otherTable(neighbor.table ?? group?.table);
|
|
8736
|
+
if (!table)
|
|
8737
|
+
continue;
|
|
8738
|
+
const families = neighbor.families ?? group?.families ?? [Address2.family(neighbor.address)];
|
|
8739
|
+
const networks = (bgp?.networks ?? []).filter((prefix) => families.includes(Address2.family(Address2.host(prefix))));
|
|
8740
|
+
const inTable = (prefix) => statics.some((route) => route.table === table && Address2.canonical(route.prefix) === Address2.canonical(prefix));
|
|
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));
|
|
8745
|
+
if (!missing.length)
|
|
8746
|
+
continue;
|
|
8747
|
+
const where = `routing.bgp.neighbors.${name}`;
|
|
8748
|
+
const route = (prefix) => `{ prefix: "${prefix}", blackhole: true, table: "${table}" }`;
|
|
8749
|
+
const fix = `On RouterOS a session announces from its own table, ${table}`;
|
|
8750
|
+
if (missing.length === announced.length) {
|
|
8751
|
+
findings.push({
|
|
8752
|
+
level: "error",
|
|
8753
|
+
device: device.name,
|
|
8754
|
+
message: `${where} would announce nothing: ${fix}, which has no route for any of networks. Add the routes it announces to ${table} too, such as ${route(missing[0])} in routing.static`
|
|
8755
|
+
});
|
|
8756
|
+
continue;
|
|
8757
|
+
}
|
|
8758
|
+
for (const prefix of missing) {
|
|
8759
|
+
findings.push({
|
|
8760
|
+
level: "warning",
|
|
8761
|
+
device: device.name,
|
|
8762
|
+
message: `${where} does not announce ${prefix}: ${fix}, which has no route for it. If its export should announce it, add ${route(prefix)} to routing.static`
|
|
8763
|
+
});
|
|
8764
|
+
}
|
|
8765
|
+
}
|
|
8766
|
+
return findings;
|
|
8767
|
+
}
|
|
8716
8768
|
unsupported(device) {
|
|
8717
8769
|
const found = [];
|
|
8718
8770
|
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
@@ -8950,6 +9002,7 @@ ${certificates}` : "";
|
|
|
8950
9002
|
const switchRules = plan.steps.some((step) => step.send.some((command) => command.startsWith(RouterOs.SWITCH_RULES)));
|
|
8951
9003
|
const limits = options.bgp ?? BgpWait.AFTER_APPLY;
|
|
8952
9004
|
const establishedBefore = await this.establishedSessions(device, options);
|
|
9005
|
+
const announcedBefore = establishedBefore.length ? await this.announcedNow(device, options) : [];
|
|
8953
9006
|
return {
|
|
8954
9007
|
run: async () => {
|
|
8955
9008
|
if (!switchRules && !establishedBefore.length) {
|
|
@@ -8960,7 +9013,8 @@ ${certificates}` : "";
|
|
|
8960
9013
|
try {
|
|
8961
9014
|
const switchProblems = switchRules ? await RouterOs.invalidSwitchRules(session, device) : [];
|
|
8962
9015
|
const bgpProblems = await this.bgpBack(device, session, establishedBefore, limits);
|
|
8963
|
-
|
|
9016
|
+
const announceProblems = await this.announcedBack(device, session, announcedBefore, limits);
|
|
9017
|
+
return [...switchProblems, ...bgpProblems, ...announceProblems];
|
|
8964
9018
|
} finally {
|
|
8965
9019
|
session.close();
|
|
8966
9020
|
}
|
|
@@ -9030,6 +9084,50 @@ ${certificates}` : "";
|
|
|
9030
9084
|
}
|
|
9031
9085
|
return down.map((bgp) => bgp.limit ? `BGP session ${bgp.neighbor} was established before the apply and closed on its limit${bgp.limit.max !== undefined ? ` of ${bgp.limit.max} prefixes` : ""}: the neighbor sends more than it may. Find out why, then: circuit bgp ${device.name} --neighbors '^${bgp.neighbor}$' --clear-limit` : `BGP session ${bgp.neighbor} was established before the apply and is not ${limits.seconds} s after it (${bgp.reason ?? (bgp.uptime === "no session" ? "no session" : "not established")}). Look with: circuit bgp ${device.name} --log`);
|
|
9032
9086
|
}
|
|
9087
|
+
async announcedNow(device, options) {
|
|
9088
|
+
if (!device.routing?.bgp?.networks?.length)
|
|
9089
|
+
return [];
|
|
9090
|
+
const session = await this.session(device, options);
|
|
9091
|
+
try {
|
|
9092
|
+
return await this.announced(device, session);
|
|
9093
|
+
} finally {
|
|
9094
|
+
session.close();
|
|
9095
|
+
}
|
|
9096
|
+
}
|
|
9097
|
+
async announced(device, session) {
|
|
9098
|
+
const bgp = device.routing?.bgp;
|
|
9099
|
+
const networks = bgp?.networks ?? [];
|
|
9100
|
+
if (!networks.length)
|
|
9101
|
+
return [];
|
|
9102
|
+
const sessions = (await session.run(RouterOs.LIST_BGP_SESSIONS, 60000)).split(/\r?\n/).map((line) => line.trim().split("|")).filter((fields) => fields.length > 1 && fields[0] !== "disabled");
|
|
9103
|
+
const neighbors = Object.entries(bgp?.neighbors ?? {});
|
|
9104
|
+
const neighborOf = (sessionName) => {
|
|
9105
|
+
const remote = sessions.find(([name]) => name === sessionName)?.[1];
|
|
9106
|
+
const byAddress = neighbors.find(([, neighbor]) => neighbor.address === remote);
|
|
9107
|
+
const byName = neighbors.find(([name]) => name === sessionName.replace(/-\d+$/, ""));
|
|
9108
|
+
return (byAddress ?? byName)?.[0];
|
|
9109
|
+
};
|
|
9110
|
+
const output = await session.run(RouterOs.listAdvertisements(networks), 60000);
|
|
9111
|
+
return output.split(/\r?\n/).map((line) => line.trim().split("|")).flatMap(([marker, peer, prefix]) => {
|
|
9112
|
+
const neighbor = marker === "ADV" && peer ? neighborOf(peer) : undefined;
|
|
9113
|
+
return neighbor && prefix ? [`${neighbor} ${prefix}`] : [];
|
|
9114
|
+
}).sort();
|
|
9115
|
+
}
|
|
9116
|
+
async announcedBack(device, session, before, limits) {
|
|
9117
|
+
if (!before.length)
|
|
9118
|
+
return [];
|
|
9119
|
+
const missing = (now) => before.filter((pair) => !now.includes(pair));
|
|
9120
|
+
const after = await BgpWait.until(() => this.announced(device, session), (now) => !missing(now).length, limits, (now) => console.log(` BGP: ${missing(now).length} announcement(s) not back yet, checking again in ${limits.every} s`));
|
|
9121
|
+
const gone = missing(after);
|
|
9122
|
+
if (!gone.length) {
|
|
9123
|
+
console.log(` BGP: every session announces what it announced before`);
|
|
9124
|
+
return [];
|
|
9125
|
+
}
|
|
9126
|
+
return gone.map((pair) => {
|
|
9127
|
+
const [neighbor, prefix] = pair.split(" ");
|
|
9128
|
+
return `BGP session ${neighbor} announced ${prefix} before the apply and does not ${limits.seconds} s after it, though it may still be established. A RouterOS session announces from its routing table: that table needs a route for ${prefix}, and a session whose table changed must restart. Look with: circuit show ${device.name} '/routing bgp advertisements print'`;
|
|
9129
|
+
});
|
|
9130
|
+
}
|
|
9033
9131
|
static async invalidSwitchRules(session, device) {
|
|
9034
9132
|
const answer = (await session.run(RouterOs.COUNT_INVALID_SWITCH_RULES)).trim();
|
|
9035
9133
|
if (!/^\d+$/.test(answer))
|
|
@@ -11245,9 +11343,11 @@ class Validator {
|
|
|
11245
11343
|
const adapter = Platforms.find(device.platform);
|
|
11246
11344
|
if (!adapter)
|
|
11247
11345
|
report(`no adapter for platform ${device.platform}`);
|
|
11248
|
-
else
|
|
11346
|
+
else {
|
|
11249
11347
|
for (const field of adapter.unsupported(device))
|
|
11250
11348
|
report(`${device.platform} cannot express ${field}`);
|
|
11349
|
+
findings.push(...adapter.checks?.(device) ?? []);
|
|
11350
|
+
}
|
|
11251
11351
|
}
|
|
11252
11352
|
findings.push(...CrossChecks.run(network.devices));
|
|
11253
11353
|
for (const check of network.checks ?? [])
|
|
@@ -11458,8 +11558,8 @@ class Validator {
|
|
|
11458
11558
|
report(`static route ${route.prefix} has bfd without via, the address BFD watches`);
|
|
11459
11559
|
names.expectInterface(`static route ${route.prefix} interface`, route.interface);
|
|
11460
11560
|
names.expect(`static route ${route.prefix} VRF`, route.vrf, route.vrf !== undefined && route.vrf in (device.vrfs ?? {}));
|
|
11461
|
-
names.expect(`static route ${route.prefix} table`, route.table, route.table !== undefined && route.table in (routing?.tables ?? {}));
|
|
11462
|
-
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))
|
|
11463
11563
|
report(`static route ${route.prefix} has both vrf and table; a route is in one of them`);
|
|
11464
11564
|
}
|
|
11465
11565
|
Validator.checkTables(device, names, report, cidr);
|
|
@@ -11467,7 +11567,7 @@ class Validator {
|
|
|
11467
11567
|
const isPolicy = (policy) => policy !== undefined && (policy in policies);
|
|
11468
11568
|
for (const prefix of bgp?.networks ?? [])
|
|
11469
11569
|
cidr("BGP network", prefix);
|
|
11470
|
-
const isTable = (table) => table !== undefined && (table in (routing?.tables ?? {}));
|
|
11570
|
+
const isTable = (table) => table !== undefined && (table === "main" || (table in (routing?.tables ?? {})));
|
|
11471
11571
|
for (const [name, group] of Object.entries(bgp?.groups ?? {})) {
|
|
11472
11572
|
for (const policy of [group.import, group.export]) {
|
|
11473
11573
|
names.expect(`BGP group ${name} policy`, policy, isPolicy(policy));
|
|
@@ -11500,11 +11600,13 @@ class Validator {
|
|
|
11500
11600
|
const tables = routing?.tables ?? {};
|
|
11501
11601
|
const bgp = routing?.bgp;
|
|
11502
11602
|
const installed = new Set;
|
|
11503
|
-
for (const route of routing?.static ?? [])
|
|
11504
|
-
|
|
11505
|
-
|
|
11603
|
+
for (const route of routing?.static ?? []) {
|
|
11604
|
+
const table = Utils2.otherTable(route.table);
|
|
11605
|
+
if (table)
|
|
11606
|
+
installed.add(table);
|
|
11607
|
+
}
|
|
11506
11608
|
for (const neighbor of Object.values(bgp?.neighbors ?? {})) {
|
|
11507
|
-
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));
|
|
11508
11610
|
if (table)
|
|
11509
11611
|
installed.add(table);
|
|
11510
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
|
*
|
|
@@ -11,8 +11,8 @@ export declare class BgpWait {
|
|
|
11
11
|
* After an apply, the sessions established before have 180 seconds to be established again, read every 10 seconds. A session reset by a change opens again when one side retries; three minutes leaves room for a slow peer without holding the apply up for long. A session that never went down is seen at the first read, so a healthy apply does not wait.
|
|
12
12
|
*/
|
|
13
13
|
static readonly AFTER_APPLY: WaitLimits;
|
|
14
|
-
/** Read
|
|
15
|
-
static until(read: () => Promise<
|
|
14
|
+
/** Read until `enough` holds or time runs out, and give the last read: the sessions, or what they announce. `waiting` is told of each read that fell short, before the pause. */
|
|
15
|
+
static until<T>(read: () => Promise<T>, enough: (value: T) => boolean, limits: WaitLimits, waiting?: (value: T) => void): Promise<T>;
|
|
16
16
|
/** How many of these sessions are established. */
|
|
17
17
|
static upCount(sessions: readonly BgpSession[]): number;
|
|
18
18
|
/** Sessions closed on their limit within this many minutes of each other are one burst. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Neighbor } from "../core/cabling.js";
|
|
2
|
+
import type { Finding } from "../core/define.js";
|
|
2
3
|
import type { Device, Family } from "../schema/index.js";
|
|
3
4
|
import type { ConsoleDialect } from "../transport/console.js";
|
|
4
5
|
import type { WaitLimits } from "./bgp-wait.js";
|
|
@@ -101,6 +102,8 @@ export declare abstract class Platform {
|
|
|
101
102
|
readonly console: ConsoleDialect | undefined;
|
|
102
103
|
/** Fields this platform cannot express. Validation fails on any of them, so nothing is skipped silently. */
|
|
103
104
|
abstract unsupported(device: Device): string[];
|
|
105
|
+
/** What the platform does with a config that it may not do elsewhere, each as `validate` reports it: an error stops `diff` and `apply`, a warning is reported only. Absent on a platform with none. */
|
|
106
|
+
checks?(device: Device): Finding[];
|
|
104
107
|
/** The switch rules the render makes from the device's hardware ACLs, so `validate` can hold them to the model's limits. Absent on a platform whose rules are not counted. */
|
|
105
108
|
switchRules?(device: Device): SwitchRules;
|
|
106
109
|
/** The whole configuration the device should run. Secrets appear as markers, never values. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Neighbor } from "../../core/cabling.js";
|
|
2
|
+
import type { Finding } from "../../core/define.js";
|
|
2
3
|
import type { Device, Family } from "../../schema/index.js";
|
|
3
4
|
import { type ConsoleDialect } from "../../transport/console.js";
|
|
4
5
|
import { type ApplyOptions, type BgpLogLine, type BgpSession, type HealthCheck, type HealthOptions, type OsSupport, type Plan, type ReadOptions, type ReadSession, type SwitchRules, Platform } from "../platform.js";
|
|
@@ -18,6 +19,8 @@ export declare class RouterOs extends Platform {
|
|
|
18
19
|
*/
|
|
19
20
|
private static readonly LIST_BGP_SESSIONS;
|
|
20
21
|
/** Open again the session from one address, only while it is closed on its limit. */
|
|
22
|
+
/** Each advertisement of these prefixes, one line each: `ADV|<session>|<prefix>`. One line, because a script sent over exec runs as one. */
|
|
23
|
+
private static listAdvertisements;
|
|
21
24
|
private static clearLimit;
|
|
22
25
|
/**
|
|
23
26
|
* Every LLDP neighbor, one line each: our interfaces, the port first and then the LAG and bridge it is in, the far system name, and the far port as LLDP reports it. `:tostr` keeps the interface list one value; joined to a string as it is, a list repeats the whole line for each of its items. One line, because a script sent over exec runs as one.
|
|
@@ -41,6 +44,10 @@ export declare class RouterOs extends Platform {
|
|
|
41
44
|
readonly os: OsSupport;
|
|
42
45
|
readonly extension = ".rsc";
|
|
43
46
|
readonly console: ConsoleDialect;
|
|
47
|
+
/**
|
|
48
|
+
* A BGP session with a `table` announces from that table, not from main: each prefix of `networks` it announces needs a route in the table too, or it is not announced (TRAPS.md). A session left with nothing to announce is an error; a prefix missing from its table is a warning, since its export may not announce it anyway.
|
|
49
|
+
*/
|
|
50
|
+
checks(device: Device): Finding[];
|
|
44
51
|
unsupported(device: Device): string[];
|
|
45
52
|
render(device: Device): string;
|
|
46
53
|
switchRules(device: Device): SwitchRules;
|
|
@@ -63,7 +70,7 @@ export declare class RouterOs extends Platform {
|
|
|
63
70
|
/** A `time>` filter in `/log print` does not filter, so the lines are kept here by comparing each one's time with the device's clock. */
|
|
64
71
|
bgpLog(session: ReadSession, minutes: number): Promise<BgpLogLine[]>;
|
|
65
72
|
/**
|
|
66
|
-
*
|
|
73
|
+
* Three checks after an apply. When the plan touched switch rules, none may be invalid. BGP sessions established before the apply must be established again, and announce again each of the device's `networks` they announced before, within `BgpWait.AFTER_APPLY`. A session can stay up and stop announcing (TRAPS.md), which the session check alone would not see.
|
|
67
74
|
*/
|
|
68
75
|
health(device: Device, plan: Plan, options?: HealthOptions): Promise<HealthCheck>;
|
|
69
76
|
/** The LLDP neighbors the device sees, by Circuit's port names. */
|
|
@@ -80,6 +87,12 @@ export declare class RouterOs extends Platform {
|
|
|
80
87
|
private establishedSessions;
|
|
81
88
|
/** Wait until every one of these neighbors is established again, and give a problem for each that is not when time runs out. */
|
|
82
89
|
private bgpBack;
|
|
90
|
+
/** What each session announces of the device's own `networks`, as `neighbor prefix`. None, without logging in, on a device without networks. */
|
|
91
|
+
private announcedNow;
|
|
92
|
+
/** The `networks` each session announces, as `neighbor prefix`, sorted. An advertisement names its session, which is matched to its neighbor by address first, as in `bgpSessions`. */
|
|
93
|
+
private announced;
|
|
94
|
+
/** Wait until each session announces again what it announced before, and give a problem for each prefix a session no longer announces when time runs out. */
|
|
95
|
+
private announcedBack;
|
|
83
96
|
/** A problem when any switch rule is invalid, saying what to do. */
|
|
84
97
|
private static invalidSwitchRules;
|
|
85
98
|
/** Log in over SSH. `flags` go after the user, as RouterOS reads them: `+ct300w`. */
|
|
@@ -9,6 +9,10 @@ export declare class RouterOsPlan {
|
|
|
9
9
|
private static readonly DYNAMIC;
|
|
10
10
|
/** Menus where an entry that keeps its name but changes what it is known by is changed with a `set`, so what points at it by name, such as an address on a VLAN interface, stays attached. In every other menu it is removed and added again. */
|
|
11
11
|
private static readonly CHANGED_IN_PLACE;
|
|
12
|
+
/**
|
|
13
|
+
* Properties a running entry takes only when it restarts. A BGP session given a new `routing-table` stops announcing at once and keeps its routes in the old table until it restarts (TRAPS.md), so the plan disables the connection, changes it and enables it again: one restart, written in the plan.
|
|
14
|
+
*/
|
|
15
|
+
private static readonly RESTART_ON;
|
|
12
16
|
/** The variable holding the old rules of an ordered menu while the new ones are added. Removed when the rewrite ends. */
|
|
13
17
|
private static readonly OLD_RULES;
|
|
14
18
|
/** What blocks an apply: something this plan cannot converge. */
|
|
@@ -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> & {
|