@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.
@@ -0,0 +1,66 @@
1
+ /** The far end of a port, for a description of your own: the device and its port by Circuit's name. */
2
+ export type FarEnd = {
3
+ device: string;
4
+ port: string;
5
+ };
6
+ /** What every cable of one entry shares. */
7
+ type Shared = {
8
+ /** What is between the two ends, such as a carrier's local loop. Both ends get it on their `link`. */
9
+ through?: string;
10
+ /** A port's description from its far end. When absent, Circuit writes the far device and the far port as printed on it, such as `firewall-1 port6`, from the far device's own file. */
11
+ describe?: (far: FarEnd) => string;
12
+ };
13
+ /** A port a cable builds: the other end, the LAG it is in, and a description when `describe` gives one. */
14
+ type CablePort<L extends string> = {
15
+ link: {
16
+ device: string;
17
+ port: string;
18
+ through?: string;
19
+ };
20
+ lag?: L;
21
+ description?: string;
22
+ };
23
+ /** Ports for both ends of the cables between two of our devices, each cable written once. */
24
+ export declare class Cables {
25
+ /**
26
+ * One cable, both ends. Spread each end into its device's `ports`:
27
+ *
28
+ * ```ts
29
+ * const uplink = Cables.link({ a: { device: "router-1", port: "25g-1" }, b: { device: "switch-1", port: "10g-48" } });
30
+ * // router-1: ports: { ...uplink.a }
31
+ * ```
32
+ */
33
+ static link<const NA extends string, const NB extends string>(spec: Shared & {
34
+ a: {
35
+ device: string;
36
+ port: NA;
37
+ };
38
+ b: {
39
+ device: string;
40
+ port: NB;
41
+ };
42
+ }): {
43
+ a: Record<NA, CablePort<never>>;
44
+ b: Record<NB, CablePort<never>>;
45
+ };
46
+ /**
47
+ * The cables of a LAG, both ends, from one list read from end A: `{ "25g-1": "10g-2" }` is a cable from A's 25g-1 to B's 10g-2. Each port is a member of its end's LAG. Spread each end into its device's `ports`; the LAG itself stays on each device.
48
+ */
49
+ static lag<const LA extends string, const LB extends string, const C extends Readonly<Record<string, string>>>(spec: Shared & {
50
+ a: {
51
+ device: string;
52
+ lag: LA;
53
+ };
54
+ b: {
55
+ device: string;
56
+ lag: LB;
57
+ };
58
+ cables: C;
59
+ }): {
60
+ a: Record<keyof C & string, CablePort<LA>>;
61
+ b: Record<C[keyof C], CablePort<LB>>;
62
+ };
63
+ /** A port facing `port` on the far device. */
64
+ private static end;
65
+ }
66
+ export {};
@@ -1,10 +1,14 @@
1
1
  export { Bgp } from "./bgp.js";
2
- export type { CommunityScheme, SanityLengths } from "./bgp.js";
2
+ export type { Announced, CommunityScheme, DefaultOnlyOptions, LearnOptions, SanityLengths } from "./bgp.js";
3
+ export { Cables } from "./cables.js";
4
+ export type { FarEnd } from "./cables.js";
3
5
  export { Checks } from "./checks.js";
4
6
  export type { TrustBoundary } from "./checks.js";
5
7
  export { Exchange } from "./exchange.js";
6
8
  export type { ExchangeMember, InternetExchange } from "./exchange.js";
7
9
  export { Filters } from "./filters.js";
8
10
  export { RouteServerConfig } from "./route-server.js";
11
+ export { Upstreams } from "./upstreams.js";
12
+ export type { Upstream, UpstreamCatalog, UpstreamPolicies } from "./upstreams.js";
9
13
  export type { RouteServer } from "./route-server.js";
10
14
  export type { AntiAmplificationOptions } from "./filters.js";
