@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.
@@ -11,6 +11,9 @@ class Utils2 {
11
11
  }
12
12
  return result;
13
13
  }
14
+ static otherTable(table) {
15
+ return table === "main" ? undefined : table;
16
+ }
14
17
  static timeZoneOffset(zone) {
15
18
  const name = new Intl.DateTimeFormat("en-US", {
16
19
  timeZone: zone,
package/dist/cli/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  Utils2,
4
4
  Secrets2
5
- } from "../chunks/index-20kcjxjg.js";
5
+ } from "../chunks/index-3b343xva.js";
6
6
  import {
7
7
  Topology,
8
8
  AddressSpace,
@@ -549,7 +549,7 @@ class Derive {
549
549
  for (const route of routing?.static ?? []) {
550
550
  if (route.bfd && !has("static-route-bfd"))
551
551
  found.push(`routing.static ${route.prefix} bfd, BFD on a static route is ${unproved}`);
552
- if (route.table && !has("routing-tables"))
552
+ if (Utils2.otherTable(route.table) && !has("routing-tables"))
553
553
  found.push(`routing.static ${route.prefix} table, a routing table beside the main one is ${unproved}`);
554
554
  }
555
555
  if (!has("routing-tables")) {
@@ -560,11 +560,11 @@ class Derive {
560
560
  }
561
561
  const bgp = routing?.bgp;
562
562
  for (const [name, group] of Object.entries(bgp?.groups ?? {})) {
563
- if (group.table && !has("routing-tables"))
563
+ if (Utils2.otherTable(group.table) && !has("routing-tables"))
564
564
  found.push(`routing.bgp.groups.${name}.table, a routing table beside the main one is ${unproved}`);
565
565
  }
566
566
  for (const [name, neighbor] of Object.entries(bgp?.neighbors ?? {})) {
567
- if (neighbor.table && !has("routing-tables"))
567
+ if (Utils2.otherTable(neighbor.table) && !has("routing-tables"))
568
568
  found.push(`routing.bgp.neighbors.${name}.table, a routing table beside the main one is ${unproved}`);
569
569
  const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
570
570
  const bfd = neighbor.bfd ?? group?.bfd;
@@ -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;
@@ -8334,7 +8343,7 @@ class RouterOsRender {
8334
8343
  distance: route.distance,
8335
8344
  "dst-address": route.prefix,
8336
8345
  gateway,
8337
- "routing-table": route.vrf ?? route.table
8346
+ "routing-table": route.vrf ?? Utils2.otherTable(route.table)
8338
8347
  });
8339
8348
  }
8340
8349
  }
@@ -8455,7 +8464,7 @@ class RouterOsRender {
8455
8464
  "output.network": originates ? RouterOsRender.NETWORKS[family] : undefined,
8456
8465
  "remote.address": `${neighbor.address}/${addressFamily === "ipv4" ? 32 : 128}`,
8457
8466
  "remote.as": settings.remote_as,
8458
- "routing-table": settings.table,
8467
+ "routing-table": Utils2.otherTable(settings.table),
8459
8468
  "tcp-md5-key": settings.password && Secrets2.marker(settings.password),
8460
8469
  "use-bfd": settings.bfd ? "yes" : undefined
8461
8470
  });
@@ -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,45 @@ 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 = Utils2.otherTable(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 exportPolicy = neighbor.export ?? group?.export;
8742
+ const accepted = (exportPolicy ? device.policies?.[exportPolicy] ?? [] : []).filter((rule) => rule.action === "accept" && rule.match?.prefix).map((rule) => Address2.canonical(rule.match.prefix));
8743
+ const announced = accepted.length ? networks.filter((prefix) => accepted.includes(Address2.canonical(prefix))) : networks;
8744
+ const missing = announced.filter((prefix) => !inTable(prefix));
8745
+ if (!missing.length)
8746
+ continue;
8747
+ const where = `routing.bgp.neighbors.${name}`;
8748
+ const route = (prefix) => `{ prefix: "${prefix}", blackhole: true, table: "${table}" }`;
8749
+ const fix = `On RouterOS a session announces from its own table, ${table}`;
8750
+ if (missing.length === announced.length) {
8751
+ findings.push({
8752
+ level: "error",
8753
+ device: device.name,
8754
+ 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`
8755
+ });
8756
+ continue;
8757
+ }
8758
+ for (const prefix of missing) {
8759
+ findings.push({
8760
+ level: "warning",
8761
+ device: device.name,
8762
+ 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`
8763
+ });
8764
+ }
8765
+ }
8766
+ return findings;
8767
+ }
8716
8768
  unsupported(device) {
8717
8769
  const found = [];
8718
8770
  for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
@@ -8950,6 +9002,7 @@ ${certificates}` : "";
8950
9002
  const switchRules = plan.steps.some((step) => step.send.some((command) => command.startsWith(RouterOs.SWITCH_RULES)));
8951
9003
  const limits = options.bgp ?? BgpWait.AFTER_APPLY;
8952
9004
  const establishedBefore = await this.establishedSessions(device, options);
9005
+ const announcedBefore = establishedBefore.length ? await this.announcedNow(device, options) : [];
8953
9006
  return {
8954
9007
  run: async () => {
8955
9008
  if (!switchRules && !establishedBefore.length) {
@@ -8960,7 +9013,8 @@ ${certificates}` : "";
8960
9013
  try {
8961
9014
  const switchProblems = switchRules ? await RouterOs.invalidSwitchRules(session, device) : [];
8962
9015
  const bgpProblems = await this.bgpBack(device, session, establishedBefore, limits);
8963
- return [...switchProblems, ...bgpProblems];
9016
+ const announceProblems = await this.announcedBack(device, session, announcedBefore, limits);
9017
+ return [...switchProblems, ...bgpProblems, ...announceProblems];
8964
9018
  } finally {
8965
9019
  session.close();
8966
9020
  }
@@ -9030,6 +9084,50 @@ ${certificates}` : "";
9030
9084
  }
9031
9085
  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
9086
  }
9087
+ async announcedNow(device, options) {
9088
+ if (!device.routing?.bgp?.networks?.length)
9089
+ return [];
9090
+ const session = await this.session(device, options);
9091
+ try {
9092
+ return await this.announced(device, session);
9093
+ } finally {
9094
+ session.close();
9095
+ }
9096
+ }
9097
+ async announced(device, session) {
9098
+ const bgp = device.routing?.bgp;
9099
+ const networks = bgp?.networks ?? [];
9100
+ if (!networks.length)
9101
+ return [];
9102
+ 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");
9103
+ const neighbors = Object.entries(bgp?.neighbors ?? {});
9104
+ const neighborOf = (sessionName) => {
9105
+ const remote = sessions.find(([name]) => name === sessionName)?.[1];
9106
+ const byAddress = neighbors.find(([, neighbor]) => neighbor.address === remote);
9107
+ const byName = neighbors.find(([name]) => name === sessionName.replace(/-\d+$/, ""));
9108
+ return (byAddress ?? byName)?.[0];
9109
+ };
9110
+ const output = await session.run(RouterOs.listAdvertisements(networks), 60000);
9111
+ return output.split(/\r?\n/).map((line) => line.trim().split("|")).flatMap(([marker, peer, prefix]) => {
9112
+ const neighbor = marker === "ADV" && peer ? neighborOf(peer) : undefined;
9113
+ return neighbor && prefix ? [`${neighbor} ${prefix}`] : [];
9114
+ }).sort();
9115
+ }
9116
+ async announcedBack(device, session, before, limits) {
9117
+ if (!before.length)
9118
+ return [];
9119
+ const missing = (now) => before.filter((pair) => !now.includes(pair));
9120
+ 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`));
9121
+ const gone = missing(after);
9122
+ if (!gone.length) {
9123
+ console.log(` BGP: every session announces what it announced before`);
9124
+ return [];
9125
+ }
9126
+ return gone.map((pair) => {
9127
+ const [neighbor, prefix] = pair.split(" ");
9128
+ 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'`;
9129
+ });
9130
+ }
9033
9131
  static async invalidSwitchRules(session, device) {
9034
9132
  const answer = (await session.run(RouterOs.COUNT_INVALID_SWITCH_RULES)).trim();
9035
9133
  if (!/^\d+$/.test(answer))
@@ -11245,9 +11343,11 @@ class Validator {
11245
11343
  const adapter = Platforms.find(device.platform);
11246
11344
  if (!adapter)
11247
11345
  report(`no adapter for platform ${device.platform}`);
11248
- else
11346
+ else {
11249
11347
  for (const field of adapter.unsupported(device))
11250
11348
  report(`${device.platform} cannot express ${field}`);
11349
+ findings.push(...adapter.checks?.(device) ?? []);
11350
+ }
11251
11351
  }
11252
11352
  findings.push(...CrossChecks.run(network.devices));
11253
11353
  for (const check of network.checks ?? [])
@@ -11458,8 +11558,8 @@ class Validator {
11458
11558
  report(`static route ${route.prefix} has bfd without via, the address BFD watches`);
11459
11559
  names.expectInterface(`static route ${route.prefix} interface`, route.interface);
11460
11560
  names.expect(`static route ${route.prefix} VRF`, route.vrf, route.vrf !== undefined && route.vrf in (device.vrfs ?? {}));
11461
- names.expect(`static route ${route.prefix} table`, route.table, route.table !== undefined && route.table in (routing?.tables ?? {}));
11462
- if (route.vrf && route.table)
11561
+ names.expect(`static route ${route.prefix} table`, route.table, route.table !== undefined && (route.table === "main" || (route.table in (routing?.tables ?? {}))));
11562
+ if (route.vrf && Utils2.otherTable(route.table))
11463
11563
  report(`static route ${route.prefix} has both vrf and table; a route is in one of them`);
11464
11564
  }
11465
11565
  Validator.checkTables(device, names, report, cidr);
@@ -11467,7 +11567,7 @@ class Validator {
11467
11567
  const isPolicy = (policy) => policy !== undefined && (policy in policies);
11468
11568
  for (const prefix of bgp?.networks ?? [])
11469
11569
  cidr("BGP network", prefix);
11470
- const isTable = (table) => table !== undefined && (table in (routing?.tables ?? {}));
11570
+ const isTable = (table) => table !== undefined && (table === "main" || (table in (routing?.tables ?? {})));
11471
11571
  for (const [name, group] of Object.entries(bgp?.groups ?? {})) {
11472
11572
  for (const policy of [group.import, group.export]) {
11473
11573
  names.expect(`BGP group ${name} policy`, policy, isPolicy(policy));
@@ -11500,11 +11600,13 @@ class Validator {
11500
11600
  const tables = routing?.tables ?? {};
11501
11601
  const bgp = routing?.bgp;
11502
11602
  const installed = new Set;
11503
- for (const route of routing?.static ?? [])
11504
- if (route.table)
11505
- installed.add(route.table);
11603
+ for (const route of routing?.static ?? []) {
11604
+ const table = Utils2.otherTable(route.table);
11605
+ if (table)
11606
+ installed.add(table);
11607
+ }
11506
11608
  for (const neighbor of Object.values(bgp?.neighbors ?? {})) {
11507
- const table = neighbor.table ?? (neighbor.group ? bgp?.groups?.[neighbor.group]?.table : undefined);
11609
+ const table = Utils2.otherTable(neighbor.table ?? (neighbor.group ? bgp?.groups?.[neighbor.group]?.table : undefined));
11508
11610
  if (table)
11509
11611
  installed.add(table);
11510
11612
  }
@@ -147,22 +147,31 @@ class Bgp {
147
147
  action: "accept"
148
148
  }));
149
149
  };
150
- const passOnTo = (neighborAs) => scheme.blackhole?.upstreams?.[neighborAs];
151
- const blackholeExport = (neighborAs) => {
150
+ const asnOf = (neighbor) => typeof neighbor === "number" ? neighbor : neighbor.asn;
151
+ const passOnTo = (neighbor) => typeof neighbor === "number" ? undefined : neighbor.communities?.blackhole;
152
+ const blackholeExport = (neighbor) => {
152
153
  if (scheme.blackhole === undefined)
153
154
  return [];
154
- const passOn = passOnTo(neighborAs);
155
+ const passOn = passOnTo(neighbor);
155
156
  const match = { large_community: community(scheme.blackhole.function, 0) };
156
- return passOn ? [
157
+ if (!passOn)
158
+ return [{ description: "Blackholed here, never announced", match, action: "reject" }];
159
+ const large = passOn.split(":").length === 3;
160
+ return [
157
161
  {
158
- description: `Blackhole passed on to AS${neighborAs}`,
162
+ description: `Blackhole passed on to AS${asnOf(neighbor)}`,
159
163
  match,
160
- set: { remove_communities: ours.standard, remove_large_communities: ours.large, add_communities: [passOn] },
164
+ set: {
165
+ remove_communities: ours.standard,
166
+ remove_large_communities: ours.large,
167
+ ...large ? { add_large_communities: [passOn] } : { add_communities: [passOn] }
168
+ },
161
169
  action: "accept"
162
170
  }
163
- ] : [{ description: "Blackholed here, never announced", match, action: "reject" }];
171
+ ];
164
172
  };
165
- const rejectDoNotAnnounce = (neighborAs) => {
173
+ const rejectDoNotAnnounce = (neighbor) => {
174
+ const neighborAs = asnOf(neighbor);
166
175
  const fn = scheme.do_not_announce;
167
176
  if (fn === undefined)
168
177
  return [];
@@ -175,7 +184,8 @@ class Bgp {
175
184
  }
176
185
  ];
177
186
  };
178
- const prepends = (neighborAs) => Bgp.PREPENDS.flatMap(([name, times]) => {
187
+ const prepends = (neighbor) => Bgp.PREPENDS.flatMap(([name, times]) => {
188
+ const neighborAs = asnOf(neighbor);
179
189
  const fn = scheme.prepend?.[name];
180
190
  if (fn === undefined)
181
191
  return [];
@@ -191,7 +201,7 @@ class Bgp {
191
201
  description: "Our communities stay inside our network",
192
202
  set: { remove_communities: ours.standard, remove_large_communities: ours.large }
193
203
  });
194
- const actions = (neighborAs) => passOnTo(neighborAs) ? [...rejectDoNotAnnounce(neighborAs), ...blackholeExport(neighborAs), ...prepends(neighborAs), stripOurs()] : [...blackholeExport(neighborAs), ...rejectDoNotAnnounce(neighborAs), ...prepends(neighborAs), stripOurs()];
204
+ const actions = (neighbor) => passOnTo(neighbor) ? [...rejectDoNotAnnounce(neighbor), ...blackholeExport(neighbor), ...prepends(neighbor), stripOurs()] : [...blackholeExport(neighbor), ...rejectDoNotAnnounce(neighbor), ...prepends(neighbor), stripOurs()];
195
205
  const catalogue = [
196
206
  ...Object.entries(scheme.learned_from?.classes ?? {}).map(([name, value]) => ({
197
207
  community: written(scheme.learned_from.function, value),
@@ -216,7 +226,7 @@ class Bgp {
216
226
  ...scheme.blackhole !== undefined ? [
217
227
  {
218
228
  community: written(scheme.blackhole.function, 0),
219
- description: Object.keys(scheme.blackhole.upstreams ?? {}).length ? `Blackhole: dropped in AS${asn} and by its upstreams that take it` : `Blackhole: dropped in AS${asn}, not announced further`
229
+ description: `Blackhole: dropped in AS${asn}, and by the upstreams that take it`
220
230
  }
221
231
  ] : []
222
232
  ];
@@ -691,6 +701,28 @@ class Filters {
691
701
  }
692
702
  // src/helpers/upstreams.ts
693
703
  class Upstreams {
704
+ static catalog(catalog) {
705
+ return catalog;
706
+ }
707
+ static doNotAnnounceTo(upstream, asns, description) {
708
+ if (!asns.length)
709
+ return [];
710
+ const community = upstream.communities?.do_not_announce_to;
711
+ if (!community)
712
+ throw new Error(`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")}`);
713
+ const communities = asns.map(community);
714
+ const large = communities.filter((value) => value.split(":").length === 3);
715
+ const standard = communities.filter((value) => value.split(":").length === 2);
716
+ return [
717
+ {
718
+ description,
719
+ set: {
720
+ ...standard.length ? { add_communities: standard } : {},
721
+ ...large.length ? { add_large_communities: large } : {}
722
+ }
723
+ }
724
+ ];
725
+ }
694
726
  static define(catalog, options = {}) {
695
727
  const all = Object.entries(catalog);
696
728
  const nameOf = (key, upstream) => upstream.name ?? key;
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  Utils2,
3
3
  Secrets2
4
- } from "./chunks/index-20kcjxjg.js";
4
+ } from "./chunks/index-3b343xva.js";
5
5
  import {
6
6
  Address2
7
7
  } from "./chunks/index-n7w3tg5y.js";
@@ -2,6 +2,8 @@ type Merged<T extends readonly object[]> = T extends readonly [infer Head, ...in
2
2
  export declare class Utils {
3
3
  /** Combine records into one, such as a device's own interfaces and those a module derives for it. Unlike spreading them, a name two of them hold is an error rather than one silently replacing the other. */
4
4
  static merge<const T extends readonly Record<string, unknown>[]>(...records: T): Merged<T>;
5
+ /** The table a route or a BGP session is in, beside the main one: `undefined` for the main table, written `main` or left out. */
6
+ static otherTable(table: string | undefined): string | undefined;
5
7
  /** Minutes east of UTC an IANA zone is at now, for platforms that take an offset rather than a zone. */
6
8
  static timeZoneOffset(zone: string): number;
7
9
  }
@@ -82,15 +82,24 @@ export type CommunityScheme<Asn extends number = number, LearnedFrom extends num
82
82
  three_times?: number;
83
83
  };
84
84
  /**
85
- * 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`.
86
- *
87
- * `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.
85
+ * 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.
88
86
  */
89
87
  blackhole?: {
90
88
  function: number;
91
- upstreams?: Readonly<Record<number, StandardCommunity>>;
92
89
  };
93
90
  };
91
+ /** The communities another network offers us, as its NOC gives them: what it does with a route we send it. */
92
+ export type NeighborCommunities = {
93
+ /** It drops traffic to a route we send it with this community. */
94
+ blackhole?: StandardCommunity | LargeCommunity;
95
+ /** 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`. */
96
+ do_not_announce_to?: (asn: number) => StandardCommunity | LargeCommunity;
97
+ };
98
+ /** A neighbor an export goes to: its AS, or its AS and the communities it offers. */
99
+ export type ExportNeighbor = number | {
100
+ asn: number;
101
+ communities?: NeighborCommunities;
102
+ };
94
103
  /** The class names a scheme declares, or any name when it declares none. */
95
104
  type ClassName<Classes extends CommunityClasses> = {} extends Classes ? string : Extract<keyof Classes, string>;
96
105
  /** BGP policy: an import sanity policy, the shapes most imports and exports take, a community scheme, and helpers that read a device's BGP neighbors. */
@@ -153,10 +162,12 @@ export declare class Bgp {
153
162
  * 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.
154
163
  *
155
164
  * - `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`.
156
- * - `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.
157
- * - `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`.
158
- * - `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`.
159
- * - `prepends(neighborAs)`: prepends once, twice or three times toward this neighbor, where a route asks. Nothing without `prepend`.
165
+ * Each export helper takes the neighbor: its AS, or `{ asn, communities }` with the communities it offers, such as an `Upstreams.catalog` entry.
166
+ *
167
+ * - `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.
168
+ * - `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.
169
+ * - `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`.
170
+ * - `prepends(neighbor)`: prepends once, twice or three times toward this neighbor, where a route asks. Nothing without `prepend`.
160
171
  * - `stripOurs()`: one rule removing every community of yours, `Our communities stay inside our network`.
161
172
  * - `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.
162
173
  * - `catalogue`: every community, for `communities` in `defineNetwork`.
@@ -170,10 +181,10 @@ export declare class Bgp {
170
181
  site?: number;
171
182
  trusted?: boolean;
172
183
  }) => PolicyRule<never, never>;
173
- actions: (neighborAs: number) => PolicyRule<never, never>[];
174
- blackholeExport: (neighborAs: number) => PolicyRule<never, never>[];
175
- rejectDoNotAnnounce: (neighborAs: number) => PolicyRule<never, never>[];
176
- prepends: (neighborAs: number) => PolicyRule<never, never>[];
184
+ actions: (neighbor: ExportNeighbor) => PolicyRule<never, never>[];
185
+ blackholeExport: (neighbor: ExportNeighbor) => PolicyRule<never, never>[];
186
+ rejectDoNotAnnounce: (neighbor: ExportNeighbor) => PolicyRule<never, never>[];
187
+ prepends: (neighbor: ExportNeighbor) => PolicyRule<never, never>[];
177
188
  stripOurs: () => PolicyRule<never, never>;
178
189
  blackhole: <S extends string>(prefixSets: readonly S[]) => PolicyRule<never, S>[];
179
190
  catalogue: Community[];
@@ -1,5 +1,5 @@
1
1
  export { Bgp } from "./bgp.js";
2
- export type { AnnounceOptions, Announced, CommunityScheme, DefaultOnlyOptions, LearnOptions, SanityLengths } from "./bgp.js";
2
+ export type { AnnounceOptions, Announced, CommunityScheme, DefaultOnlyOptions, ExportNeighbor, LearnOptions, NeighborCommunities, SanityLengths, } from "./bgp.js";
3
3
  export { Cables } from "./cables.js";
4
4
  export type { FarEnd } from "./cables.js";
5
5
  export { Checks } from "./checks.js";
@@ -1,4 +1,5 @@
1
1
  import type { Exchange as ExchangeLan, Family, FilterRule, Gre, InterfaceAddress, IP, Neighbor, NeighborSettings, PolicyRule, Port, Secret, Vlan, VlanInterface } from "../schema/index.js";
2
+ import type { NeighborCommunities } from "./bgp.js";
2
3
  /** A route policy an upstream's sessions use: its name on the device, and its rules, in order. */
3
4
  export type UpstreamPolicy = {
4
5
  /** The policy's name, as the device carries it. */
@@ -67,7 +68,7 @@ export type Upstream = {
67
68
  families?: readonly Family[];
68
69
  password?: Secret;
69
70
  };
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. */
71
+ /** 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
72
  table?: string;
72
73
  /** 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
74
  limits?: {
@@ -80,6 +81,8 @@ export type Upstream = {
80
81
  export: UpstreamPolicy;
81
82
  /** Its VLAN is listed by `privateDrops()`, for an ACL that drops RFC 1918 sources arriving from it. Nothing else is done with it. */
82
83
  drop_private?: true;
84
+ /** 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. */
85
+ communities?: NeighborCommunities;
83
86
  };
84
87
  /** Upstreams by key. */
85
88
  export type UpstreamCatalog = Readonly<Record<string, Upstream>>;
@@ -145,14 +148,22 @@ type GroupName<C> = Values<{
145
148
  group: infer N extends string;
146
149
  } ? N : never;
147
150
  }>;
148
- /** Each routing table name. */
149
- type TableName<C> = Values<{
151
+ /** Each routing table name, other than main. */
152
+ type TableName<C> = Exclude<Values<{
150
153
  [K in keyof C]: C[K] extends {
151
154
  table: infer N extends string;
152
155
  } ? N : never;
153
- }>;
156
+ }>, "main">;
154
157
  /** A network's upstreams written once, and each device's part built from them. */
155
158
  export declare class Upstreams {
159
+ /**
160
+ * 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.
161
+ */
162
+ static catalog<const C extends UpstreamCatalog>(catalog: C & UpstreamCatalog): C;
163
+ /**
164
+ * 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.
165
+ */
166
+ static doNotAnnounceTo(upstream: Upstream, asns: readonly number[], description: string): PolicyRule<never, never>[];
156
167
  /**
157
168
  * 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:
158
169
  *
@@ -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. */
@@ -15,8 +15,8 @@ export type StaticRoute<I extends string, F extends string, T extends string = n
15
15
  distance?: number;
16
16
  /** The VRF the route is in. The main table when absent. */
17
17
  vrf?: F;
18
- /** The table the route is in, one of `routing.tables`. The main table when absent. A route has a `vrf` or a `table`, not both. */
19
- table?: T;
18
+ /** The table the route is in: `main`, or one of `routing.tables`. The main table when absent; write `main` to say so. A route has a `vrf` or a `table`, not both. */
19
+ table?: T | "main";
20
20
  /** Why the route exists. */
21
21
  description?: string;
22
22
  };
@@ -54,8 +54,8 @@ 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. */
58
- table?: T;
57
+ /** The table the routes learned are installed in: `main`, or one of `routing.tables`. The main table when absent; write `main` to say so. 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
+ table?: T | "main";
59
59
  };
60
60
  /** One BGP session. Settings it does not set come from its group. */
61
61
  export type Neighbor<P extends string, G extends string, T extends string = never> = NeighborSettings<P, T> & {