@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 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 sessions = await read();
6826
- while (!enough(sessions) && Date.now() < deadline) {
6827
- waiting(sessions);
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
- sessions = await read();
6829
+ value = await read();
6830
6830
  }
6831
- return sessions;
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", RouterOsPlan.find(menu, RouterOsExport.identity(current)), [], update));
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
- return [...switchProblems, ...bgpProblems];
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 the sessions until `enough` holds or time runs out, and give the last read. `waiting` is told of each read that fell short, before the pause. */
15
- static until(read: () => Promise<BgpSession[]>, enough: (sessions: BgpSession[]) => boolean, limits: WaitLimits, waiting?: (sessions: BgpSession[]) => void): Promise<BgpSession[]>;
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
- * Two checks after an apply. When the plan touched switch rules, none may be invalid. BGP sessions established before the apply must be established again, within `BgpWait.AFTER_APPLY`.
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. What is announced still comes from `networks` and the main table. */
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takodotid/circuit",
3
- "version": "0.42.0",
3
+ "version": "0.42.1",
4
4
  "description": "Declarative, vendor-neutral network configuration: describe each device in TypeScript, and Circuit makes the device match. Alpha.",
5
5
  "keywords": [
6
6
  "network",
@@ -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 for (const field of adapter.unsupported(device)) report(`${device.platform} cannot express ${field}`);
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));
@@ -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 the sessions until `enough` holds or time runs out, and give the last read. `waiting` is told of each read that fell short, before the pause. */
19
- static async until(
20
- read: () => Promise<BgpSession[]>,
21
- enough: (sessions: BgpSession[]) => boolean,
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: (sessions: BgpSession[]) => void = () => {}
24
- ): Promise<BgpSession[]> {
23
+ waiting: (value: T) => void = () => {}
24
+ ): Promise<T> {
25
25
  const deadline = Date.now() + limits.seconds * 1000;
26
- let sessions = await read();
26
+ let value = await read();
27
27
 
28
- while (!enough(sessions) && Date.now() < deadline) {
29
- waiting(sessions);
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
- sessions = await read();
31
+ value = await read();
32
32
  }
33
33
 
34
- return sessions;
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 in that table and not in main; the next hop still resolves through main. Proved on a CCR2216 on 7.23.5 with a session to a test speaker: its routes showed `routing-table=<table>`, active, and main kept only its own static route. The export prints `routing-table` only when it is not `main`.
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
- * Two checks after an apply. When the plan touched switch rules, none may be invalid. BGP sessions established before the apply must be established again, within `BgpWait.AFTER_APPLY`.
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
- return [...switchProblems, ...bgpProblems];
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", RouterOsPlan.find(menu, RouterOsExport.identity(current)), [], update));
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)));
@@ -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. What is announced still comes from `networks` and the main table. */
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