@takodotid/circuit 0.41.0 → 0.42.1

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.
Files changed (50) hide show
  1. package/dist/chunks/index-6wb41q67.js +62 -0
  2. package/dist/chunks/{index-k38bby43.js → index-yzw86rc8.js} +4 -62
  3. package/dist/chunks/{server-ggertnce.js → server-cxr9ynxc.js} +1 -1
  4. package/dist/cli/index.js +226 -55
  5. package/dist/helpers/index.js +14 -9
  6. package/dist/types/core/cabling.d.ts +0 -6
  7. package/dist/types/core/define.d.ts +1 -1
  8. package/dist/types/core/validator.d.ts +2 -0
  9. package/dist/types/helpers/cables.d.ts +14 -11
  10. package/dist/types/helpers/exchange.d.ts +7 -1
  11. package/dist/types/helpers/upstreams.d.ts +11 -3
  12. package/dist/types/platforms/bgp-wait.d.ts +2 -2
  13. package/dist/types/platforms/derive.d.ts +1 -1
  14. package/dist/types/platforms/fortios/routing.d.ts +2 -2
  15. package/dist/types/platforms/junos/bgp.d.ts +2 -2
  16. package/dist/types/platforms/platform.d.ts +3 -0
  17. package/dist/types/platforms/routeros/index.d.ts +14 -1
  18. package/dist/types/platforms/routeros/plan.d.ts +4 -0
  19. package/dist/types/platforms/routeros/render.d.ts +4 -0
  20. package/dist/types/schema/device.d.ts +4 -4
  21. package/dist/types/schema/interfaces.d.ts +1 -1
  22. package/dist/types/schema/routing.d.ts +43 -10
  23. package/package.json +1 -1
  24. package/src/cli/index.ts +26 -29
  25. package/src/core/cabling.ts +0 -30
  26. package/src/core/define.ts +3 -1
  27. package/src/core/validator.ts +64 -1
  28. package/src/helpers/cables.ts +20 -15
  29. package/src/helpers/exchange.ts +9 -3
  30. package/src/helpers/upstreams.ts +9 -4
  31. package/src/platforms/bgp-wait.ts +11 -11
  32. package/src/platforms/derive.ts +23 -1
  33. package/src/platforms/fortios/routing.ts +2 -2
  34. package/src/platforms/junos/bgp.ts +2 -2
  35. package/src/platforms/platform.ts +3 -0
  36. package/src/platforms/routeros/TRAPS.md +6 -0
  37. package/src/platforms/routeros/export.ts +1 -0
  38. package/src/platforms/routeros/index.ts +133 -4
  39. package/src/platforms/routeros/plan.ts +20 -1
  40. package/src/platforms/routeros/render.ts +28 -3
  41. package/src/schema/device.ts +4 -3
  42. package/src/schema/interfaces.ts +1 -1
  43. package/src/schema/routing.ts +46 -10
  44. package/templates/colocation/checks.ts +14 -11
  45. package/templates/colocation/edge-01.ts +2 -2
  46. package/templates/colocation/tenancy.ts +50 -71
  47. package/templates/colocation/tenants/acme.ts +10 -10
  48. package/templates/colocation/tenants/index.ts +2 -1
  49. package/templates/colocation/tor-01.ts +2 -2
  50. package/templates/project/AGENTS.md +15 -15
@@ -5,6 +5,7 @@
5
5
  import type { Client } from "ssh2";
6
6
  import { Address } from "../../core/address";
7
7
  import type { Neighbor } from "../../core/cabling";
8
+ import type { Finding } from "../../core/define";
8
9
  import { Secrets } from "../../core/secrets";
9
10
  import type { Device, Family } from "../../schema";
10
11
  import { SerialConsole, type ConsoleDialect, ConsoleShell } from "../../transport/console";
@@ -84,6 +85,12 @@ export class RouterOs extends Platform {
84
85
  ].join(" ");
85
86
 