@@ -0,0 +1,239 @@
1
+ import type { Exchange as ExchangeLan, Family, FilterRule, Gre, InterfaceAddress, IP, LargeCommunity, Neighbor, NeighborSettings, PolicyRule, Port, Secret, StandardCommunity, Vlan, VlanInterface } from "../schema/index.js";
2
+ import { type Announced } from "./bgp.js";
3
+ /** One network we exchange routes with: a transit we buy, an exchange's route servers, or a peer. */
4
+ export type Upstream = {
5
+ /** Its name as people write it, for descriptions: `Transit A`. The key when absent. */
6
+ name?: string;
7
+ asn: number;
8
+ /** What it is to us: `provider` for a transit, `rs` for an exchange's route servers, `peer` for a direct session. */
9
+ role: "provider" | "rs" | "peer";
10
+ /** Where it lands on our side: a VLAN, our addresses on it, and the switch port it arrives on. A peer over a tunnel, or over an exchange's VLAN, has none. */
11
+ handoff?: {
12
+ /** The VLAN's ID. */
13
+ vlan: number;
14
+ /** It sends its VLAN tagged: its port gets a native VLAN of its own with this ID, which nothing else is in. Untagged when absent. */
15
+ native?: number;
16
+ /** Its number in names with `{number}`, such as `core-ipt01-transita`. Written here, so removing another upstream renumbers nothing. */
17
+ number?: number;
18
+ /** Our addresses on it. `{ address, peer }` for a point-to-point address whose far end is outside its own prefix. */
19
+ addresses: readonly (InterfaceAddress | {
20
+ address: InterfaceAddress;
21
+ peer: IP;
22
+ })[];
23
+ /** The switch port it arrives on. */
24
+ port?: {
25
+ device: string;
26
+ port: string;
27
+ storm_control?: Port<string, string, string, string>["storm_control"];
28
+ };
29
+ /** An exchange's LAN, for `circuit peeringdb`. */
30
+ exchange?: ExchangeLan;
31
+ };
32
+ /** A GRE tunnel to it. `number` is its number in names with `{number}`. */
33
+ tunnel?: {
34
+ local: IP;
35
+ remote: IP;
36
+ addresses: readonly InterfaceAddress[];
37
+ mtu?: number;
38
+ number?: number;
39
+ };
40
+ /** Our BGP sessions with it, by name: its address, and ours when it is not the interface's. */
41
+ sessions: Readonly<Record<string, {
42
+ address: IP;
43
+ local_address?: IP;
44
+ }>>;
45
+ /** The sessions are more than one hop away, from this address of ours. They share a group. */
46
+ multihop?: {
47
+ local_address: IP;
48
+ families?: readonly Family[];
49
+ password?: Secret;
50
+ };
51
+ /** The most prefixes a session may send, by its family: about twice what it sends. Over it, the session closes until it is cleared. */
52
+ limits?: {
53
+ ipv4?: number;
54
+ ipv6?: number;
55
+ };
56
+ /** What we take. Nothing when absent. */
57
+ import?: {
58
+ /** Its default route, at this preference. */
59
+ default?: {
60
+ local_pref?: number;
61
+ description?: string;
62
+ };
63
+ /** Its routes, through `Bgp.learn`: the `before` rules, sanity, this preference, the tag. Only the default when absent. */
64
+ learn?: {
65
+ local_pref?: number;
66
+ before?: readonly PolicyRule<never, never>[];
67
+ };
68
+ };
69
+ /** Rules of yours in its export, between the community actions and what we announce, such as a prepend. */
70
+ export?: readonly PolicyRule<never, never>[];
71
+ /** What we announce to it. Everything else is rejected. */
72
+ announce: readonly Announced[];
73
+ /** Drop RFC 1918 sources arriving from it. `privateDrops()` lists the VLANs. */
74
+ drop_private?: true;
75
+ };
76
+ /** Upstreams by key. The key names everything built from it. */
77
+ export type UpstreamCatalog = Readonly<Record<string, Upstream>>;
78
+ /** A name made from an upstream: `{key}` is its key, `{KEY}` the key in capitals, `{number}` its `handoff.number` or `tunnel.number`. */
79
+ type Pattern = string;
80
+ /** A pattern for every upstream, or one per role. A role left out takes the default. */
81
+ type ByRole = Pattern | {
82
+ readonly provider?: Pattern;
83
+ readonly rs?: Pattern;
84
+ readonly peer?: Pattern;
85
+ };
86
+ /** How the names built from each upstream are made. Each one left out takes its default. */
87
+ export type UpstreamNames = {
88
+ /** The VLAN a handoff is on. `{key}` when absent. */
89
+ vlan?: ByRole;
90
+ /** The native VLAN of a handoff sent tagged. `{key}-native` when absent. */
91
+ native?: Pattern;
92
+ /** The router's interface: per role, and `tunnel` for one reached over a tunnel. `{key}` when absent. */
93
+ interface?: Pattern | {
94
+ readonly provider?: Pattern;
95
+ readonly rs?: Pattern;
96
+ readonly peer?: Pattern;
97
+ readonly tunnel?: Pattern;
98
+ };
99
+ /** The import policy. `{KEY}-IN` when absent. */
100
+ import?: Pattern;
101
+ /** The export policy. `{KEY}-OUT` when absent. */
102
+ export?: Pattern;
103
+ /** The BGP group of an exchange's route servers or of multihop sessions. `{key}` when absent. */
104
+ group?: Pattern;
105
+ /** The description of a handoff's switch port. Its `name` when absent. No device names it, so it may be any function. */
106
+ port?: (key: string, upstream: Upstream) => string;
107
+ };
108
+ /** How each upstream's policies are written: shared by every upstream of a catalog. */
109
+ export type UpstreamPolicies<P extends string> = {
110
+ /** A policy holding `Bgp.sanity()` that each import calls. The sanity rules are written in each import when absent. */
111
+ sanity?: P;
112
+ /** Where a route came from, by upstream: a community or a rule, such as `communityScheme(...).tag(...)`. Nothing is tagged when absent. */
113
+ tag?: (key: string, upstream: Upstream) => StandardCommunity | LargeCommunity | PolicyRule<never, never>;
114
+ /** Rules first in each export, such as `communityScheme(...).actions(upstream.asn)`. */
115
+ actions?: (key: string, upstream: Upstream) => readonly PolicyRule<never, never>[];
116
+ /** The source address for routes of its family, one per family. */
117
+ sources?: readonly IP[];
118
+ };
119
+ /** The names a device can refer to, and their patterns when the project gives none. */
120
+ declare const DEFAULTS: {
121
+ readonly vlan: "{key}";
122
+ readonly native: "{key}-native";
123
+ readonly interface: "{key}";
124
+ readonly import: "{KEY}-IN";
125
+ readonly export: "{KEY}-OUT";
126
+ readonly group: "{key}";
127
+ };
128
+ type Defaults = typeof DEFAULTS;
129
+ type Field = keyof Defaults;
130
+ type Key<C> = keyof C & string;
131
+ /** The upstreams that land on a VLAN of ours. */
132
+ type Landed<C> = {
133
+ [K in Key<C>]: C[K] extends {
134
+ handoff: object;
135
+ } ? K : never;
136
+ }[Key<C>];
137
+ /** The upstreams that send their VLAN tagged. */
138
+ type Tagged<C> = {
139
+ [K in Key<C>]: C[K] extends {
140
+ handoff: {
141
+ native: number;
142
+ };
143
+ } ? K : never;
144
+ }[Key<C>];
145
+ /** The upstreams reached over a tunnel. */
146
+ type Tunneled<C> = {
147
+ [K in Key<C>]: C[K] extends {
148
+ tunnel: object;
149
+ } ? K : never;
150
+ }[Key<C>];
151
+ /** The upstreams whose sessions share a group: an exchange's route servers, or multihop sessions. */
152
+ type Grouped<C> = {
153
+ [K in Key<C>]: C[K] extends {
154
+ role: "rs";
155
+ } | {
156
+ multihop: object;
157
+ } ? K : never;
158
+ }[Key<C>];
159
+ /** The upstreams we take routes from. */
160
+ type Importing<C> = {
161
+ [K in Key<C>]: C[K] extends {
162
+ import: object;
163
+ } ? K : never;
164
+ }[Key<C>];
165
+ /** The project's pattern for a name, or the default. */
166
+ type PatternOf<N, F extends Field> = N extends {
167
+ [X in F]: infer P;
168
+ } ? P : Defaults[F];
169
+ /** The pattern an upstream takes: the only one, its tunnel's, its role's, or the default. */
170
+ type Chosen<P, U, F extends Field> = P extends string ? P : U extends {
171
+ tunnel: object;
172
+ } ? P extends {
173
+ tunnel: infer T extends string;
174
+ } ? T : Defaults[F] : U extends {
175
+ role: infer R extends string;
176
+ } ? P extends {
177
+ [X in R]: infer T extends string;
178
+ } ? T : Defaults[F] : Defaults[F];
179
+ /** Every `From` in `S` replaced. */
180
+ type Replace<S extends string, From extends string, To extends string> = S extends `${infer A}${From}${infer B}` ? `${A}${To}${Replace<B, From, To>}` : S;
181
+ /** An upstream's `handoff.number` or `tunnel.number`. */
182
+ type NumberOf<U> = U extends {
183
+ handoff: {
184
+ number: infer N extends number;
185
+ };
186
+ } ? N : U extends {
187
+ tunnel: {
188
+ number: infer N extends number;
189
+ };
190
+ } ? N : number;
191
+ /** The name built for upstream `K`: its pattern with the key, the key in capitals and its number filled in. */
192
+ type Named<C, N, F extends Field, K extends Key<C>> = Replace<Replace<Replace<Chosen<PatternOf<N, F>, C[K], F>, "{key}", K>, "{KEY}", Uppercase<K>>, "{number}", `${NumberOf<C[K]>}`>;
193
+ /** Each of these upstreams' names. */
194
+ type Names<C, N, F extends Field, Ks extends Key<C>> = {
195
+ [K in Ks]: Named<C, N, F, K>;
196
+ }[Ks];
197
+ /** The catalog as written, with any key the shape does not have refused, at every level. */
198
+ type Exact<S, T> = T extends readonly unknown[] | string | number | boolean | Secret | ((...args: never[]) => unknown) ? T : {
199
+ [K in keyof T]: K extends keyof S ? Exact<NonNullable<S[K]>, T[K]> : never;
200
+ };
201
+ /** The names as written, with a name or a role Upstreams does not make refused. */
202
+ type ExactNames<N> = {
203
+ [F in keyof N]: F extends keyof UpstreamNames ? N[F] extends string | ((...args: never[]) => unknown) ? N[F] : {
204
+ [R in keyof N[F]]: R extends "provider" | "rs" | "peer" | "tunnel" ? N[F][R] : never;
205
+ } : never;
206
+ };
207
+ /** A network's upstreams written once, and each device's part built from them. */
208
+ export declare class Upstreams {
209
+ /**
210
+ * Each upstream written once: its AS, where it lands, its sessions, limits and policy. Everything a device needs is built from it, under names made from each key, so a device that names one is checked. By default:
211
+ *
212
+ * - VLAN `<key>`, and `<key>-native` for one that sends its VLAN tagged. The router's interface is `<key>` too, a VLAN interface or a GRE tunnel.
213
+ * - Policies `<KEY>-IN` and `<KEY>-OUT`; a BGP group `<key>` for an exchange's route servers or multihop sessions.
214
+ *
215
+ * `names` gives patterns of your own, such as `{ interface: { provider: "core-ipt0{number}-{key}" } }`; see `UpstreamNames`.
216
+ *
217
+ * On the router: `vlans()`, `interfaces()`, `uplinks()`, `groups()`, `neighbors()`, `policies({ ... })`, `tunnelRules()`, `addresses()`. On a switch: `vlans(device)`, `ports(device)`, `vlansOn(device)`. For checks and ACLs: `untrusted()`, `natives()`, `privateDrops()`. Lists follow the catalog's order, where order counts on a device.
218
+ */
219
+ static define<const C extends UpstreamCatalog, const N extends UpstreamNames = {}>(catalog: C & {
220
+ [K in keyof C]: Exact<Upstream, C[K]>;
221
+ }, options?: {
222
+ names?: N & ExactNames<N>;
223
+ }): {
224
+ vlans: <const D extends string | undefined = undefined>(device?: D) => D extends string ? { [K in Names<C, N, "vlan", Landed<C>>]: Vlan; } & { [K in Names<C, N, "native", Tagged<C>>]: Vlan; } : { [K in Names<C, N, "vlan", Landed<C>>]: Vlan; };
225
+ vlansOn: (device: string) => Names<C, N, "vlan", Landed<C>>[];
226
+ ports: (device: string) => Record<string, Port<Names<C, N, "native", Tagged<C>> | Names<C, N, "vlan", Landed<C>>, never, never, never>>;
227
+ interfaces: () => { [K in Names<C, N, "interface", Landed<C>>]: VlanInterface<Names<C, N, "vlan", Landed<C>>, never>; } & { [K in Names<C, N, "interface", Tunneled<C>>]: Gre<never>; };
228
+ uplinks: () => Names<C, N, "interface", Landed<C>>[];
229
+ groups: () => Record<Names<C, N, "group", Grouped<C>>, NeighborSettings<Names<C, N, "export", Key<C>> | Names<C, N, "import", Importing<C>>>>;
230
+ neighbors: () => Record<string, Neighbor<Names<C, N, "export", Key<C>> | Names<C, N, "import", Importing<C>>, Names<C, N, "group", Grouped<C>>>>;
231
+ policies: <const P extends string = never>(options?: UpstreamPolicies<P>) => Record<Names<C, N, "export", Key<C>> | Names<C, N, "import", Importing<C>>, PolicyRule<P, never>[]>;
232
+ untrusted: () => (Names<C, N, "native", Tagged<C>> | Names<C, N, "vlan", Landed<C>>)[];
233
+ natives: () => Names<C, N, "native", Tagged<C>>[];
234
+ privateDrops: () => Names<C, N, "vlan", Landed<C>>[];
235
+ tunnelRules: () => FilterRule<never, never>[];
236
+ addresses: () => IP[];
237
+ };
238
+ }
239
+ export {};
@@ -15,4 +15,10 @@ export declare class BgpWait {
15
15
  static until(read: () => Promise<BgpSession[]>, enough: (sessions: BgpSession[]) => boolean, limits: WaitLimits, waiting?: (sessions: BgpSession[]) => void): Promise<BgpSession[]>;
16
16
  /** How many of these sessions are established. */
17
17
  static upCount(sessions: readonly BgpSession[]): number;
18
+ /** Sessions closed on their limit within this many minutes of each other are one burst. */
19
+ static readonly BURST_MINUTES = 5;
20
+ /**
21
+ * 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.
22
+ */
23
+ static limitBursts(sessions: readonly BgpSession[]): BgpSession[][];
18
24
  }
