@takodotid/circuit 0.42.0 → 0.42.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +105 -8
- package/dist/types/helpers/upstreams.d.ts +1 -1
- 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 +1 -1
- package/package.json +1 -1
- package/src/core/validator.ts +4 -1
- package/src/helpers/upstreams.ts +1 -1
- package/src/platforms/bgp-wait.ts +11 -11
- package/src/platforms/platform.ts +3 -0
- package/src/platforms/routeros/TRAPS.md +3 -1
- package/src/platforms/routeros/index.ts +122 -2
- package/src/platforms/routeros/plan.ts +17 -1
- package/src/schema/routing.ts +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -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;
|
|
@@ -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,42 @@ 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 = 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 missing = networks.filter((prefix) => !inTable(prefix));
|
|
8742
|
+
if (!missing.length)
|
|
8743
|
+
continue;
|
|
8744
|
+
const where = `routing.bgp.neighbors.${name}`;
|
|
8745
|
+
const route = (prefix) => `{ prefix: "${prefix}", blackhole: true, table: "${table}" }`;
|
|
8746
|
+
const fix = `On RouterOS a session announces from its own table, ${table}`;
|
|
8747
|
+
if (missing.length === networks.length) {
|
|
8748
|
+
findings.push({
|
|
8749
|
+
level: "error",
|
|
8750
|
+
device: device.name,
|
|
8751
|
+
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`
|
|
8752
|
+
});
|
|
8753
|
+
continue;
|
|
8754
|
+
}
|
|
8755
|
+
for (const prefix of missing) {
|
|
8756
|
+
findings.push({
|
|
8757
|
+
level: "warning",
|
|
8758
|
+
device: device.name,
|
|
8759
|
+
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`
|
|
8760
|
+
});
|
|
8761
|
+
}
|
|
8762
|
+
}
|
|
8763
|
+
return findings;
|
|
8764
|
+
}
|
|
8716
8765
|
unsupported(device) {
|
|
8717
8766
|
const found = [];
|
|
8718
8767
|
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
@@ -8950,6 +8999,7 @@ ${certificates}` : "";
|
|
|
8950
8999
|
const switchRules = plan.steps.some((step) => step.send.some((command) => command.startsWith(RouterOs.SWITCH_RULES)));
|
|
8951
9000
|
const limits = options.bgp ?? BgpWait.AFTER_APPLY;
|
|
8952
9001
|
const establishedBefore = await this.establishedSessions(device, options);
|
|
9002
|
+
const announcedBefore = establishedBefore.length ? await this.announcedNow(device, options) : [];
|
|
8953
9003
|
return {
|
|
8954
9004
|
run: async () => {
|
|
8955
9005
|
if (!switchRules && !establishedBefore.length) {
|
|
@@ -8960,7 +9010,8 @@ ${certificates}` : "";
|
|
|
8960
9010
|
try {
|
|
8961
9011
|
const switchProblems = switchRules ? await RouterOs.invalidSwitchRules(session, device) : [];
|
|
8962
9012
|
const bgpProblems = await this.bgpBack(device, session, establishedBefore, limits);
|
|
8963
|
-
|
|
9013
|
+
const announceProblems = await this.announcedBack(device, session, announcedBefore, limits);
|
|
9014
|
+
return [...switchProblems, ...bgpProblems, ...announceProblems];
|
|
8964
9015
|
} finally {
|
|
8965
9016
|
session.close();
|
|
8966
9017
|
}
|
|
@@ -9030,6 +9081,50 @@ ${certificates}` : "";
|
|
|
9030
9081
|
}
|
|
9031
9082
|
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
9083
|
}
|
|
9084
|
+
async announcedNow(device, options) {
|
|
9085
|
+
if (!device.routing?.bgp?.networks?.length)
|
|
9086
|
+
return [];
|
|
9087
|
+
const session = await this.session(device, options);
|
|
9088
|
+
try {
|
|
9089
|
+
return await this.announced(device, session);
|
|
9090
|
+
} finally {
|
|
9091
|
+
session.close();
|
|
9092
|
+
}
|
|
9093
|
+
}
|
|
9094
|
+
async announced(device, session) {
|
|
9095
|
+
const bgp = device.routing?.bgp;
|
|
9096
|
+
const networks = bgp?.networks ?? [];
|
|
9097
|
+
if (!networks.length)
|
|
9098
|
+
return [];
|
|
9099
|
+
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");
|
|
9100
|
+
const neighbors = Object.entries(bgp?.neighbors ?? {});
|
|
9101
|
+
const neighborOf = (sessionName) => {
|
|
9102
|
+
const remote = sessions.find(([name]) => name === sessionName)?.[1];
|
|
9103
|
+
const byAddress = neighbors.find(([, neighbor]) => neighbor.address === remote);
|
|
9104
|
+
const byName = neighbors.find(([name]) => name === sessionName.replace(/-\d+$/, ""));
|
|
9105
|
+
return (byAddress ?? byName)?.[0];
|
|
9106
|
+
};
|
|
9107
|
+
const output = await session.run(RouterOs.listAdvertisements(networks), 60000);
|
|
9108
|
+
return output.split(/\r?\n/).map((line) => line.trim().split("|")).flatMap(([marker, peer, prefix]) => {
|
|
9109
|
+
const neighbor = marker === "ADV" && peer ? neighborOf(peer) : undefined;
|
|
9110
|
+
return neighbor && prefix ? [`${neighbor} ${prefix}`] : [];
|
|
9111
|
+
}).sort();
|
|
9112
|
+
}
|
|
9113
|
+
async announcedBack(device, session, before, limits) {
|
|
9114
|
+
if (!before.length)
|
|
9115
|
+
return [];
|
|
9116
|
+
const missing = (now) => before.filter((pair) => !now.includes(pair));
|
|
9117
|
+
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`));
|
|
9118
|
+
const gone = missing(after);
|
|
9119
|
+
if (!gone.length) {
|
|
9120
|
+
console.log(` BGP: every session announces what it announced before`);
|
|
9121
|
+
return [];
|
|
9122
|
+
}
|
|
9123
|
+
return gone.map((pair) => {
|
|
9124
|
+
const [neighbor, prefix] = pair.split(" ");
|
|
9125
|
+
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'`;
|
|
9126
|
+
});
|
|
9127
|
+
}
|
|
9033
9128
|
static async invalidSwitchRules(session, device) {
|
|
9034
9129
|
const answer = (await session.run(RouterOs.COUNT_INVALID_SWITCH_RULES)).trim();
|
|
9035
9130
|
if (!/^\d+$/.test(answer))
|
|
@@ -11245,9 +11340,11 @@ class Validator {
|
|
|
11245
11340
|
const adapter = Platforms.find(device.platform);
|
|
11246
11341
|
if (!adapter)
|
|
11247
11342
|
report(`no adapter for platform ${device.platform}`);
|
|
11248
|
-
else
|
|
11343
|
+
else {
|
|
11249
11344
|
for (const field of adapter.unsupported(device))
|
|
11250
11345
|
report(`${device.platform} cannot express ${field}`);
|
|
11346
|
+
findings.push(...adapter.checks?.(device) ?? []);
|
|
11347
|
+
}
|
|
11251
11348
|
}
|
|
11252
11349
|
findings.push(...CrossChecks.run(network.devices));
|
|
11253
11350
|
for (const check of network.checks ?? [])
|
|
@@ -67,7 +67,7 @@ export type Upstream = {
|
|
|
67
67
|
families?: readonly Family[];
|
|
68
68
|
password?: Secret;
|
|
69
69
|
};
|
|
70
|
-
/** The routing table its sessions install the routes they learn in, one of the router's `routing.tables`: a transit that carries only one block's traffic out. The main table when absent. */
|
|
70
|
+
/** The routing table its sessions install the routes they learn in, one of the router's `routing.tables`: a transit that carries only one block's traffic out. The main table when absent. On RouterOS the sessions also announce from that table, so what they announce needs a route there too. */
|
|
71
71
|
table?: string;
|
|
72
72
|
/** 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
73
|
limits?: {
|
|
@@ -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. */
|
|
@@ -54,7 +54,7 @@ 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, one of `routing.tables`. The main table when absent.
|
|
57
|
+
/** The table the routes learned are installed in, one of `routing.tables`. The main table when absent. 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
58
|
table?: T;
|
|
59
59
|
};
|
|
60
60
|
/** One BGP session. Settings it does not set come from its group. */
|
package/package.json
CHANGED
package/src/core/validator.ts
CHANGED
|
@@ -63,7 +63,10 @@ export class Validator {
|
|
|
63
63
|
|
|
64
64
|
const adapter = Platforms.find(device.platform);
|
|
65
65
|
if (!adapter) report(`no adapter for platform ${device.platform}`);
|
|
66
|
-
else
|
|
66
|
+
else {
|
|
67
|
+
for (const field of adapter.unsupported(device)) report(`${device.platform} cannot express ${field}`);
|
|
68
|
+
findings.push(...(adapter.checks?.(device) ?? []));
|
|
69
|
+
}
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
findings.push(...CrossChecks.run(network.devices));
|
package/src/helpers/upstreams.ts
CHANGED
|
@@ -67,7 +67,7 @@ export type Upstream = {
|
|
|
67
67
|
group?: string;
|
|
68
68
|
/** The sessions are more than one hop away, from this address of ours. */
|
|
69
69
|
multihop?: { local_address: IP; families?: readonly Family[]; password?: Secret };
|
|
70
|
-
/** The routing table its sessions install the routes they learn in, one of the router's `routing.tables`: a transit that carries only one block's traffic out. The main table when absent. */
|
|
70
|
+
/** The routing table its sessions install the routes they learn in, one of the router's `routing.tables`: a transit that carries only one block's traffic out. The main table when absent. On RouterOS the sessions also announce from that table, so what they announce needs a route there too. */
|
|
71
71
|
table?: string;
|
|
72
72
|
/** 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
73
|
limits?: { ipv4?: number; ipv6?: number };
|
|
@@ -15,23 +15,23 @@ export class BgpWait {
|
|
|
15
15
|
*/
|
|
16
16
|
static readonly AFTER_APPLY: WaitLimits = { seconds: 180, every: 10 };
|
|
17
17
|
|
|
18
|
-
/** Read
|
|
19
|
-
static async until(
|
|
20
|
-
read: () => Promise<
|
|
21
|
-
enough: (
|
|
18
|
+
/** 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. */
|
|
19
|
+
static async until<T>(
|
|
20
|
+
read: () => Promise<T>,
|
|
21
|
+
enough: (value: T) => boolean,
|
|
22
22
|
limits: WaitLimits,
|
|
23
|
-
waiting: (
|
|
24
|
-
): Promise<
|
|
23
|
+
waiting: (value: T) => void = () => {}
|
|
24
|
+
): Promise<T> {
|
|
25
25
|
const deadline = Date.now() + limits.seconds * 1000;
|
|
26
|
-
let
|
|
26
|
+
let value = await read();
|
|
27
27
|
|
|
28
|
-
while (!enough(
|
|
29
|
-
waiting(
|
|
28
|
+
while (!enough(value) && Date.now() < deadline) {
|
|
29
|
+
waiting(value);
|
|
30
30
|
await sleep(Math.min(limits.every * 1000, Math.max(0, deadline - Date.now())));
|
|
31
|
-
|
|
31
|
+
value = await read();
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
return
|
|
34
|
+
return value;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/** How many of these sessions are established. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Neighbor } from "../core/cabling";
|
|
2
|
+
import type { Finding } from "../core/define";
|
|
2
3
|
import type { Device, Family } from "../schema";
|
|
3
4
|
import type { ConsoleDialect } from "../transport/console";
|
|
4
5
|
import type { WaitLimits } from "./bgp-wait";
|
|
@@ -100,6 +101,8 @@ export abstract class Platform {
|
|
|
100
101
|
readonly console: ConsoleDialect | undefined = undefined;
|
|
101
102
|
/** Fields this platform cannot express. Validation fails on any of them, so nothing is skipped silently. */
|
|
102
103
|
abstract unsupported(device: Device): string[];
|
|
104
|
+
/** 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. */
|
|
105
|
+
checks?(device: Device): Finding[];
|
|
103
106
|
/** 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. */
|
|
104
107
|
switchRules?(device: Device): SwitchRules;
|
|
105
108
|
/** The whole configuration the device should run. Secrets appear as markers, never values. */
|
|
@@ -64,6 +64,8 @@ Behaviour that looks like something else. Each one has been hit on a real device
|
|
|
64
64
|
- No MAC learning limit on 7.23.5: `/interface bridge port` has `learn` but no limit, and `/interface ethernet switch port` has none (CCR2216). `mac_limit` stays refused.
|
|
65
65
|
- A switch rule matches a kind of frame with `mac-protocol` (`ip`, `ipv6`, `arp` among 23 kinds), but cannot match "not" one. An ethertype filter is a rule that lets each kind through and a last one with an empty `new-dst-ports` for the rest, after the port's ACL rules, which match only `ip` or `ipv6`. Four such rules on a free port took the router to 70 switch rules, none invalid (CCR2216, 7.23.5).
|
|
66
66
|
- A routing table holds no routes the router forwards by unless it has `fib`: `/routing table add fib name=...`. The export prints the flag between the properties, `add comment=... fib name=...`.
|
|
67
|
-
- A BGP connection's `routing-table` installs what it learns
|
|
67
|
+
- A BGP connection's `routing-table` is where the session installs what it learns and also where it announces from: an `output.network` prefix is announced only while that table has a route for it, and a route in main alone is not enough. On a CCR2216 on 7.23.5, a fresh session with `routing-table=<table>` installed its routes there, active, with the next hop resolved through main, and announced nothing until the table had the prefix too; then it did. Circuit's validate refuses a session in a table that holds none of `networks`, and warns for each it lacks. Routing filters cannot move a route to a table: `set routing-table` and `set table` are `invalid argument`.
|
|
68
|
+
- A running session takes a new `routing-table` only when it restarts, but its announcements stop at once: set from main to a table, it withdrew everything within seconds, kept its learned routes in main, and announced nothing even once the table had the prefix; set back to main, it still announced nothing until it restarted. `/routing bgp session resend afi=ipv6` did not help either, on the router where this was found (#157). Disabling and enabling the connection restarted it in the new table. The plan does exactly that, written on the plan's line, and after an apply Circuit checks that every session still announces what it announced of `networks` before (CCR2216, 7.23.5).
|
|
69
|
+
- A passive connection without `local.address` listens only on its default local address, `local.default-address`, which may be a public one: a peer connecting to another address of the router gets a TCP reset (CCR2216, 7.23.5).
|
|
68
70
|
- `/routing rule` is an ordered list without names, like a filter chain, and the plan rewrites it whole in the config's order. `lookup-only-in-table` drops a packet its table has no route for (a ping printed `no route`); `lookup` routes it by main instead (the ping left). An interface to match is `interface=`. A `drop` or `unreachable` rule is printed with `!realm`, which Circuit does not write (CCR2216, 7.23.5).
|
|
69
71
|
- An eBGP connection to an address on no connected network, without `multihop=yes`, takes the TCP connection, logs `connection <local> from <remote>`, and closes it with no OPEN and nothing else in the log, even with `bgp,debug` logging on (CCR2216, 7.23.5).
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import type { Client } from "ssh2";
|
|
6
6
|
import { Address } from "../../core/address";
|
|
7
7
|
import type { Neighbor } from "../../core/cabling";
|
|
8
|
+
import type { Finding } from "../../core/define";
|
|
8
9
|
import { Secrets } from "../../core/secrets";
|
|
9
10
|
import type { Device, Family } from "../../schema";
|
|
10
11
|
import { SerialConsole, type ConsoleDialect, ConsoleShell } from "../../transport/console";
|
|
@@ -84,6 +85,12 @@ export class RouterOs extends Platform {
|
|
|
84
85
|
].join(" ");
|
|
85
86
|
|
|
86
87
|
/** Open again the session from one address, only while it is closed on its limit. */
|
|
88
|
+
/** Each advertisement of these prefixes, one line each: `ADV|<session>|<prefix>`. One line, because a script sent over exec runs as one. */
|
|
89
|
+
private static listAdvertisements(prefixes: readonly string[]): string {
|
|
90
|
+
const where = prefixes.map((prefix) => `dst=${prefix}`).join(" or ");
|
|
91
|
+
return `:foreach a in=[/routing bgp advertisements print as-value where ${where}] do={ :put ("ADV|" . ($a->"peer") . "|" . ($a->"dst")) }`;
|
|
92
|
+
}
|
|
93
|
+
|
|
87
94
|
private static clearLimit(address: string): string {
|
|
88
95
|
return `/routing bgp session clear [find remote.address=${address} and limit-exceeded] flag=limit-exceeded`;
|
|
89
96
|
}
|
|
@@ -135,6 +142,51 @@ export class RouterOs extends Platform {
|
|
|
135
142
|
|
|
136
143
|
override readonly console = RouterOs.CONSOLE;
|
|
137
144
|
|
|
145
|
+
/**
|
|
146
|
+
* 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.
|
|
147
|
+
*/
|
|
148
|
+
checks(device: Device): Finding[] {
|
|
149
|
+
const bgp = device.routing?.bgp;
|
|
150
|
+
const statics = device.routing?.static ?? [];
|
|
151
|
+
const findings: Finding[] = [];
|
|
152
|
+
|
|
153
|
+
for (const [name, neighbor] of Object.entries(bgp?.neighbors ?? {})) {
|
|
154
|
+
const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
|
|
155
|
+
const table = neighbor.table ?? group?.table;
|
|
156
|
+
if (!table) continue;
|
|
157
|
+
|
|
158
|
+
const families = neighbor.families ?? group?.families ?? [Address.family(neighbor.address)];
|
|
159
|
+
const networks = (bgp?.networks ?? []).filter((prefix) => families.includes(Address.family(Address.host(prefix))));
|
|
160
|
+
const inTable = (prefix: string) =>
|
|
161
|
+
statics.some((route) => route.table === table && Address.canonical(route.prefix) === Address.canonical(prefix));
|
|
162
|
+
const missing = networks.filter((prefix) => !inTable(prefix));
|
|
163
|
+
if (!missing.length) continue;
|
|
164
|
+
|
|
165
|
+
const where = `routing.bgp.neighbors.${name}`;
|
|
166
|
+
const route = (prefix: string) => `{ prefix: "${prefix}", blackhole: true, table: "${table}" }`;
|
|
167
|
+
const fix = `On RouterOS a session announces from its own table, ${table}`;
|
|
168
|
+
|
|
169
|
+
if (missing.length === networks.length) {
|
|
170
|
+
findings.push({
|
|
171
|
+
level: "error",
|
|
172
|
+
device: device.name,
|
|
173
|
+
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`,
|
|
174
|
+
});
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const prefix of missing) {
|
|
179
|
+
findings.push({
|
|
180
|
+
level: "warning",
|
|
181
|
+
device: device.name,
|
|
182
|
+
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`,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return findings;
|
|
188
|
+
}
|
|
189
|
+
|
|
138
190
|
unsupported(device: Device): string[] {
|
|
139
191
|
const found: string[] = [];
|
|
140
192
|
|
|
@@ -427,13 +479,14 @@ export class RouterOs extends Platform {
|
|
|
427
479
|
}
|
|
428
480
|
|
|
429
481
|
/**
|
|
430
|
-
*
|
|
482
|
+
* 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.
|
|
431
483
|
*/
|
|
432
484
|
async health(device: Device, plan: Plan, options: HealthOptions = {}): Promise<HealthCheck> {
|
|
433
485
|
const switchRules = plan.steps.some((step) => step.send.some((command) => command.startsWith(RouterOs.SWITCH_RULES)));
|
|
434
486
|
const limits = options.bgp ?? BgpWait.AFTER_APPLY;
|
|
435
487
|
|
|
436
488
|
const establishedBefore = await this.establishedSessions(device, options);
|
|
489
|
+
const announcedBefore = establishedBefore.length ? await this.announcedNow(device, options) : [];
|
|
437
490
|
|
|
438
491
|
return {
|
|
439
492
|
run: async () => {
|
|
@@ -447,7 +500,8 @@ export class RouterOs extends Platform {
|
|
|
447
500
|
try {
|
|
448
501
|
const switchProblems = switchRules ? await RouterOs.invalidSwitchRules(session, device) : [];
|
|
449
502
|
const bgpProblems = await this.bgpBack(device, session, establishedBefore, limits);
|
|
450
|
-
|
|
503
|
+
const announceProblems = await this.announcedBack(device, session, announcedBefore, limits);
|
|
504
|
+
return [...switchProblems, ...bgpProblems, ...announceProblems];
|
|
451
505
|
} finally {
|
|
452
506
|
session.close();
|
|
453
507
|
}
|
|
@@ -563,6 +617,72 @@ export class RouterOs extends Platform {
|
|
|
563
617
|
);
|
|
564
618
|
}
|
|
565
619
|
|
|
620
|
+
/** What each session announces of the device's own `networks`, as `neighbor prefix`. None, without logging in, on a device without networks. */
|
|
621
|
+
private async announcedNow(device: Device, options: ReadOptions): Promise<string[]> {
|
|
622
|
+
if (!device.routing?.bgp?.networks?.length) return [];
|
|
623
|
+
|
|
624
|
+
const session = await this.session(device, options);
|
|
625
|
+
|
|
626
|
+
try {
|
|
627
|
+
return await this.announced(device, session);
|
|
628
|
+
} finally {
|
|
629
|
+
session.close();
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** 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`. */
|
|
634
|
+
private async announced(device: Device, session: ReadSession): Promise<string[]> {
|
|
635
|
+
const bgp = device.routing?.bgp;
|
|
636
|
+
const networks = bgp?.networks ?? [];
|
|
637
|
+
if (!networks.length) return [];
|
|
638
|
+
|
|
639
|
+
const sessions = (await session.run(RouterOs.LIST_BGP_SESSIONS, 60_000))
|
|
640
|
+
.split(/\r?\n/)
|
|
641
|
+
.map((line) => line.trim().split("|"))
|
|
642
|
+
.filter((fields) => fields.length > 1 && fields[0] !== "disabled");
|
|
643
|
+
const neighbors = Object.entries(bgp?.neighbors ?? {});
|
|
644
|
+
const neighborOf = (sessionName: string) => {
|
|
645
|
+
const remote = sessions.find(([name]) => name === sessionName)?.[1];
|
|
646
|
+
const byAddress = neighbors.find(([, neighbor]) => neighbor.address === remote);
|
|
647
|
+
const byName = neighbors.find(([name]) => name === sessionName.replace(/-\d+$/, ""));
|
|
648
|
+
return (byAddress ?? byName)?.[0];
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
const output = await session.run(RouterOs.listAdvertisements(networks), 60_000);
|
|
652
|
+
return output
|
|
653
|
+
.split(/\r?\n/)
|
|
654
|
+
.map((line) => line.trim().split("|"))
|
|
655
|
+
.flatMap(([marker, peer, prefix]) => {
|
|
656
|
+
const neighbor = marker === "ADV" && peer ? neighborOf(peer) : undefined;
|
|
657
|
+
return neighbor && prefix ? [`${neighbor} ${prefix}`] : [];
|
|
658
|
+
})
|
|
659
|
+
.sort();
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** 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. */
|
|
663
|
+
private async announcedBack(device: Device, session: ReadSession, before: readonly string[], limits: WaitLimits): Promise<string[]> {
|
|
664
|
+
if (!before.length) return [];
|
|
665
|
+
|
|
666
|
+
const missing = (now: string[]) => before.filter((pair) => !now.includes(pair));
|
|
667
|
+
const after = await BgpWait.until(
|
|
668
|
+
() => this.announced(device, session),
|
|
669
|
+
(now) => !missing(now).length,
|
|
670
|
+
limits,
|
|
671
|
+
(now) => console.log(` BGP: ${missing(now).length} announcement(s) not back yet, checking again in ${limits.every} s`)
|
|
672
|
+
);
|
|
673
|
+
|
|
674
|
+
const gone = missing(after);
|
|
675
|
+
if (!gone.length) {
|
|
676
|
+
console.log(` BGP: every session announces what it announced before`);
|
|
677
|
+
return [];
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
return gone.map((pair) => {
|
|
681
|
+
const [neighbor, prefix] = pair.split(" ");
|
|
682
|
+
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'`;
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
|
|
566
686
|
/** A problem when any switch rule is invalid, saying what to do. */
|
|
567
687
|
private static async invalidSwitchRules(session: ReadSession, device: Device): Promise<string[]> {
|
|
568
688
|
const answer = (await session.run(RouterOs.COUNT_INVALID_SWITCH_RULES)).trim();
|
|
@@ -83,6 +83,11 @@ export class RouterOsPlan {
|
|
|
83
83
|
/** 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. */
|
|
84
84
|
private static readonly CHANGED_IN_PLACE = new Set(["/interface vlan"]);
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* 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.
|
|
88
|
+
*/
|
|
89
|
+
private static readonly RESTART_ON: Record<string, string[]> = { "/routing bgp connection": ["routing-table"] };
|
|
90
|
+
|
|
86
91
|
/** The variable holding the old rules of an ordered menu while the new ones are added. Removed when the rewrite ends. */
|
|
87
92
|
private static readonly OLD_RULES = "frameworkOldRules";
|
|
88
93
|
|
|
@@ -314,8 +319,19 @@ export class RouterOsPlan {
|
|
|
314
319
|
const update = this.changes(entry, current, `${menu} ${entryKey}`);
|
|
315
320
|
if (!Object.keys(update).length) continue;
|
|
316
321
|
|
|
322
|
+
const selector = RouterOsPlan.find(menu, RouterOsExport.identity(current));
|
|
323
|
+
const restartsOn = (RouterOsPlan.RESTART_ON[menu] ?? []).filter((property) => property in update);
|
|
324
|
+
if (restartsOn.length) {
|
|
325
|
+
changeStep.show.push(
|
|
326
|
+
`~ ${entryKey} ${RouterOsPlan.describe(update)}, restarting the session: it takes a new ${restartsOn.join(" and ")} only when it restarts`
|
|
327
|
+
);
|
|
328
|
+
changeStep.send.push(RouterOsPlan.line(menu, "set", selector, [], { disabled: "yes", ...update }));
|
|
329
|
+
changeStep.send.push(RouterOsPlan.line(menu, "set", selector, [], { disabled: "no" }));
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
|
|
317
333
|
changeStep.show.push(`~ ${entryKey} ${RouterOsPlan.describe(update)}`);
|
|
318
|
-
changeStep.send.push(RouterOsPlan.line(menu, "set",
|
|
334
|
+
changeStep.send.push(RouterOsPlan.line(menu, "set", selector, [], update));
|
|
319
335
|
}
|
|
320
336
|
|
|
321
337
|
const removed = existingEntries.filter((entry) => !kept.has(key(entry)));
|
package/src/schema/routing.ts
CHANGED
|
@@ -57,7 +57,7 @@ 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, one of `routing.tables`. The main table when absent.
|
|
60
|
+
/** The table the routes learned are installed in, one of `routing.tables`. The main table when absent. 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
61
|
table?: T;
|
|
62
62
|
};
|
|
63
63
|
|