@takodotid/circuit 0.42.1 → 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;
@@ -8343,7 +8343,7 @@ class RouterOsRender {
8343
8343
  distance: route.distance,
8344
8344
  "dst-address": route.prefix,
8345
8345
  gateway,
8346
- "routing-table": route.vrf ?? route.table
8346
+ "routing-table": route.vrf ?? Utils2.otherTable(route.table)
8347
8347
  });
8348
8348
  }
8349
8349
  }
@@ -8464,7 +8464,7 @@ class RouterOsRender {
8464
8464
  "output.network": originates ? RouterOsRender.NETWORKS[family] : undefined,
8465
8465
  "remote.address": `${neighbor.address}/${addressFamily === "ipv4" ? 32 : 128}`,
8466
8466
  "remote.as": settings.remote_as,
8467
- "routing-table": settings.table,
8467
+ "routing-table": Utils2.otherTable(settings.table),
8468
8468
  "tcp-md5-key": settings.password && Secrets2.marker(settings.password),
8469
8469
  "use-bfd": settings.bfd ? "yes" : undefined
8470
8470
  });
@@ -8732,19 +8732,22 @@ class RouterOs extends Platform {
8732
8732
  const findings = [];
8733
8733
  for (const [name, neighbor] of Object.entries(bgp?.neighbors ?? {})) {
8734
8734
  const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
8735
- const table = neighbor.table ?? group?.table;
8735
+ const table = Utils2.otherTable(neighbor.table ?? group?.table);
8736
8736
  if (!table)
8737
8737
  continue;
8738
8738
  const families = neighbor.families ?? group?.families ?? [Address2.family(neighbor.address)];
8739
8739
  const networks = (bgp?.networks ?? []).filter((prefix) => families.includes(Address2.family(Address2.host(prefix))));
8740
8740
  const inTable = (prefix) => statics.some((route) => route.table === table && Address2.canonical(route.prefix) === Address2.canonical(prefix));
8741
- const missing = networks.filter((prefix) => !inTable(prefix));
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));
8742
8745
  if (!missing.length)
8743
8746
  continue;
8744
8747
  const where = `routing.bgp.neighbors.${name}`;
8745
8748
  const route = (prefix) => `{ prefix: "${prefix}", blackhole: true, table: "${table}" }`;
8746
8749
  const fix = `On RouterOS a session announces from its own table, ${table}`;
