@takodotid/circuit 0.42.0 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takodotid/circuit",
3
- "version": "0.42.0",
3
+ "version": "0.43.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/core/utils.ts CHANGED
@@ -19,6 +19,11 @@ export class Utils {
19
19
  return result as Merged<T>;
20
20
  }
21
21
 
22
+ /** The table a route or a BGP session is in, beside the main one: `undefined` for the main table, written `main` or left out. */
23
+ static otherTable(table: string | undefined): string | undefined {
24
+ return table === "main" ? undefined : table;
25
+ }
26
+
22
27
  /** Minutes east of UTC an IANA zone is at now, for platforms that take an offset rather than a zone. */
23
28
  static timeZoneOffset(zone: string): number {
24
29
  const name = new Intl.DateTimeFormat("en-US", {
@@ -1,6 +1,7 @@
1
1
  // Rules every network has to satisfy, whatever its design. Types catch most of this at compile time; these run again at load, because config is data and may not come from TypeScript.
2
2
 
3
3
  import { X509Certificate } from "node:crypto";
4
+ import { Utils } from "./utils";
4
5
  import { Platforms } from "../platforms";
5
6
  import { Catalog } from "../platforms/catalog";
6
7
  import type { Device, Switched } from "../schema";
@@ -63,7 +64,10 @@ export class Validator {
63
64
 
64
65
  const adapter = Platforms.find(device.platform);
65
66
  if (!adapter) report(`no adapter for platform ${device.platform}`);
66
- else for (const field of adapter.unsupported(device)) report(`${device.platform} cannot express ${field}`);
67
+ else {
68
+ for (const field of adapter.unsupported(device)) report(`${device.platform} cannot express ${field}`);
69
+ findings.push(...(adapter.checks?.(device) ?? []));
70
+ }
67
71
  }
68
72
 
69
73
  findings.push(...CrossChecks.run(network.devices));
@@ -294,9 +298,10 @@ export class Validator {
294
298
  names.expect(
295
299
  `static route ${route.prefix} table`,
296
300
  route.table,
297
- route.table !== undefined && route.table in (routing?.tables ?? {})
301
+ route.table !== undefined && (route.table === "main" || route.table in (routing?.tables ?? {}))
298
302
  );
299
- if (route.vrf && route.table) report(`static route ${route.prefix} has both vrf and table; a route is in one of them`);
303
+ if (route.vrf && Utils.otherTable(route.table))
304
+ report(`static route ${route.prefix} has both vrf and table; a route is in one of them`);
300
305
  }
301
306
 
302
307
  Validator.checkTables(device, names, report, cidr);
@@ -306,7 +311,7 @@ export class Validator {
306
311
 
307
312
  for (const prefix of bgp?.networks ?? []) cidr("BGP network", prefix);
308
313
 
309
- const isTable = (table: string | undefined) => table !== undefined && table in (routing?.tables ?? {});
314
+ const isTable = (table: string | undefined) => table !== undefined && (table === "main" || table in (routing?.tables ?? {}));
310
315
 
311
316
  for (const [name, group] of Object.entries(bgp?.groups ?? {})) {
312
317
  for (const policy of [group.import, group.export]) {
@@ -348,9 +353,12 @@ export class Validator {
348
353
  const bgp = routing?.bgp;
349
354
 
350
355
  const installed = new Set<string>();
351
- for (const route of routing?.static ?? []) if (route.table) installed.add(route.table);
356
+ for (const route of routing?.static ?? []) {
357
+ const table = Utils.otherTable(route.table);
358
+ if (table) installed.add(table);
359
+ }
352
360
  for (const neighbor of Object.values(bgp?.neighbors ?? {})) {
353
- const table = neighbor.table ?? (neighbor.group ? bgp?.groups?.[neighbor.group]?.table : undefined);
361
+ const table = Utils.otherTable(neighbor.table ?? (neighbor.group ? bgp?.groups?.[neighbor.group]?.table : undefined));
354
362
  if (table) installed.add(table);
355
363
  }
356
364
 
@@ -80,13 +80,22 @@ export type CommunityScheme<
80
80
  /** A neighbor asks: prepend toward an AS once, twice or three times, `asn:function:asn`. */
81
81
  prepend?: { once?: number; twice?: number; three_times?: number };
82
82
  /**
83
- * A neighbor asks: drop all traffic to this route, `asn:function:0`, usually one address under attack. Your router drops it, and so does every upstream in `upstreams`.
84
- *
85
- * `upstreams` maps an upstream's AS to its own blackhole community, as its NOC gives it. The route is passed to that upstream with it, so the traffic stops before it reaches your links. An upstream not listed never receives the route.
83
+ * A neighbor asks: drop all traffic to this route, `asn:function:0`, usually one address under attack. Your router drops it. An upstream whose own blackhole community you know (`communities.blackhole` on the neighbor you pass to `actions`) receives the route with it and drops the traffic too; any other never receives the route.
86
84
  */
87
- blackhole?: { function: number; upstreams?: Readonly<Record<number, StandardCommunity>> };
85
+ blackhole?: { function: number };
86
+ };
87
+
88
+ /** The communities another network offers us, as its NOC gives them: what it does with a route we send it. */
89
+ export type NeighborCommunities = {
90
+ /** It drops traffic to a route we send it with this community. */
91
+ blackhole?: StandardCommunity | LargeCommunity;
92
+ /** It does not pass a route we send it with this community to the AS given, such as an exchange's route servers' `rs-asn:0:peer-asn`. */
93
+ do_not_announce_to?: (asn: number) => StandardCommunity | LargeCommunity;
88
94
  };
89
95
 
96
+ /** A neighbor an export goes to: its AS, or its AS and the communities it offers. */
97
+ export type ExportNeighbor = number | { asn: number; communities?: NeighborCommunities };
98
+
90
99
  /** The class names a scheme declares, or any name when it declares none. */
91
100
  type ClassName<Classes extends CommunityClasses> = {} extends Classes ? string : Extract<keyof Classes, string>;
92
101
 
@@ -248,10 +257,12 @@ export class Bgp {
248
257
  * Policy rules and a catalogue from one community scheme, so the communities your routers set, the ones they act on, and the ones you publish never disagree.
249
258
  *
250
259
  * - `tag(neighborClass, neighborAs, { site, trusted })`: one rule for an import policy, `{ description: "Learned from AS<n>", set }`. Its `set` removes your standard communities and your large ones (a `trusted` neighbor loses only the "where it came from" functions, so it keeps its requests: do not announce, prepend, blackhole), then adds `learned_from`, `learned_at` and `learned_from_as` where the scheme has them. `site` is needed when the scheme has `learned_at`.
251
- * - `actions(neighborAs)`: the rules for an export policy toward one neighbor, before the rules that accept. They are `[...blackholeExport(n), ...rejectDoNotAnnounce(n), ...prepends(n), stripOurs()]`; toward a neighbor that takes blackholes (`blackhole.upstreams`), `rejectDoNotAnnounce(n)` comes first, so a route asked not to go there is never passed on as a blackhole.
252
- * - `blackholeExport(neighborAs)`: a blackholed route goes no further, `Blackholed here, never announced`, unless `blackhole.upstreams` names the neighbor's own blackhole community: then it is passed on with it, so the neighbor drops the traffic too. Nothing without `blackhole`.
253
- * - `rejectDoNotAnnounce(neighborAs)`: rejects a route asked not to be announced anywhere, then one asked not to be announced to this neighbor. Nothing without `do_not_announce`.
254
- * - `prepends(neighborAs)`: prepends once, twice or three times toward this neighbor, where a route asks. Nothing without `prepend`.
260
+ * Each export helper takes the neighbor: its AS, or `{ asn, communities }` with the communities it offers, such as an `Upstreams.catalog` entry.
261
+ *
262
+ * - `actions(neighbor)`: the rules for an export policy toward one neighbor, before the rules that accept. They are `[...blackholeExport(n), ...rejectDoNotAnnounce(n), ...prepends(n), stripOurs()]`; toward a neighbor that takes blackholes (`communities.blackhole`), `rejectDoNotAnnounce(n)` comes first, so a route asked not to go there is never passed on as a blackhole.
263
+ * - `blackholeExport(neighbor)`: a blackholed route goes no further, `Blackholed here, never announced`, unless the neighbor has `communities.blackhole`: then it is passed on with it, so the neighbor drops the traffic too. Nothing without `blackhole` in the scheme.
264
+ * - `rejectDoNotAnnounce(neighbor)`: rejects a route asked not to be announced anywhere, then one asked not to be announced to this neighbor. Nothing without `do_not_announce`.
265
+ * - `prepends(neighbor)`: prepends once, twice or three times toward this neighbor, where a route asks. Nothing without `prepend`.
255
266
  * - `stripOurs()`: one rule removing every community of yours, `Our communities stay inside our network`.
256
267
  * - `blackhole(prefixSets)`: rules for the import policy of a neighbor you trust with blackholes, before anything that rejects long prefixes. They take a route the neighbor asked to blackhole, but only inside `prefixSets`, its own space, and drop traffic to it here. One rule per set, so one call covers both families.
257
268
  * - `catalogue`: every community, for `communities` in `defineNetwork`.
@@ -331,28 +342,38 @@ export class Bgp {
331
342
  }));
332
343
  };
333
344
 
345
+ /** The neighbor's AS. */
346
+ const asnOf = (neighbor: ExportNeighbor) => (typeof neighbor === "number" ? neighbor : neighbor.asn);
347
+
334
348
  /** The neighbor's own blackhole community, when it takes blackholes from us. */
335
- const passOnTo = (neighborAs: number) => scheme.blackhole?.upstreams?.[neighborAs];
349
+ const passOnTo = (neighbor: ExportNeighbor) => (typeof neighbor === "number" ? undefined : neighbor.communities?.blackhole);
336
350
 
337
- const blackholeExport = (neighborAs: number): PolicyRule<never, never>[] => {
351
+ const blackholeExport = (neighbor: ExportNeighbor): PolicyRule<never, never>[] => {
338
352
  if (scheme.blackhole === undefined) return [];
339
353
 
340
- const passOn = passOnTo(neighborAs);
354
+ const passOn = passOnTo(neighbor);
341
355
  const match = { large_community: community(scheme.blackhole.function, 0) };
356
+ if (!passOn) return [{ description: "Blackholed here, never announced", match, action: "reject" }];
342
357
 
343
- return passOn
344
- ? [
345
- {
346
- description: `Blackhole passed on to AS${neighborAs}`,
347
- match,
348
- set: { remove_communities: ours.standard, remove_large_communities: ours.large, add_communities: [passOn] },
349
- action: "accept",
350
- },
351
- ]
352
- : [{ description: "Blackholed here, never announced", match, action: "reject" }];
358
+ const large = passOn.split(":").length === 3;
359
+ return [
360
+ {
361
+ description: `Blackhole passed on to AS${asnOf(neighbor)}`,
362
+ match,
363
+ set: {
364
+ remove_communities: ours.standard,
365
+ remove_large_communities: ours.large,
366
+ ...(large
367
+ ? { add_large_communities: [passOn as LargeCommunity] }
368
+ : { add_communities: [passOn as StandardCommunity] }),
369
+ },
370
+ action: "accept",
371
+ },
372
+ ];
353
373
  };
354
374
 
355
- const rejectDoNotAnnounce = (neighborAs: number): PolicyRule<never, never>[] => {
375
+ const rejectDoNotAnnounce = (neighbor: ExportNeighbor): PolicyRule<never, never>[] => {
376
+ const neighborAs = asnOf(neighbor);
356
377
  const fn = scheme.do_not_announce;
357
378
  if (fn === undefined) return [];
358
379
 
@@ -366,8 +387,9 @@ export class Bgp {
366
387
  ];
367
388
  };
368
389
 
369
- const prepends = (neighborAs: number): PolicyRule<never, never>[] =>
390
+ const prepends = (neighbor: ExportNeighbor): PolicyRule<never, never>[] =>
370
391
  Bgp.PREPENDS.flatMap(([name, times]) => {
392
+ const neighborAs = asnOf(neighbor);
371
393
  const fn = scheme.prepend?.[name];
372
394
  if (fn === undefined) return [];
373
395
 
@@ -386,10 +408,10 @@ export class Bgp {
386
408
  });
387
409
 
388
410
  // A route asked not to be announced to a neighbor is never passed on to it as a blackhole either; where nothing is passed on, both only reject.
389
- const actions = (neighborAs: number): PolicyRule<never, never>[] =>
390
- passOnTo(neighborAs)
391
- ? [...rejectDoNotAnnounce(neighborAs), ...blackholeExport(neighborAs), ...prepends(neighborAs), stripOurs()]
392
- : [...blackholeExport(neighborAs), ...rejectDoNotAnnounce(neighborAs), ...prepends(neighborAs), stripOurs()];
411
+ const actions = (neighbor: ExportNeighbor): PolicyRule<never, never>[] =>
412
+ passOnTo(neighbor)
413
+ ? [...rejectDoNotAnnounce(neighbor), ...blackholeExport(neighbor), ...prepends(neighbor), stripOurs()]
414
+ : [...blackholeExport(neighbor), ...rejectDoNotAnnounce(neighbor), ...prepends(neighbor), stripOurs()];
393
415
 
394
416
  const catalogue: Community[] = [
395
417
  ...Object.entries<number>(scheme.learned_from?.classes ?? {}).map(([name, value]) => ({
@@ -424,9 +446,7 @@ export class Bgp {
424
446
  ? [
425
447
  {
426
448
  community: written(scheme.blackhole.function, 0),
427
- description: Object.keys(scheme.blackhole.upstreams ?? {}).length
428
- ? `Blackhole: dropped in AS${asn} and by its upstreams that take it`
429
- : `Blackhole: dropped in AS${asn}, not announced further`,
449
+ description: `Blackhole: dropped in AS${asn}, and by the upstreams that take it`,
430
450
  },
431
451
  ]
432
452
  : []),
@@ -1,7 +1,16 @@
1
1
  // Building blocks most networks write the same way: address lists, cables written once for both ends, edge filters, a BGP import sanity policy, an exchange's member ports and route servers, upstreams written once, checks, and helpers for checks of your own. Each returns plain config to place where it belongs, so nothing here applies itself.
2
2
 
3
3
  export { Bgp } from "./bgp";
4
- export type { AnnounceOptions, Announced, CommunityScheme, DefaultOnlyOptions, LearnOptions, SanityLengths } from "./bgp";
4
+ export type {
5
+ AnnounceOptions,
6
+ Announced,
7
+ CommunityScheme,
8
+ DefaultOnlyOptions,
9
+ ExportNeighbor,
10
+ LearnOptions,
11
+ NeighborCommunities,
12
+ SanityLengths,
13
+ } from "./bgp";
5
14
  export { Cables } from "./cables";
6
15
  export type { FarEnd } from "./cables";
7
16
  export { Checks } from "./checks";
@@ -5,6 +5,8 @@ import type {
5
5
  FilterRule,
6
6
  Gre,
7
7
  InterfaceAddress,
8
+ StandardCommunity,
9
+ LargeCommunity,
8
10
  IP,
9
11
  Neighbor,
10
12
  NeighborSettings,
@@ -15,6 +17,7 @@ import type {
15
17
  Vlan,
16
18
  VlanInterface,
17
19
  } from "../schema";
20
+ import type { NeighborCommunities } from "./bgp";
18
21
 
19
22
  /** A route policy an upstream's sessions use: its name on the device, and its rules, in order. */
20
23
  export type UpstreamPolicy = {
@@ -67,7 +70,7 @@ export type Upstream = {
67
70
  group?: string;
68
71
  /** The sessions are more than one hop away, from this address of ours. */
69
72
  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. */
73
+ /** The routing table its sessions install the routes they learn in: `main`, or one of the router's `routing.tables`, for a transit that carries only one block's traffic out. The main table when absent; write `main` to say so. On RouterOS the sessions also announce from that table, so what they announce needs a route there too. */
71
74
  table?: string;
72
75
  /** 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
76
  limits?: { ipv4?: number; ipv6?: number };
@@ -77,6 +80,8 @@ export type Upstream = {
77
80
  export: UpstreamPolicy;
78
81
  /** Its VLAN is listed by `privateDrops()`, for an ACL that drops RFC 1918 sources arriving from it. Nothing else is done with it. */
79
82
  drop_private?: true;
83
+ /** The communities it offers, as its NOC gives them: its blackhole community, and how to keep a route from one AS. `communityScheme().actions(upstream)` passes blackholes on with it, and `Upstreams.doNotAnnounceTo` uses the other. */
84
+ communities?: NeighborCommunities;
80
85
  };
81
86
 
82
87
  /** Upstreams by key. */
@@ -107,11 +112,45 @@ type PolicyName<C> = Values<{
107
112
  }>;
108
113
  /** Each BGP group name. */
109
114
  type GroupName<C> = Values<{ [K in keyof C]: C[K] extends { group: infer N extends string } ? N : never }>;
110
- /** Each routing table name. */
111
- type TableName<C> = Values<{ [K in keyof C]: C[K] extends { table: infer N extends string } ? N : never }>;
115
+ /** Each routing table name, other than main. */
116
+ type TableName<C> = Exclude<Values<{ [K in keyof C]: C[K] extends { table: infer N extends string } ? N : never }>, "main">;
112
117
 
113
118
  /** A network's upstreams written once, and each device's part built from them. */
114
119
  export class Upstreams {
120
+ /**
121
+ * A catalog of upstreams, by key, written once and checked: a key the shape doesn't have is refused by its name. It gives the catalog back as written, so its keys and entries can be read, and is what `define` takes.
122
+ */
123
+ static catalog<const C extends UpstreamCatalog>(catalog: C & UpstreamCatalog): C {
124
+ return catalog;
125
+ }
126
+
127
+ /**
128
+ * Export rules asking an upstream not to pass our routes to these ASes, with its own `communities.do_not_announce_to`: one rule, `description`, adding one community per AS. Nothing for no AS. An upstream without `do_not_announce_to` is an error, since it can't be asked.
129
+ */
130
+ static doNotAnnounceTo(upstream: Upstream, asns: readonly number[], description: string): PolicyRule<never, never>[] {
131
+ if (!asns.length) return [];
132
+
133
+ const community = upstream.communities?.do_not_announce_to;
134
+ if (!community)
135
+ throw new Error(
136
+ `Upstreams: ${upstream.name ?? `AS${upstream.asn}`} has no communities.do_not_announce_to, so it can't be asked to keep our routes from AS${asns.join(", AS")}`
137
+ );
138
+
139
+ const communities = asns.map(community);
140
+ const large = communities.filter((value) => value.split(":").length === 3) as LargeCommunity[];
141
+ const standard = communities.filter((value) => value.split(":").length === 2) as StandardCommunity[];
142
+
143
+ return [
144
+ {
145
+ description,
146
+ set: {
147
+ ...(standard.length ? { add_communities: standard } : {}),
148
+ ...(large.length ? { add_large_communities: large } : {}),
149
+ },
150
+ },
151
+ ];
152
+ }
153
+
115
154
  /**
116
155
  * Each upstream written once: its AS, where it lands, its sessions, limits and policies, and every name it puts on a device. Everything a device needs is built from it:
117
156
  *
@@ -229,7 +268,7 @@ export class Upstreams {
229
268
  role: upstream.role,
230
269
  ...(upstream.import ? { import: upstream.import.name as PolicyName<C> } : {}),
231
270
  export: upstream.export.name as PolicyName<C>,
232
- ...(upstream.table ? { table: upstream.table as TableName<C> } : {}),
271
+ ...(upstream.table ? { table: upstream.table as TableName<C> | "main" } : {}),
233
272
  ...(multihop
234
273
  ? {
235
274
  local_address: multihop.local_address,
@@ -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,6 +1,7 @@
1
1
  // Values more than one adapter derives from a device in the same way.
2
2
 
3
3
  import { Address } from "../core/address";
4
+ import { Utils } from "../core/utils";
4
5
  import type { Device } from "../schema";
5
6
 
6
7
  /** A feature a platform renders once proved on one of its devices. */
@@ -54,7 +55,7 @@ export class Derive {
54
55
  for (const route of routing?.static ?? []) {
55
56
  if (route.bfd && !has("static-route-bfd"))
56
57
  found.push(`routing.static ${route.prefix} bfd, BFD on a static route is ${unproved}`);
57
- if (route.table && !has("routing-tables"))
58
+ if (Utils.otherTable(route.table) && !has("routing-tables"))
58
59
  found.push(`routing.static ${route.prefix} table, a routing table beside the main one is ${unproved}`);
59
60
  }
60
61
 
@@ -66,12 +67,12 @@ export class Derive {
66
67
  const bgp = routing?.bgp;
67
68
 
68
69
  for (const [name, group] of Object.entries(bgp?.groups ?? {})) {
69
- if (group.table && !has("routing-tables"))
70
+ if (Utils.otherTable(group.table) && !has("routing-tables"))
70
71
  found.push(`routing.bgp.groups.${name}.table, a routing table beside the main one is ${unproved}`);
71
72
  }
72
73
 
73
74
  for (const [name, neighbor] of Object.entries(bgp?.neighbors ?? {})) {
74
- if (neighbor.table && !has("routing-tables"))
75
+ if (Utils.otherTable(neighbor.table) && !has("routing-tables"))
75
76
  found.push(`routing.bgp.neighbors.${name}.table, a routing table beside the main one is ${unproved}`);
76
77
 
77
78
  const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
@@ -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).
@@ -3,8 +3,10 @@
3
3
  // The platform applies every command the moment it arrives and has no candidate configuration, so a run is guarded three ways. Every command is first compiled on the device without running, so a syntax error or an unknown property costs nothing. A backup is saved and a scheduler armed to restore it, and the run must log in again in a fresh session before the scheduler is removed. A command the device refuses restores the backup at once, rather than leaving the device half applied.
4
4
 
5
5
  import type { Client } from "ssh2";
6
+ import { Utils } from "../../core/utils";
6
7
  import { Address } from "../../core/address";
7
8
  import type { Neighbor } from "../../core/cabling";
9
+ import type { Finding } from "../../core/define";
8
10
  import { Secrets } from "../../core/secrets";
9
11
  import type { Device, Family } from "../../schema";
10
12
  import { SerialConsole, type ConsoleDialect, ConsoleShell } from "../../transport/console";
@@ -84,6 +86,12 @@ export class RouterOs extends Platform {
84
86
  ].join(" ");
85
87
 
86
88
  /** Open again the session from one address, only while it is closed on its limit. */
89
+ /** Each advertisement of these prefixes, one line each: `ADV|<session>|<prefix>`. One line, because a script sent over exec runs as one. */
90
+ private static listAdvertisements(prefixes: readonly string[]): string {
91
+ const where = prefixes.map((prefix) => `dst=${prefix}`).join(" or ");
92
+ return `:foreach a in=[/routing bgp advertisements print as-value where ${where}] do={ :put ("ADV|" . ($a->"peer") . "|" . ($a->"dst")) }`;
93
+ }
94
+
87
95
  private static clearLimit(address: string): string {
88
96
  return `/routing bgp session clear [find remote.address=${address} and limit-exceeded] flag=limit-exceeded`;
89
97
  }
@@ -135,6 +143,58 @@ export class RouterOs extends Platform {
135
143
 
136
144
  override readonly console = RouterOs.CONSOLE;
137
145
 
146
+ /**
147
+ * 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.
148
+ */
149
+ checks(device: Device): Finding[] {
150
+ const bgp = device.routing?.bgp;
151
+ const statics = device.routing?.static ?? [];
152
+ const findings: Finding[] = [];
153
+
154
+ for (const [name, neighbor] of Object.entries(bgp?.neighbors ?? {})) {
155
+ const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
156
+ const table = Utils.otherTable(neighbor.table ?? group?.table);
157
+ if (!table) continue;
158
+
159
+ const families = neighbor.families ?? group?.families ?? [Address.family(neighbor.address)];
160
+ const networks = (bgp?.networks ?? []).filter((prefix) => families.includes(Address.family(Address.host(prefix))));
161
+ const inTable = (prefix: string) =>
162
+ statics.some((route) => route.table === table && Address.canonical(route.prefix) === Address.canonical(prefix));
163
+ // What its export accepts by prefix: a network it would reject anyway needs no route in the table. Every network when the export names none.
164
+ const exportPolicy = neighbor.export ?? group?.export;
165
+ const accepted = (exportPolicy ? (device.policies?.[exportPolicy] ?? []) : [])
166
+ .filter((rule) => rule.action === "accept" && rule.match?.prefix)
167
+ .map((rule) => Address.canonical(rule.match!.prefix!));
168
+ const announced = accepted.length ? networks.filter((prefix) => accepted.includes(Address.canonical(prefix))) : networks;
169
+
170
+ const missing = announced.filter((prefix) => !inTable(prefix));
171
+ if (!missing.length) continue;
172
+
173
+ const where = `routing.bgp.neighbors.${name}`;
174
+ const route = (prefix: string) => `{ prefix: "${prefix}", blackhole: true, table: "${table}" }`;
175
+ const fix = `On RouterOS a session announces from its own table, ${table}`;
176
+
177
+ if (missing.length === announced.length) {
178
+ findings.push({
179
+ level: "error",
180
+ device: device.name,
181
+ 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`,
182
+ });
183
+ continue;
184
+ }
185
+
186
+ for (const prefix of missing) {
187
+ findings.push({
188
+ level: "warning",
189
+ device: device.name,
190
+ 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`,
191
+ });
192
+ }
193
+ }
194
+
195
+ return findings;
196
+ }
197
+
138
198
  unsupported(device: Device): string[] {
139
199
  const found: string[] = [];
140
200
 
@@ -427,13 +487,14 @@ export class RouterOs extends Platform {
427
487
  }
428
488
 
429
489
  /**
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`.
490
+ * 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
491
  */
432
492
  async health(device: Device, plan: Plan, options: HealthOptions = {}): Promise<HealthCheck> {
433
493
  const switchRules = plan.steps.some((step) => step.send.some((command) => command.startsWith(RouterOs.SWITCH_RULES)));
434
494
  const limits = options.bgp ?? BgpWait.AFTER_APPLY;
435
495
 
436
496
  const establishedBefore = await this.establishedSessions(device, options);
497
+ const announcedBefore = establishedBefore.length ? await this.announcedNow(device, options) : [];
437
498
 
438
499
  return {
439
500
  run: async () => {
@@ -447,7 +508,8 @@ export class RouterOs extends Platform {
447
508
  try {
448
509
  const switchProblems = switchRules ? await RouterOs.invalidSwitchRules(session, device) : [];
449
510
  const bgpProblems = await this.bgpBack(device, session, establishedBefore, limits);
450
- return [...switchProblems, ...bgpProblems];
511
+ const announceProblems = await this.announcedBack(device, session, announcedBefore, limits);
512
+ return [...switchProblems, ...bgpProblems, ...announceProblems];
451
513
  } finally {
452
514
  session.close();
453
515
  }
@@ -563,6 +625,72 @@ export class RouterOs extends Platform {
563
625
  );
564
626
  }
565
627
 
628
+ /** What each session announces of the device's own `networks`, as `neighbor prefix`. None, without logging in, on a device without networks. */
629
+ private async announcedNow(device: Device, options: ReadOptions): Promise<string[]> {
630
+ if (!device.routing?.bgp?.networks?.length) return [];
631
+
632
+ const session = await this.session(device, options);
633
+
634
+ try {
635
+ return await this.announced(device, session);
636
+ } finally {
637
+ session.close();
638
+ }
639
+ }
640
+
641
+ /** 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`. */
642
+ private async announced(device: Device, session: ReadSession): Promise<string[]> {
643
+ const bgp = device.routing?.bgp;
644
+ const networks = bgp?.networks ?? [];
645
+ if (!networks.length) return [];
646
+
647
+ const sessions = (await session.run(RouterOs.LIST_BGP_SESSIONS, 60_000))
648
+ .split(/\r?\n/)
649
+ .map((line) => line.trim().split("|"))
650
+ .filter((fields) => fields.length > 1 && fields[0] !== "disabled");
651
+ const neighbors = Object.entries(bgp?.neighbors ?? {});
652
+ const neighborOf = (sessionName: string) => {
653
+ const remote = sessions.find(([name]) => name === sessionName)?.[1];
654
+ const byAddress = neighbors.find(([, neighbor]) => neighbor.address === remote);
655
+ const byName = neighbors.find(([name]) => name === sessionName.replace(/-\d+$/, ""));
656
+ return (byAddress ?? byName)?.[0];
657
+ };
658
+
659
+ const output = await session.run(RouterOs.listAdvertisements(networks), 60_000);
660
+ return output
661
+ .split(/\r?\n/)
662
+ .map((line) => line.trim().split("|"))
663
+ .flatMap(([marker, peer, prefix]) => {
664
+ const neighbor = marker === "ADV" && peer ? neighborOf(peer) : undefined;
665
+ return neighbor && prefix ? [`${neighbor} ${prefix}`] : [];
666
+ })
667
+ .sort();
668
+ }
669
+
670
+ /** 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. */
671
+ private async announcedBack(device: Device, session: ReadSession, before: readonly string[], limits: WaitLimits): Promise<string[]> {
672
+ if (!before.length) return [];
673
+
674
+ const missing = (now: string[]) => before.filter((pair) => !now.includes(pair));
675
+ const after = await BgpWait.until(
676
+ () => this.announced(device, session),
677
+ (now) => !missing(now).length,
678
+ limits,
679
+ (now) => console.log(` BGP: ${missing(now).length} announcement(s) not back yet, checking again in ${limits.every} s`)
680
+ );
681
+
682
+ const gone = missing(after);
683
+ if (!gone.length) {
684
+ console.log(` BGP: every session announces what it announced before`);
685
+ return [];
686
+ }
687
+
688
+ return gone.map((pair) => {
689
+ const [neighbor, prefix] = pair.split(" ");
690
+ 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'`;
691
+ });
692
+ }
693
+
566
694
  /** A problem when any switch rule is invalid, saying what to do. */
567
695
  private static async invalidSwitchRules(session: ReadSession, device: Device): Promise<string[]> {
568
696
  const answer = (await session.run(RouterOs.COUNT_INVALID_SWITCH_RULES)).trim();