@takodotid/circuit 0.37.0 → 0.39.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.
@@ -1,4 +1,4 @@
1
- // Waiting for BGP sessions to be established: `circuit bgp --wait`, and the check after an apply.
1
+ // BGP sessions as `circuit bgp` and the check after an apply read them: waiting for them to be established, and spotting limits that closed several at once.
2
2
 
3
3
  import { setTimeout as sleep } from "node:timers/promises";
4
4
  import type { BgpSession } from "./platform";
@@ -38,4 +38,27 @@ export class BgpWait {
38
38
  static upCount(sessions: readonly BgpSession[]): number {
39
39
  return sessions.filter((session) => session.established).length;
40
40
  }
41
+
42
+ /** Sessions closed on their limit within this many minutes of each other are one burst. */
43
+ static readonly BURST_MINUTES = 5;
44
+
45
+ /**
46
+ * Groups of two or more sessions closed on their limit within `BURST_MINUTES` of each other, earliest first. Several limits at once is usually a leak upstream, not growth, so raising them is the wrong fix.
47
+ */
48
+ static limitBursts(sessions: readonly BgpSession[]): BgpSession[][] {
49
+ const closed = sessions
50
+ .filter((session) => session.limit?.stoppedAt !== undefined && !Number.isNaN(session.limit.stoppedAt))
51
+ .sort((a, b) => a.limit!.stoppedAt! - b.limit!.stoppedAt!);
52
+
53
+ const bursts: BgpSession[][] = [];
54
+ for (const session of closed) {
55
+ const last = bursts.at(-1);
56
+ const previous = last?.at(-1);
57
+ if (last && previous && session.limit!.stoppedAt! - previous.limit!.stoppedAt! <= BgpWait.BURST_MINUTES * 60_000)
58
+ last.push(session);
59
+ else bursts.push([session]);
60
+ }
61
+
62
+ return bursts.filter((burst) => burst.length > 1);
63
+ }
41
64
  }
@@ -58,8 +58,15 @@ export type BgpSession = {
58
58
  prefixes: string;
59
59
  /** How long it has been up, as the device prints it, or `no session`. */
60
60
  uptime: string;
61
+ /** Why it is down, when the device says: `limit exceeded`, `admin down`, `connecting`. */
62
+ reason?: string;
63
+ /** It closed because the neighbor sent more prefixes than its limit, and stays closed until cleared. */
64
+ limit?: BgpLimit;
61
65
  };
62
66
 
67
+ /** A session closed on its prefix limit: the limit, and when it closed, as the device prints it and in milliseconds, to compare. */
68
+ export type BgpLimit = { max?: number; stopped?: string; stoppedAt?: number };
69
+
63
70
  /** One BGP log line: when, as the device printed it, such as `2026-10-24 03:12:44`, and the rest of the line. */
64
71
  export type BgpLogLine = { at: string; message: string };
65
72
 