8747
- if (missing.length === networks.length) {
8750
+ if (missing.length === announced.length) {
8748
8751
  findings.push({
8749
8752
  level: "error",
8750
8753
  device: device.name,
@@ -11555,8 +11558,8 @@ class Validator {
11555
11558
  report(`static route ${route.prefix} has bfd without via, the address BFD watches`);
11556
11559
  names.expectInterface(`static route ${route.prefix} interface`, route.interface);
11557
11560
  names.expect(`static route ${route.prefix} VRF`, route.vrf, route.vrf !== undefined && route.vrf in (device.vrfs ?? {}));
11558
- names.expect(`static route ${route.prefix} table`, route.table, route.table !== undefined && route.table in (routing?.tables ?? {}));
11559
- 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))
11560
11563
  report(`static route ${route.prefix} has both vrf and table; a route is in one of them`);
11561
11564
  }
11562
11565
  Validator.checkTables(device, names, report, cidr);
@@ -11564,7 +11567,7 @@ class Validator {
11564
11567
  const isPolicy = (policy) => policy !== undefined && (policy in policies);
11565
11568
  for (const prefix of bgp?.networks ?? [])
11566
11569
  cidr("BGP network", prefix);
11567
- const isTable = (table) => table !== undefined && (table in (routing?.tables ?? {}));
11570
+ const isTable = (table) => table !== undefined && (table === "main" || (table in (routing?.tables ?? {})));
11568
11571
  for (const [name, group] of Object.entries(bgp?.groups ?? {})) {
11569
11572
  for (const policy of [group.import, group.export]) {
11570
11573
  names.expect(`BGP group ${name} policy`, policy, isPolicy(policy));
@@ -11597,11 +11600,13 @@ class Validator {
11597
11600
  const tables = routing?.tables ?? {};
11598
11601
  const bgp = routing?.bgp;
11599
11602
  const installed = new Set;
11600
- for (const route of routing?.static ?? [])
11601
- if (route.table)
11602
- 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
+ }
11603
11608
  for (const neighbor of Object.values(bgp?.neighbors ?? {})) {
11604
- 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));
11605
11610
  if (table)
11606
11611
  installed.add(table);
11607
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. On RouterOS the sessions also announce from that table, so what they announce needs a route there too. */
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
  *
@@ -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. 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;
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> & {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takodotid/circuit",
3
- "version": "0.42.1",
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";
@@ -297,9 +298,10 @@ export class Validator {
297
298
  names.expect(
298
299
  `static route ${route.prefix} table`,
299
300
  route.table,
300
- route.table !== undefined && route.table in (routing?.tables ?? {})
301
+ route.table !== undefined && (route.table === "main" || route.table in (routing?.tables ?? {}))
301
302
  );
302
- 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`);
303
305
  }
304
306
 
305
307
  Validator.checkTables(device, names, report, cidr);
@@ -309,7 +311,7 @@ export class Validator {
309
311
 
310
312
  for (const prefix of bgp?.networks ?? []) cidr("BGP network", prefix);
311
313
 
312
- 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 ?? {}));
313
315
 
314
316
  for (const [name, group] of Object.entries(bgp?.groups ?? {})) {
315
317
  for (const policy of [group.import, group.export]) {
@@ -351,9 +353,12 @@ export class Validator {
351
353
  const bgp = routing?.bgp;
352
354
 
353
355
  const installed = new Set<string>();
354
- 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
+ }
355
360
  for (const neighbor of Object.values(bgp?.neighbors ?? {})) {
356
- 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));
357
362
  if (table) installed.add(table);
358
363
  }
359
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. On RouterOS the sessions also announce from that table, so what they announce needs a route there too. */
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,
@@ -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;
@@ -3,6 +3,7 @@
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";
8
9
  import type { Finding } from "../../core/define";
@@ -152,21 +153,28 @@ export class RouterOs extends Platform {
152
153
 
153
154
  for (const [name, neighbor] of Object.entries(bgp?.neighbors ?? {})) {
154
155
  const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
155
- const table = neighbor.table ?? group?.table;
156
+ const table = Utils.otherTable(neighbor.table ?? group?.table);
156
157
  if (!table) continue;
157
158
 
158
159
  const families = neighbor.families ?? group?.families ?? [Address.family(neighbor.address)];
159
160
  const networks = (bgp?.networks ?? []).filter((prefix) => families.includes(Address.family(Address.host(prefix))));
160
161
  const inTable = (prefix: string) =>
161
162
  statics.some((route) => route.table === table && Address.canonical(route.prefix) === Address.canonical(prefix));
162
- const missing = networks.filter((prefix) => !inTable(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));
163
171
  if (!missing.length) continue;
164
172
 
165
173
  const where = `routing.bgp.neighbors.${name}`;
166
174
  const route = (prefix: string) => `{ prefix: "${prefix}", blackhole: true, table: "${table}" }`;
167
175
  const fix = `On RouterOS a session announces from its own table, ${table}`;
168
176
 
169
- if (missing.length === networks.length) {
177
+ if (missing.length === announced.length) {
170
178
  findings.push({
171
179
  level: "error",
172
180
  device: device.name,
@@ -1,6 +1,7 @@
1
1
  // The neutral model as a RouterOS 7 export, written in the form `/export` prints so the two compare line for line.
2
2
 
3
3
  import { X509Certificate } from "node:crypto";
4
+ import { Utils } from "../../core/utils";
4
5
  import { Address } from "../../core/address";
5
6
  import { Secrets } from "../../core/secrets";
6
7
  import type {
@@ -1147,7 +1148,7 @@ export class RouterOsRender {
1147
1148
  distance: route.distance,
1148
1149
  "dst-address": route.prefix,
1149
1150
  gateway,
1150
- "routing-table": route.vrf ?? route.table,
1151
+ "routing-table": route.vrf ?? Utils.otherTable(route.table),
1151
1152
  });
1152
1153
  }
1153
1154
  }
@@ -1292,7 +1293,7 @@ export class RouterOsRender {
1292
1293
  "output.network": originates ? RouterOsRender.NETWORKS[family] : undefined,
1293
1294
  "remote.address": `${neighbor.address}/${addressFamily === "ipv4" ? 32 : 128}`,
1294
1295
  "remote.as": settings.remote_as,
1295
- "routing-table": settings.table,
1296
+ "routing-table": Utils.otherTable(settings.table),
1296
1297
  "tcp-md5-key": settings.password && Secrets.marker(settings.password),
1297
1298
  "use-bfd": settings.bfd ? "yes" : undefined,
1298
1299
  });
@@ -16,8 +16,8 @@ export type StaticRoute<I extends string, F extends string, T extends string = n
16
16
  distance?: number;
17
17
  /** The VRF the route is in. The main table when absent. */
18
18
  vrf?: F;
19
- /** 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. */
20
- table?: T;
19
+ /** 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. */
20
+ table?: T | "main";
21
21
  /** Why the route exists. */
22
22
  description?: string;
23
23
  };
@@ -57,8 +57,8 @@ export type NeighborSettings<P extends string, T extends string = never> = {
57
57
  passive?: boolean;
58
58
  /** Detect a dead link in under a second with BFD. */
59
59
  bfd?: boolean;
60
- /** The table the routes learned are installed in, one of `routing.tables`. The main table when absent. On RouterOS the session also announces from that table, so each prefix of `networks` it announces needs a route in the table too, and a running session takes a new table only when it restarts. */
61
- table?: T;
60
+ /** 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. */
61
+ table?: T | "main";
62
62
  };
63
63
 
64
64
  /** One BGP session. Settings it does not set come from its group. */