@@ -57,6 +57,16 @@ export type BgpSession = {
57
57
  prefixes: string;
58
58
  /** How long it has been up, as the device prints it, or `no session`. */
59
59
  uptime: string;
60
+ /** Why it is down, when the device says: `limit exceeded`, `admin down`, `connecting`. */
61
+ reason?: string;
62
+ /** It closed because the neighbor sent more prefixes than its limit, and stays closed until cleared. */
63
+ limit?: BgpLimit;
64
+ };
65
+ /** A session closed on its prefix limit: the limit, and when it closed, as the device prints it and in milliseconds, to compare. */
66
+ export type BgpLimit = {
67
+ max?: number;
68
+ stopped?: string;
69
+ stoppedAt?: number;
60
70
  };
61
71
  /** One BGP log line: when, as the device printed it, such as `2026-10-24 03:12:44`, and the rest of the line. */
62
72
  export type BgpLogLine = {
@@ -113,6 +123,8 @@ export declare abstract class Platform {
113
123
  bgpSessions?(device: Device, session: ReadSession): Promise<BgpSession[]>;
114
124
  /** The device's BGP log lines from the last `minutes`, by the device's own clock, read over a `session()`. */
115
125
  bgpLog?(session: ReadSession, minutes: number): Promise<BgpLogLine[]>;
126
+ /** Open again these sessions, each closed on its prefix limit, read by `bgpSessions`. Only a session still closed on its limit is touched. */
127
+ bgpClearLimit?(session: ReadSession, sessions: readonly BgpSession[]): Promise<void>;
116
128
  /**
117
129
  * 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.
118
130
  */
@@ -13,8 +13,12 @@ export declare class RouterOs extends Platform {
13
13
  private static readonly EXPORT_TIMESTAMP;
14
14
  /** Lists certificates in the form the renderer writes them, since an export does not include them. One line, because a script sent over exec runs as one. */
15
15
  private static readonly LIST_CERTIFICATES;
16
- /** Every BGP session, one line each: name, remote address, established, prefixes received, uptime. One line, because a script sent over exec runs as one. */
16
+ /**
17
+ * 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.
18
+ */
17
19
  private static readonly LIST_BGP_SESSIONS;
20
+ /** Open again the session from one address, only while it is closed on its limit. */
21
+ private static clearLimit;
18
22
  /**
19
23
  * 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.
20
24
  */
@@ -51,8 +55,11 @@ export declare class RouterOs extends Platform {
51
55
  session(device: Device, options?: ReadOptions): Promise<ReadSession>;
52
56
  /**
53
57
  * 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.
58
+ *
59
+ * 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.
54
60
  */
55
61
  bgpSessions(device: Device, session: ReadSession): Promise<BgpSession[]>;
62
+ bgpClearLimit(session: ReadSession, sessions: readonly BgpSession[]): Promise<void>;
56
63
  /** 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. */
57
64
  bgpLog(session: ReadSession, minutes: number): Promise<BgpLogLine[]>;
58
65
  /**
@@ -109,7 +109,7 @@ export type Port<V extends string, A extends string, L extends string, F extends
109
109
  acl?: A;
110
110
  /** The LAG this port is in. Every other setting goes on the LAG. */
111
111
  lag?: L;
112
- /** Another of our devices at the other end of the cable. */
112
+ /** 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`. */
113
113
  link?: Link;
114
114
  };
115
115
  /** Settings every interface that is not a physical port shares. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takodotid/circuit",
3
- "version": "0.37.0",
3
+ "version": "0.39.0",
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",
package/src/cli/index.ts CHANGED
@@ -103,12 +103,20 @@ class Cli {
103
103
  return new Cli(configPath, network, options);
104
104
  }
105
105
 
106
- /** The config, a TypeScript file. Bun imports it as it is; Node needs jiti, which also resolves imports written without an extension. */
106
+ /**
107
+ * The config, a TypeScript file. Bun imports it as it is; Node needs jiti, which also resolves imports written without an extension. A linked port without a description is given one from its far end.
108
+ */
107
109
  private static async load(path: string): Promise<Network> {
108
- if (process.versions.bun) return (await import(path)).default;
110
+ let network: Network;
111
+
112
+ if (process.versions.bun) {
113
+ network = (await import(path)).default;
114
+ } else {
115
+ const { createJiti } = await import("jiti");
116
+ network = await createJiti(import.meta.url).import<Network>(path, { default: true });
117
+ }
109
118
 
110
- const { createJiti } = await import("jiti");
111
- return createJiti(import.meta.url).import(path, { default: true });
119
+ return { ...network, devices: Cabling.describeLinks(network.devices) };
112
120
  }
113
121
 
114
122
  private static fail(message: string): never {
@@ -228,7 +236,7 @@ class Cli {
228
236
 
229
237
  program
230
238
  .command("bgp")
231
- .description("each BGP neighbor in the config next to its live session: up or down, prefixes, uptime")
239
+ .description("each BGP neighbor in the config next to its live session: up or down and why, prefixes, uptime")
232
240
  .argument("[device]", "may be left out when only one device has BGP neighbors")
233
241
  .argument("[name-regex]", "only the neighbors whose name matches")
234
242
  .option(
@@ -238,6 +246,7 @@ class Cli {
238
246
  )
239
247
  .option("--max <seconds>", "how long --wait waits at most", Cli.seconds, BgpWait.COMMAND.seconds)
240
248
  .option("--log [minutes]", "also print the device's BGP log lines from the last minutes, 5 by default", Cli.minutes)
249
+ .option("--clear-limit", "open again the neighbors shown that closed on their prefix limit; any other is left alone")
241
250
  .option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
242
251
  .action(async (first, second, flags) => (await Cli.open(flags.config)).bgp(first, second, flags));
243
252
 
@@ -604,12 +613,12 @@ class Cli {
604
613
  }
605
614
 
606
615
  /**
607
- * Each BGP neighbor in the config next to its live session. `first` is the device, or, when it names none and only one device has BGP neighbors, the name pattern. With `wait`, reads again until every neighbor shown, or that many, is up, and fails when time runs out.
616
+ * Each BGP neighbor in the config next to its live session. `first` is the device, or, when it names none and only one device has BGP neighbors, the name pattern. With `wait`, reads again until every neighbor shown, or that many, is up, and fails when time runs out. With `clearLimit`, opens again the neighbors shown that closed on their prefix limit, and fails when none did.
608
617
  */
609
618
  private async bgp(
610
619
  first: string | undefined,
611
620
  second: string | undefined,
612
- flags: { wait?: true | number; max: number; log?: true | number }
621
+ flags: { wait?: true | number; max: number; log?: true | number; clearLimit?: true }
613
622
  ): Promise<void> {
614
623
  const { device, pattern } = this.bgpTarget(first, second);
615
624
  const adapter = this.adapterOf(device);
@@ -620,6 +629,8 @@ class Cli {
620
629
  .join(", ");
621
630
  if (!adapter.bgpSessions) Cli.fail(`circuit bgp does not support ${device.platform} yet, only ${supported}`);
622
631
  if (flags.log && !adapter.bgpLog) Cli.fail(`circuit bgp --log does not support ${device.platform} yet`);
632
+ if (flags.clearLimit && !adapter.bgpClearLimit) Cli.fail(`circuit bgp --clear-limit does not support ${device.platform} yet`);
633
+ if (flags.clearLimit && flags.wait !== undefined) Cli.fail("--clear-limit and --wait go in two runs: clear first, then wait");
623
634
 
624
635
  let matcher: RegExp;
625
636
  try {
@@ -640,6 +651,11 @@ class Cli {
640
651
  try {
641
652
  const read = async () => (await adapter.bgpSessions!(device, session)).filter((neighbor) => shown.includes(neighbor.neighbor));
642
653
 
654
+ if (flags.clearLimit) {
655
+ await Cli.clearLimits(adapter, session, read);
656
+ return;
657
+ }
658
+
643
659
  const limits = { seconds: flags.max, every: BgpWait.COMMAND.every };
644
660
  const sessions = await BgpWait.until(
645
661
  read,
@@ -660,6 +676,27 @@ class Cli {
660
676
  }
661
677
  }
662
678
 
679
+ /** Open again the sessions closed on their limit, say which others were left alone, and show them all after. */
680
+ private static async clearLimits(adapter: Platform, session: ReadSession, read: () => Promise<BgpSession[]>): Promise<void> {
681
+ const sessions = await read();
682
+ const closed = sessions.filter((bgp) => bgp.limit);
683
+
684
+ if (!closed.length) {
685
+ Cli.printBgp(sessions);
686
+ console.error("\nno neighbor shown is closed on its prefix limit, so nothing was cleared");
687
+ process.exitCode = 1;
688
+ return;
689
+ }
690
+
691
+ for (const bgp of sessions) if (!bgp.limit) console.log(`${bgp.neighbor}: left alone, not closed on its limit`);
692
+ await adapter.bgpClearLimit!(session, closed);
693
+ console.log(
694
+ `cleared ${closed.map((bgp) => bgp.neighbor).join(", ")}: each connects again, and closes again if it still sends too much\n`
695
+ );
696
+
697
+ Cli.printBgp(await read());
698
+ }
699
+
663
700
  /** The device `circuit bgp` reads, and the name pattern, from its two optional arguments. */
664
701
  private bgpTarget(first: string | undefined, second: string | undefined): { device: Device; pattern: string | undefined } {
665
702
  if (second !== undefined || (first && this.network.devices.some((device) => device.name === first))) {
@@ -681,12 +718,34 @@ class Cli {
681
718
 
682
719
  for (const session of sessions) {
683
720
  const state = session.established ? "up " : "DOWN";
721
+ const why = session.reason ? ` ${Cli.downReason(session)}` : "";
684
722
  console.log(
685
- `${session.neighbor.padEnd(width)} ${state} ${session.prefixes.padStart(7)} ${session.uptime.padEnd(12)} ${session.remote}`
723
+ `${session.neighbor.padEnd(width)} ${state} ${session.prefixes.padStart(7)} ${session.uptime.padEnd(12)} ${session.remote}${why}`
686
724
  );
687
725
  }
688
726
 
689
727
  console.log(`${BgpWait.upCount(sessions)}/${sessions.length} up`);
728
+
729
+ for (const burst of BgpWait.limitBursts(sessions)) {
730
+ const names = burst.map((session) => session.neighbor);
731
+ const listed = names.length > 1 ? `${names.slice(0, -1).join(", ")} and ${names.at(-1)}` : names[0];
732
+ const seconds = Math.round((burst.at(-1)!.limit!.stoppedAt! - burst[0]!.limit!.stoppedAt!) / 1000);
733
+
734
+ console.log(
735
+ `
736
+ ${listed} closed on their prefix limit within ${seconds} s of each other, from ${burst[0]!.limit!.stopped}. Several limits at once is usually a leak upstream, not growth: find the leak before raising a limit, then clear them with --clear-limit.`
737
+ );
738
+ }
739
+ }
740
+
741
+ /** Why a session is down, with its limit and when it closed when that is why. */
742
+ private static downReason(session: BgpSession): string {
743
+ const { limit } = session;
744
+ if (!limit) return session.reason ?? "";
745
+
746
+ const over = limit.max !== undefined ? `: over ${limit.max} prefixes` : "";
747
+ const when = limit.stopped ? `, closed ${limit.stopped}` : "";
748
+ return `${session.reason}${over}${when}`;
690
749
  }
691
750
 
692
751
  /** The device's BGP log lines from the last `minutes`, a message repeated folded into one line, secrets hidden. */
@@ -121,6 +121,36 @@ export class Cabling {
121
121
  return findings;
122
122
  }
123
123
 
124
+ /**
125
+ * Each linked port without a description gets one from its far end: the far device and the far port as printed on it, such as `firewall-1 port6`. The far device's own file has its platform and model, so the label is never written twice. A port whose far device is not in the network keeps none.
126
+ */
127
+ static describeLinks(devices: readonly Device[]): Device[] {
128
+ const byName = new Map(devices.map((device) => [device.name, device]));
129
+
130
+ return devices.map((device) => {
131
+ const ports = Object.entries(device.ports ?? {});
132
+ if (!ports.some(([, port]) => port?.link && port.description === undefined)) return device;
133
+
134
+ const described = ports.map(([name, port]) => {
135
+ const far = port?.link ? byName.get(port.link.device) : undefined;
136
+ if (!port?.link || port.description !== undefined || !far) return [name, port];
137
+
138
+ return [name, { ...port, description: `${far.name} ${Cabling.labelOf(far, port.link.port)}` }];
139
+ });
140
+
141
+ return { ...device, ports: Object.fromEntries(described) } as Device;
142
+ });
143
+ }
144
+
145
+ /** A port as printed on the device, or Circuit's name for it when the model has no spelling for it. */
146
+ private static labelOf(device: Device, port: string): string {
147
+ try {
148
+ return Catalog.spell(device.platform, device.model, port);
149
+ } catch {
150
+ return port;
151
+ }
152
+ }
153
+
124
154
  /** A system name is the device's name, alone or with a domain after it. */
125
155
  private static isDevice(system: string, name: string): boolean {
126
156
  return system === name || system.startsWith(`${name}.`);