@@ -115,6 +122,8 @@ export abstract class Platform {
115
122
  bgpSessions?(device: Device, session: ReadSession): Promise<BgpSession[]>;
116
123
  /** The device's BGP log lines from the last `minutes`, by the device's own clock, read over a `session()`. */
117
124
  bgpLog?(session: ReadSession, minutes: number): Promise<BgpLogLine[]>;
125
+ /** Open again these sessions, each closed on its prefix limit, read by `bgpSessions`. Only a session still closed on its limit is touched. */
126
+ bgpClearLimit?(session: ReadSession, sessions: readonly BgpSession[]): Promise<void>;
118
127
  /**
119
128
  * Checks that the device is healthy after an apply, not only that it took the commands. Called before the plan is sent, to read what the checks compare against; the CLI runs them after the apply and the snapshot. Absent on a platform with no checks yet.
120
129
  */
@@ -12,6 +12,7 @@ Behaviour that looks like something else. Each one has been hit on a real device
12
12
  - A session edited repeatedly can stick: SYN both ways, no RST, no log. Remove the connection and add it again.
13
13
  - A session that never established is missing from `/routing bgp session print`; look at `/routing bgp connection print`. One that is trying prints `established` empty, not `false`, and no uptime (7.23.5).
14
14
  - A BGP session is named `<connection>-<n>`, and a renamed connection keeps its session's old name until the session restarts. `circuit bgp` matches a session by the neighbor's address first.
15
+ - A session closed on its limit keeps its entry with `limit-exceeded=true`, its `input.limit-process-routes` and `last-stopped`, and the notification it sent ends in `0601` (cease, maximum prefixes). Its `prefix-count` is back to 0, so the count that tripped it is gone. `/routing bgp session clear <id> flag=limit-exceeded` opens it again; a misspelled flag is a `syntax error`, and a `find` that matches nothing does nothing (CCR2216, 7.23.5).
15
16
  - `time>` in a `/log print` filter does not filter. `circuit bgp --log` keeps the lines of the last minutes itself, against the device's clock.
16
17
  - `tcp-md5-key` set from the CLI has been seen discarded without an error, and a mismatch logs nothing. Confirm with `/tool sniffer quick port=179`.
17
18
  - Incoming GRE is subject to the input chain. Accept it before testing the tunnel.
@@ -70,14 +70,24 @@ export class RouterOs extends Platform {
70
70
  "}",
71
71
  ].join(" ");
72
72
 
73
- /** Every BGP session, one line each: name, remote address, established, prefixes received, uptime. One line, because a script sent over exec runs as one. */
73
+ /**
74
+ * Every BGP session, one line each: name, remote address, established, prefixes received, uptime, closed on its limit, the limit, when it last closed. Then each disabled connection, `disabled|<name>|<address>`. One line, because a script sent over exec runs as one.
75
+ */
74
76
  private static readonly LIST_BGP_SESSIONS = [
75
77
  ":foreach s in=[/routing bgp session find] do={",
76
78
  ":local g [/routing bgp session get $s];",
77
- ':put (($g->"name") . "|" . ($g->"remote.address") . "|" . ($g->"established") . "|" . ($g->"prefix-count") . "|" . ($g->"uptime"))',
79
+ ':put (($g->"name") . "|" . ($g->"remote.address") . "|" . ($g->"established") . "|" . ($g->"prefix-count") . "|" . ($g->"uptime") . "|" . ($g->"limit-exceeded") . "|" . ($g->"input.limit-process-routes") . "|" . ($g->"last-stopped"))',
80
+ "};",
81
+ ":foreach c in=[/routing bgp connection find disabled] do={",
82
+ ':put ("disabled|" . [/routing bgp connection get $c name] . "|" . [/routing bgp connection get $c remote.address])',
78
83
  "}",
79
84
  ].join(" ");
80
85
 
86
+ /** Open again the session from one address, only while it is closed on its limit. */
87
+ private static clearLimit(address: string): string {
88
+ return `/routing bgp session clear [find remote.address=${address} and limit-exceeded] flag=limit-exceeded`;
89
+ }
90
+
81
91
  /**
82
92
  * 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.
83
93
  */
@@ -325,32 +335,68 @@ export class RouterOs extends Platform {
325
335
 
326
336
  /**
327
337
  * A session is named `<connection>-<n>`, but a renamed connection keeps its session's old name until the session restarts. So a neighbor is matched by its address first, then by name.
338
+ *
339
+ * A session that is down says why: closed on its limit, or still connecting. A neighbor with no session and a disabled connection is admin down.
328
340
  */
329
341
  async bgpSessions(device: Device, session: ReadSession): Promise<BgpSession[]> {
330
342
  const output = await session.run(RouterOs.LIST_BGP_SESSIONS, 60_000);
331
- const live = output
343
+ const lines = output
332
344
  .split(/\r?\n/)
333
345
  .filter((line) => line.includes("|"))
334
346
  .map((line) => line.trim().split("|"));
347
+ const live = lines.filter(([first]) => first !== "disabled");
348
+ // A connection's address carries its length, `10.0.0.1/32`.
349
+ const disabled = lines
350
+ .filter(([first]) => first === "disabled")
351
+ .map(([, name, address]) => ({ name, address: address?.split("/")[0] }));
335
352
 
336
- return Object.entries(device.routing?.bgp?.neighbors ?? {}).map(([name, neighbor]) => {
353
+ return Object.entries(device.routing?.bgp?.neighbors ?? {}).map(([name, neighbor]): BgpSession => {
337
354
  const found =
338
355
  live.find(([, remote]) => remote === neighbor.address) ??
339
356
  live.find(([sessionName]) => sessionName === name || sessionName?.replace(/-\d+$/, "") === name);
340
357
 
341
- if (!found) return { neighbor: name, remote: "-", established: false, prefixes: "-", uptime: "no session" };
358
+ if (!found) {
359
+ const off = disabled.some((connection) => connection.address === neighbor.address || connection.name === name);
360
+ return {
361
+ neighbor: name,
362
+ remote: "-",
363
+ established: false,
364
+ prefixes: "-",
365
+ uptime: "no session",
366
+ ...(off ? { reason: "admin down" } : {}),
367
+ };
368
+ }
369
+
370
+ const [, remote, established, prefixes, uptime, limitExceeded, max, stopped] = found;
371
+ const up = established === "true";
372
+ const limited = limitExceeded === "true";
342
373
 
343
- const [, remote, established, prefixes, uptime] = found;
344
374
  return {
345
375
  neighbor: name,
346
376
  remote: remote || "-",
347
- established: established === "true",
377
+ established: up,
348
378
  prefixes: prefixes || "-",
349
379
  uptime: uptime || "-",
380
+ ...(up ? {} : { reason: limited ? "limit exceeded" : "connecting" }),
381
+ ...(limited
382
+ ? {
383
+ limit: {
384
+ ...(max && /^\d+$/.test(max) ? { max: Number(max) } : {}),
385
+ ...(stopped ? { stopped, stoppedAt: RouterOs.logTime(stopped) } : {}),
386
+ },
387
+ }
388
+ : {}),
350
389
  };
351
390
  });
352
391
  }
353
392
 
393
+ async bgpClearLimit(session: ReadSession, sessions: readonly BgpSession[]): Promise<void> {
394
+ for (const bgp of sessions) {
395
+ const answer = (await session.run(RouterOs.clearLimit(bgp.remote))).trim();
396
+ if (answer) throw new Error(`could not clear ${bgp.neighbor}: ${answer.slice(0, 200)}`);
397
+ }
398
+ }
399
+
354
400
  /** 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. */
355
401
  async bgpLog(session: ReadSession, minutes: number): Promise<BgpLogLine[]> {
356
402
  const now = (await session.run(RouterOs.CLOCK)).trim();
@@ -501,9 +547,10 @@ export class RouterOs extends Platform {
501
547
  return [];
502
548
  }
503
549
 
504
- return down.map(
505
- (bgp) =>
506
- `BGP session ${bgp.neighbor} was established before the apply and is not ${limits.seconds} s after it (${bgp.uptime === "no session" ? "no session" : "not established"}). Look with: circuit bgp ${device.name} --log`
550
+ return down.map((bgp) =>
551
+ bgp.limit
552
+ ? `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} ${bgp.neighbor} --clear-limit`
553
+ : `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`
507
554
  );
508
555
  }
509
556
 
@@ -113,7 +113,7 @@ export type Port<V extends string, A extends string, L extends string, F extends
113
113
  acl?: A;
114
114
  /** The LAG this port is in. Every other setting goes on the LAG. */
115
115
  lag?: L;
116
- /** Another of our devices at the other end of the cable. */
116
+ /** Another of our devices at the other end of the cable. Without a `description`, the port is described from it: the far device and the far port as printed on it, such as `firewall-1 port6`. */
117
117
  link?: Link;
118
118
  };
119
119