86
87
  /** Open again the session from one address, only while it is closed on its limit. */
88
+ /** Each advertisement of these prefixes, one line each: `ADV|<session>|<prefix>`. One line, because a script sent over exec runs as one. */
89
+ private static listAdvertisements(prefixes: readonly string[]): string {
90
+ const where = prefixes.map((prefix) => `dst=${prefix}`).join(" or ");
91
+ return `:foreach a in=[/routing bgp advertisements print as-value where ${where}] do={ :put ("ADV|" . ($a->"peer") . "|" . ($a->"dst")) }`;
92
+ }
93
+
87
94
  private static clearLimit(address: string): string {
88
95
  return `/routing bgp session clear [find remote.address=${address} and limit-exceeded] flag=limit-exceeded`;
89
96
  }
@@ -135,6 +142,51 @@ export class RouterOs extends Platform {
135
142
 
136
143
  override readonly console = RouterOs.CONSOLE;
137
144
 
145
+ /**
146
+ * 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.
147
+ */
148
+ checks(device: Device): Finding[] {
149
+ const bgp = device.routing?.bgp;
150
+ const statics = device.routing?.static ?? [];
151
+ const findings: Finding[] = [];
152
+
153
+ for (const [name, neighbor] of Object.entries(bgp?.neighbors ?? {})) {
154
+ const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
155
+ const table = neighbor.table ?? group?.table;
156
+ if (!table) continue;
157
+
158
+ const families = neighbor.families ?? group?.families ?? [Address.family(neighbor.address)];
159
+ const networks = (bgp?.networks ?? []).filter((prefix) => families.includes(Address.family(Address.host(prefix))));
160
+ const inTable = (prefix: string) =>
161
+ statics.some((route) => route.table === table && Address.canonical(route.prefix) === Address.canonical(prefix));
162
+ const missing = networks.filter((prefix) => !inTable(prefix));
163
+ if (!missing.length) continue;
164
+
165
+ const where = `routing.bgp.neighbors.${name}`;
166
+ const route = (prefix: string) => `{ prefix: "${prefix}", blackhole: true, table: "${table}" }`;
167
+ const fix = `On RouterOS a session announces from its own table, ${table}`;
168
+
169
+ if (missing.length === networks.length) {
170
+ findings.push({
171
+ level: "error",
172
+ device: device.name,
173
+ 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`,
174
+ });
175
+ continue;
176
+ }
177
+
178
+ for (const prefix of missing) {
179
+ findings.push({
180
+ level: "warning",
181
+ device: device.name,
182
+ 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`,
183
+ });
184
+ }
185
+ }
186
+
187
+ return findings;
188
+ }
189
+
138
190
  unsupported(device: Device): string[] {
139
191
  const found: string[] = [];
140
192
 
@@ -207,7 +259,16 @@ export class RouterOs extends Platform {
207
259
  }
208
260
 
209
261
  // BFD on a static route and a MAC limit are refused above, for the reason the device gave, and an ethertype filter on a LAG as unproved.
210
- found.push(...Derive.unproved(device, ["bfd-timers", "multihop-bfd", "static-route-bfd", "mac-limit", "ethertype-filter"]));
262
+ found.push(
263
+ ...Derive.unproved(device, [
264
+ "bfd-timers",
265
+ "multihop-bfd",
266
+ "static-route-bfd",
267
+ "mac-limit",
268
+ "ethertype-filter",
269
+ "routing-tables",
270
+ ])
271
+ );
211
272
 
212
273
  return found;
213
274
  }
@@ -418,13 +479,14 @@ export class RouterOs extends Platform {
418
479
  }
419
480
 
420
481
  /**
421
- * 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`.
482
+ * 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.
422
483
  */
423
484
  async health(device: Device, plan: Plan, options: HealthOptions = {}): Promise<HealthCheck> {
424
485
  const switchRules = plan.steps.some((step) => step.send.some((command) => command.startsWith(RouterOs.SWITCH_RULES)));
425
486
  const limits = options.bgp ?? BgpWait.AFTER_APPLY;
426
487
 
427
488
  const establishedBefore = await this.establishedSessions(device, options);
489
+ const announcedBefore = establishedBefore.length ? await this.announcedNow(device, options) : [];
428
490
 
429
491
  return {
430
492
  run: async () => {
@@ -438,7 +500,8 @@ export class RouterOs extends Platform {
438
500
  try {
439
501
  const switchProblems = switchRules ? await RouterOs.invalidSwitchRules(session, device) : [];
440
502
  const bgpProblems = await this.bgpBack(device, session, establishedBefore, limits);
441
- return [...switchProblems, ...bgpProblems];
503
+ const announceProblems = await this.announcedBack(device, session, announcedBefore, limits);
504
+ return [...switchProblems, ...bgpProblems, ...announceProblems];
442
505
  } finally {
443
506
  session.close();
444
507
  }
@@ -549,11 +612,77 @@ export class RouterOs extends Platform {
549
612
 
550
613
  return down.map((bgp) =>
551
614
  bgp.limit
552
- ? `BGP session ${bgp.neighbor} was established before the apply and closed on its limit${bgp.limit.max !== undefined ? ` of ${bgp.limit.max} prefixes` : ""}: the neighbor sends more than it may. Find out why, then: circuit bgp ${device.name} ${bgp.neighbor} --clear-limit`
615
+ ? `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`
553
616
  : `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`
554
617
  );
555
618
  }
556
619
 
620
+ /** What each session announces of the device's own `networks`, as `neighbor prefix`. None, without logging in, on a device without networks. */
621
+ private async announcedNow(device: Device, options: ReadOptions): Promise<string[]> {
622
+ if (!device.routing?.bgp?.networks?.length) return [];
623
+
624
+ const session = await this.session(device, options);
625
+
626
+ try {
627
+ return await this.announced(device, session);
628
+ } finally {
629
+ session.close();
630
+ }
631
+ }
632
+
633
+ /** 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`. */
634
+ private async announced(device: Device, session: ReadSession): Promise<string[]> {
635
+ const bgp = device.routing?.bgp;
636
+ const networks = bgp?.networks ?? [];
637
+ if (!networks.length) return [];
638
+
639
+ const sessions = (await session.run(RouterOs.LIST_BGP_SESSIONS, 60_000))
640
+ .split(/\r?\n/)
641
+ .map((line) => line.trim().split("|"))
642
+ .filter((fields) => fields.length > 1 && fields[0] !== "disabled");
643
+ const neighbors = Object.entries(bgp?.neighbors ?? {});
644
+ const neighborOf = (sessionName: string) => {
645
+ const remote = sessions.find(([name]) => name === sessionName)?.[1];
646
+ const byAddress = neighbors.find(([, neighbor]) => neighbor.address === remote);
647
+ const byName = neighbors.find(([name]) => name === sessionName.replace(/-\d+$/, ""));
648
+ return (byAddress ?? byName)?.[0];
649
+ };
650
+
651
+ const output = await session.run(RouterOs.listAdvertisements(networks), 60_000);
652
+ return output
653
+ .split(/\r?\n/)
654
+ .map((line) => line.trim().split("|"))
655
+ .flatMap(([marker, peer, prefix]) => {
656
+ const neighbor = marker === "ADV" && peer ? neighborOf(peer) : undefined;
657
+ return neighbor && prefix ? [`${neighbor} ${prefix}`] : [];
658
+ })
659
+ .sort();
660
+ }
661
+
662
+ /** 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. */
663
+ private async announcedBack(device: Device, session: ReadSession, before: readonly string[], limits: WaitLimits): Promise<string[]> {
664
+ if (!before.length) return [];
665
+
666
+ const missing = (now: string[]) => before.filter((pair) => !now.includes(pair));
667
+ const after = await BgpWait.until(
668
+ () => this.announced(device, session),
669
+ (now) => !missing(now).length,
670
+ limits,
671
+ (now) => console.log(` BGP: ${missing(now).length} announcement(s) not back yet, checking again in ${limits.every} s`)
672
+ );
673
+
674
+ const gone = missing(after);
675
+ if (!gone.length) {
676
+ console.log(` BGP: every session announces what it announced before`);
677
+ return [];
678
+ }
679
+
680
+ return gone.map((pair) => {
681
+ const [neighbor, prefix] = pair.split(" ");
682
+ 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'`;
683
+ });
684
+ }
685
+
557
686
  /** A problem when any switch rule is invalid, saying what to do. */
558
687
  private static async invalidSwitchRules(session: ReadSession, device: Device): Promise<string[]> {
559
688
  const answer = (await session.run(RouterOs.COUNT_INVALID_SWITCH_RULES)).trim();
@@ -30,6 +30,9 @@ export class RouterOsPlan {
30
30
  "/interface bridge filter",
31
31
  "/interface list",
32
32
  "/ip vrf",
33
+ // Routes, rules and BGP connections name a table, so it exists first.
34
+ "/routing table",
35
+ "/routing rule",
33
36
  "/ip address",
34
37
  "/ipv6 address",
35
38
  "/ip pool",
@@ -80,6 +83,11 @@ export class RouterOsPlan {
80
83
  /** 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. */
81
84
  private static readonly CHANGED_IN_PLACE = new Set(["/interface vlan"]);
82
85
 
86
+ /**
87
+ * 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.
88
+ */
89
+ private static readonly RESTART_ON: Record<string, string[]> = { "/routing bgp connection": ["routing-table"] };
90
+
83
91
  /** The variable holding the old rules of an ordered menu while the new ones are added. Removed when the rewrite ends. */
84
92
  private static readonly OLD_RULES = "frameworkOldRules";
85
93
 
@@ -311,8 +319,19 @@ export class RouterOsPlan {
311
319
  const update = this.changes(entry, current, `${menu} ${entryKey}`);
312
320
  if (!Object.keys(update).length) continue;
313
321
 
322
+ const selector = RouterOsPlan.find(menu, RouterOsExport.identity(current));
323
+ const restartsOn = (RouterOsPlan.RESTART_ON[menu] ?? []).filter((property) => property in update);
324
+ if (restartsOn.length) {
325
+ changeStep.show.push(
326
+ `~ ${entryKey} ${RouterOsPlan.describe(update)}, restarting the session: it takes a new ${restartsOn.join(" and ")} only when it restarts`
327
+ );
328
+ changeStep.send.push(RouterOsPlan.line(menu, "set", selector, [], { disabled: "yes", ...update }));
329
+ changeStep.send.push(RouterOsPlan.line(menu, "set", selector, [], { disabled: "no" }));
330
+ continue;
331
+ }
332
+
314
333
  changeStep.show.push(`~ ${entryKey} ${RouterOsPlan.describe(update)}`);
315
- changeStep.send.push(RouterOsPlan.line(menu, "set", RouterOsPlan.find(menu, RouterOsExport.identity(current)), [], update));
334
+ changeStep.send.push(RouterOsPlan.line(menu, "set", selector, [], update));
316
335
  }
317
336
 
318
337
  const removed = existingEntries.filter((entry) => !kept.has(key(entry)));
@@ -32,7 +32,7 @@ export type AnyInterface = Interface<string, string>;
32
32
 
33
33
  type Speed = NonNullable<AnyPort["speed"]>;
34
34
  type AnyRule = PolicyRule<string, string>;
35
- type AnyNeighbor = Neighbor<string, string>;
35
+ type AnyNeighbor = Neighbor<string, string, string>;
36
36
 
37
37
  /** One device's render: the device, and the commands collected under their menu. */
38
38
  export class RouterOsRender {
@@ -1101,6 +1101,8 @@ export class RouterOsRender {
1101
1101
  }
1102
1102
 
1103
1103
  private routing(): void {
1104
+ this.routingTables();
1105
+ this.routingRules();
1104
1106
  this.staticRoutes();
1105
1107
  this.rpki();
1106
1108
  if (this.device.routing?.bgp) {
@@ -1113,6 +1115,28 @@ export class RouterOsRender {
1113
1115
  this.bfd();
1114
1116
  }
1115
1117
 
1118
+ /** A table beside the main one. `fib` puts its routes in the forwarding table, so traffic can be routed by them. */
1119
+ private routingTables(): void {
1120
+ for (const [name, table] of Object.entries(this.device.routing?.tables ?? {})) {
1121
+ this.add("/routing table", "add fib", { comment: table.description, name });
1122
+ }
1123
+ }
1124
+
1125
+ /** Rules in the order written. `lookup-only-in-table` drops what the table has no route for, and `lookup` falls back to the main table. */
1126
+ private routingRules(): void {
1127
+ for (const rule of this.device.routing?.rules ?? []) {
1128
+ const onlyInTable = rule.table !== "main" && (rule.fallback ?? "none") === "none";
1129
+ this.add("/routing rule", "add", {
1130
+ action: onlyInTable ? "lookup-only-in-table" : "lookup",
1131
+ comment: rule.description,
1132
+ "dst-address": rule.match?.dst,
1133
+ interface: rule.match?.in_interface && this.name(rule.match.in_interface),
1134
+ "src-address": rule.match?.src,
1135
+ table: rule.table,
1136
+ });
1137
+ }
1138
+ }
1139
+
1116
1140
  private staticRoutes(): void {
1117
1141
  for (const route of this.device.routing?.static ?? []) {
1118
1142
  const menu = Address.family(Address.host(route.prefix)) === "ipv4" ? "/ip route" : "/ipv6 route";
@@ -1123,7 +1147,7 @@ export class RouterOsRender {
1123
1147
  distance: route.distance,
1124
1148
  "dst-address": route.prefix,
1125
1149
  gateway,
1126
- "routing-table": route.vrf,
1150
+ "routing-table": route.vrf ?? route.table,
1127
1151
  });
1128
1152
  }
1129
1153
  }
@@ -1146,7 +1170,7 @@ export class RouterOsRender {
1146
1170
  }
1147
1171
 
1148
1172
  /** A neighbor's settings with its group's beneath them. */
1149
- private settingsOf(neighbor: AnyNeighbor): NeighborSettings<string> {
1173
+ private settingsOf(neighbor: AnyNeighbor): NeighborSettings<string, string> {
1150
1174
  const group = neighbor.group ? this.device.routing?.bgp?.groups?.[neighbor.group] : undefined;
1151
1175
  return { ...group, ...neighbor };
1152
1176
  }
@@ -1268,6 +1292,7 @@ export class RouterOsRender {
1268
1292
  "output.network": originates ? RouterOsRender.NETWORKS[family] : undefined,
1269
1293
  "remote.address": `${neighbor.address}/${addressFamily === "ipv4" ? 32 : 128}`,
1270
1294
  "remote.as": settings.remote_as,
1295
+ "routing-table": settings.table,
1271
1296
  "tcp-md5-key": settings.password && Secrets.marker(settings.password),
1272
1297
  "use-bfd": settings.bfd ? "yes" : undefined,
1273
1298
  });
@@ -71,7 +71,7 @@ export type Vrf = {
71
71
  /**
72
72
  * Everything one device runs. What is not written here is removed from the device or returned to its default.
73
73
  *
74
- * Type parameters are the names the device declares, so a reference to a name that does not exist fails to compile: N ports, V VLANs, I interfaces, P policies, S prefix sets, G BGP groups, A ACLs, AS address sets, U users, F VRFs, C certificates.
74
+ * Type parameters are the names the device declares, so a reference to a name that does not exist fails to compile: N ports, V VLANs, I interfaces, P policies, S prefix sets, G BGP groups, A ACLs, AS address sets, U users, F VRFs, C certificates, T routing tables.
75
75
  */
76
76
  export type Device<
77
77
  Pl extends string = string,
@@ -87,6 +87,7 @@ export type Device<
87
87
  U extends string = string,
88
88
  F extends string = string,
89
89
  C extends string = string,
90
+ T extends string = string,
90
91
  > = {
91
92
  /** Hostname. */
92
93
  name: string;
@@ -112,8 +113,8 @@ export type Device<
112
113
  interfaces?: Record<I, Interface<NoInfer<V>, NoInfer<F>>>;
113
114
  /** Separate routing tables, keyed by name. */
114
115
  vrfs?: Record<F, Vrf>;
115
- /** Static routes, BGP, OSPF and route validation. */
116
- routing?: Routing<NoInfer<P>, G, NoInfer<I | N>, NoInfer<F>>;
116
+ /** Routing tables and rules, static routes, BGP, OSPF and route validation. */
117
+ routing?: Routing<NoInfer<P>, G, NoInfer<I | N>, NoInfer<F>, T>;
117
118
  /** Route policies, keyed by name. */
118
119
  policies?: Record<P, RoutePolicy<NoInfer<P>, NoInfer<S>>>;
119
120
  /** Prefix sets that policies match on, keyed by name. */
@@ -113,7 +113,7 @@ export type Port<V extends string, A extends string, L extends string, F extends
113
113
  acl?: A;
114
114
  /** The LAG this port is in. Every other setting goes on the LAG. */
115
115
  lag?: L;
116
- /** Another of our devices at the other end of the cable. Without a `description`, the port is described from it: the far device and the far port as printed on it, such as `firewall-1 port6`. */
116
+ /** Another of our devices at the other end of the cable. */
117
117
  link?: Link;
118
118
  };
119
119
 
@@ -1,7 +1,7 @@
1
1
  import type { Family, IP, Prefix, Secret } from "./common";
2
2
 
3
3
  /** A route the device has without learning it. */
4
- export type StaticRoute<I extends string, F extends string> = {
4
+ export type StaticRoute<I extends string, F extends string, T extends string = never> = {
5
5
  /** Destination. */
6
6
  prefix: Prefix;
7
7
  /** Next-hop address. */
@@ -14,8 +14,10 @@ export type StaticRoute<I extends string, F extends string> = {
14
14
  bfd?: true;
15
15
  /** Preference against other routes to the same prefix. Lower wins. 1 when absent. */
16
16
  distance?: number;
17
- /** The VRF the route is in. The default table when absent. */
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
21
  /** Why the route exists. */
20
22
  description?: string;
21
23
  };
@@ -26,7 +28,7 @@ export type StaticRoute<I extends string, F extends string> = {
26
28
  export type BgpRole = "provider" | "customer" | "peer" | "rs" | "rs-client";
27
29
 
28
30
  /** Settings a BGP neighbor can set itself or take from its group. */
29
- export type NeighborSettings<P extends string> = {
31
+ export type NeighborSettings<P extends string, T extends string = never> = {
30
32
  /** The neighbor's AS. Our own AS makes the session internal. */
31
33
  remote_as?: number;
32
34
  /** Who the neighbor is. */
@@ -55,10 +57,12 @@ export type NeighborSettings<P extends string> = {
55
57
  passive?: boolean;
56
58
  /** Detect a dead link in under a second with BFD. */
57
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;
58
62
  };
59
63
 
60
64
  /** One BGP session. Settings it does not set come from its group. */
61
- export type Neighbor<P extends string, G extends string> = NeighborSettings<P> & {
65
+ export type Neighbor<P extends string, G extends string, T extends string = never> = NeighborSettings<P, T> & {
62
66
  /** The neighbor's address. */
63
67
  address: IP;
64
68
  /** The group this neighbor takes a setting from when it does not set that setting itself. */
@@ -66,15 +70,15 @@ export type Neighbor<P extends string, G extends string> = NeighborSettings<P> &
66
70
  };
67
71
 
68
72
  /** BGP on this device. */
69
- export type Bgp<P extends string, G extends string> = {
73
+ export type Bgp<P extends string, G extends string, T extends string = never> = {
70
74
  /** Our AS number. */
71
75
  asn: number;
72
76
  /** Prefixes this device originates. Each needs a route, for example a static blackhole. Export policy still decides who receives them. */
73
77
  networks?: readonly Prefix[];
74
78
  /** Settings shared by several neighbors, keyed by group name. */
75
- groups?: Record<G, NeighborSettings<P>>;
79
+ groups?: Record<G, NeighborSettings<P, T>>;
76
80
  /** Sessions keyed by name. */
77
- neighbors?: Record<string, Neighbor<P, G>>;
81
+ neighbors?: Record<string, Neighbor<P, G, T>>;
78
82
  };
79
83
 
80
84
  /** How fast BFD notices a dead neighbor: after `multiplier` packets in a row are missed, so in `interval` times `multiplier` milliseconds. */
@@ -120,14 +124,46 @@ export type Ospf<I extends string> = {
120
124
  redistribute?: readonly ("connected" | "static")[];
121
125
  };
122
126
 
127
+ /** A routing table beside the main one. It holds no interfaces: routes go in from static routes and BGP neighbors that name it in `table`, and `routing.rules` send traffic to it. */
128
+ export type RoutingTable = {
129
+ /** What the table is for. */
130
+ description?: string;
131
+ };
132
+
133
+ /** Traffic a routing rule matches: all of what is given. Every packet when absent. */
134
+ export type RoutingRuleMatch<I extends string> = {
135
+ /** Source prefix. */
136
+ src?: Prefix;
137
+ /** Destination prefix. */
138
+ dst?: Prefix;
139
+ /** The interface the traffic arrives on. */
140
+ in_interface?: I;
141
+ };
142
+
143
+ /** One routing rule: traffic it matches is routed by `table` instead of the main table. */
144
+ export type RoutingRule<I extends string, T extends string> = {
145
+ /** Why the rule exists. */
146
+ description?: string;
147
+ /** What the traffic must match. Every packet when absent, so such a rule is the last one. */
148
+ match?: RoutingRuleMatch<I>;
149
+ /** The table that routes the traffic: one of `routing.tables`, or `main`. */
150
+ table: T | "main";
151
+ /** When `table` has no route for a packet: `none` drops it as unroutable, `main` routes it by the main table instead. `none` when absent. Not for a rule whose table is `main`. */
152
+ fallback?: "none" | "main";
153
+ };
154
+
123
155
  /** Routing on this device. */
124
- export type Routing<P extends string, G extends string, I extends string, F extends string> = {
156
+ export type Routing<P extends string, G extends string, I extends string, F extends string, T extends string = never> = {
125
157
  /** Identifies this router to its neighbors. */
126
158
  router_id?: IP;
159
+ /** Routing tables beside the main one, keyed by name. Not `main`, and not the name of a VRF. */
160
+ tables?: Record<T, RoutingTable>;
161
+ /** Rules that route matching traffic by another table, such as traffic from one block by its own transits' default. In order: the first that matches decides, and traffic no rule matches is routed by the main table. */
162
+ rules?: readonly RoutingRule<I, NoInfer<T>>[];
127
163
  /** Routes the device has without learning them. */
128
- static?: readonly StaticRoute<I, F>[];
164
+ static?: readonly StaticRoute<I, F, NoInfer<T>>[];
129
165
  /** BGP sessions and what they exchange. */
130
- bgp?: Bgp<P, G>;
166
+ bgp?: Bgp<P, G, NoInfer<T>>;
131
167
  /** OSPF areas and interfaces. */
132
168
  ospf?: Ospf<I>;
133
169
  /** Validators answering route origin validation. */
@@ -3,6 +3,7 @@
3
3
  import type { Check, Finding } from "@takodotid/circuit";
4
4
  import { Address } from "@takodotid/circuit";
5
5
  import { Checks } from "@takodotid/circuit/helpers";
6
+ import { blocksOf, type Tenant } from "./tenancy";
6
7
  import { TENANTS } from "./tenants";
7
8
 
8
9
  export default [
@@ -15,21 +16,27 @@ export default [
15
16
  // A native VLAN stays on one trunk and nowhere else, or it joins every trunk it is native on.
16
17
  Checks.nativeVlansAreDeadEnds,
17
18
 
18
- // Tenants never share a name, a number, an address or a port. A port given twice would go to the last tenant alone.
19
+ // Tenants never share a name, a VLAN, an address or a port. A port given twice would go to the last tenant alone.
19
20
  () => {
20
21
  const findings: Finding[] = [];
21
22
  const error = (message: string) => findings.push({ level: "error", message });
23
+ const vlansOf = (t: Tenant) => [t.internet.vlan, ...(t.private ? [t.private.vlan] : [])];
22
24
 
23
25
  TENANTS.forEach((t, index) => {
24
26
  for (const other of TENANTS.slice(index + 1)) {
25
27
  if (other.name === t.name) error(`tenant ${t.name} is declared twice`);
26
- if (other.number === t.number) error(`tenants ${t.name} and ${other.name} share number ${t.number}`);
27
28
 
28
- for (const block of t.blocks) {
29
- const clash = other.blocks.find(
30
- ({ prefix }) => Address.contains(prefix, block.prefix) || Address.contains(block.prefix, prefix)
31
- );
32
- if (clash) error(`${block.prefix} of ${t.name} overlaps ${clash.prefix} of ${other.name}`);
29
+ for (const vlan of vlansOf(t)) {
30
+ for (const theirs of vlansOf(other)) {
31
+ if (theirs.id === vlan.id)
32
+ error(`VLAN ${vlan.id} is both ${vlan.name} of ${t.name} and ${theirs.name} of ${other.name}`);
33
+ if (theirs.name === vlan.name) error(`VLAN ${vlan.name} is declared by both ${t.name} and ${other.name}`);
34
+ }
35
+ }
36
+
37
+ for (const block of blocksOf(t)) {
38
+ const clash = blocksOf(other).find((prefix) => Address.contains(prefix, block) || Address.contains(block, prefix));
39
+ if (clash) error(`${block} of ${t.name} overlaps ${clash} of ${other.name}`);
33
40
  }
34
41
  }
35
42
 
@@ -39,10 +46,6 @@ export default [
39
46
  if (sharer) error(`port ${port} on ${device} is given to both ${t.name} and ${sharer.name}`);
40
47
  }
41
48
  }
42
-
43
- for (const { prefix, gateway } of t.blocks) {
44
- if (!Address.contains(prefix, gateway)) error(`the gateway ${gateway} of ${t.name} is outside ${prefix}`);
45
- }
46
49
  });
47
50
 
48
51
  return findings;
@@ -6,7 +6,7 @@ import type { Prefix } from "@takodotid/circuit";
6
6
  import { defineDevice, secret, Utils } from "@takodotid/circuit";
7
7
  import { Bgp, Filters } from "@takodotid/circuit/helpers";
8
8
  import { ASN, communities } from "./routing";
9
- import { edgeRules, gateways, internetVlanNames, internetVlans } from "./tenancy";
9
+ import { edgeRules, gateways, internetVlanNames, routerVlans } from "./tenancy";
10
10
  import { TENANTS } from "./tenants";
11
11
 
12
12
  /** The address space this router announces. */
@@ -34,7 +34,7 @@ export default defineDevice({
34
34
  transit: { id: 300, description: "IP transit from Hurricane Electric, AS6939" },
35
35
  ix: { id: 200, description: "Peering LAN of SGIX" },
36
36
  },
37
- internetVlans(TENANTS)
37
+ routerVlans(TENANTS)
38
38
  ),
39
39
 
40
40
  ports: {