@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
@@ -0,0 +1,62 @@
1
+ // src/platforms/fortios/models.ts
2
+ var models = {
3
+ "FG-3700D": {
4
+ ports: { "40g": 4, "10g": 28, "1g": 2 },
5
+ spelling: { "40g": "port{n}", "10g": "port{n+4}", "1g": "mgmt{n}" }
6
+ }
7
+ };
8
+
9
+ // src/platforms/junos/models.ts
10
+ var models2 = {
11
+ "QFX5100-48S-6Q": {
12
+ ports: { "40g": 6, "10g": 48, "1g": 2 },
13
+ spelling: { "40g": "et-0/0/{n+47}", "10g": "xe-0/0/{n-1}", "1g": "em{n-1}" }
14
+ }
15
+ };
16
+
17
+ // src/platforms/raisecom-ros/models.ts
18
+ var models3 = {
19
+ "RAX721-C-6C48": {
20
+ ports: { "100g": 6, "25g": 24, "10g": 24 },
21
+ spelling: { "100g": "hundredgige 1/1/{n}", "25g": "twenty-fivegige 1/2/{n}", "10g": "tengigabitethernet 1/3/{n}" }
22
+ }
23
+ };
24
+
25
+ // src/platforms/routeros/models.ts
26
+ var models4 = {
27
+ "CCR2216-1G-12XS-2XQ": {
28
+ ports: { "100g": 2, "25g": 12, "1g": 1 },
29
+ spelling: { "100g": "qsfp28-{n}-1", "25g": "sfp28-{n}", "1g": "ether{n}" },
30
+ limits: { lag_members: 8, switch_port_ranges: 5 }
31
+ }
32
+ };
33
+
34
+ // src/platforms/vrp/models.ts
35
+ var models5 = {
36
+ "CE6855-48S6Q-HI": { ports: { "10g": 48, "40g": 6 }, spelling: { "10g": "10GE1/0/{n}", "40g": "40GE1/0/{n}" } }
37
+ };
38
+
39
+ // src/platforms/catalog.ts
40
+ class Catalog {
41
+ static MODELS = { routeros: models4, vrp: models5, "raisecom-ros": models3, junos: models2, fortios: models };
42
+ static ports(platform, model) {
43
+ const spec = Catalog.MODELS[platform]?.[model];
44
+ if (!spec)
45
+ throw new Error(`${platform} has no model ${model}`);
46
+ return Object.entries(spec.ports).flatMap(([speed, count]) => Array.from({ length: count }, (_, i) => `${speed}-${i + 1}`));
47
+ }
48
+ static limits(platform, model) {
49
+ const spec = Catalog.MODELS[platform]?.[model];
50
+ return spec?.limits ?? {};
51
+ }
52
+ static spell(platform, model, port) {
53
+ const spec = Catalog.MODELS[platform]?.[model];
54
+ const parts = /^(\d+[gm])-(\d+)$/.exec(port);
55
+ const template = parts && spec?.spelling[parts[1]];
56
+ if (!template)
57
+ throw new Error(`${model} has no port ${port}`);
58
+ return template.replace(/\{n([+-]\d+)?\}/, (_, shift) => String(Number(parts[2]) + Number(shift ?? 0)));
59
+ }
60
+ }
61
+
62
+ export { Catalog };
@@ -1,68 +1,10 @@
1
+ import {
2
+ Catalog
3
+ } from "./index-6wb41q67.js";
1
4
  import {
2
5
  Address2
3
6
  } from "./index-n7w3tg5y.js";
4
7
 
5
- // src/platforms/fortios/models.ts
6
- var models = {
7
- "FG-3700D": {
8
- ports: { "40g": 4, "10g": 28, "1g": 2 },
9
- spelling: { "40g": "port{n}", "10g": "port{n+4}", "1g": "mgmt{n}" }
10
- }
11
- };
12
-
13
- // src/platforms/junos/models.ts
14
- var models2 = {
15
- "QFX5100-48S-6Q": {
16
- ports: { "40g": 6, "10g": 48, "1g": 2 },
17
- spelling: { "40g": "et-0/0/{n+47}", "10g": "xe-0/0/{n-1}", "1g": "em{n-1}" }
18
- }
19
- };
20
-
21
- // src/platforms/raisecom-ros/models.ts
22
- var models3 = {
23
- "RAX721-C-6C48": {
24
- ports: { "100g": 6, "25g": 24, "10g": 24 },
25
- spelling: { "100g": "hundredgige 1/1/{n}", "25g": "twenty-fivegige 1/2/{n}", "10g": "tengigabitethernet 1/3/{n}" }
26
- }
27
- };
28
-
29
- // src/platforms/routeros/models.ts
30
- var models4 = {
31
- "CCR2216-1G-12XS-2XQ": {
32
- ports: { "100g": 2, "25g": 12, "1g": 1 },
33
- spelling: { "100g": "qsfp28-{n}-1", "25g": "sfp28-{n}", "1g": "ether{n}" },
34
- limits: { lag_members: 8, switch_port_ranges: 5 }
35
- }
36
- };
37
-
38
- // src/platforms/vrp/models.ts
39
- var models5 = {
40
- "CE6855-48S6Q-HI": { ports: { "10g": 48, "40g": 6 }, spelling: { "10g": "10GE1/0/{n}", "40g": "40GE1/0/{n}" } }
41
- };
42
-
43
- // src/platforms/catalog.ts
44
- class Catalog {
45
- static MODELS = { routeros: models4, vrp: models5, "raisecom-ros": models3, junos: models2, fortios: models };
46
- static ports(platform, model) {
47
- const spec = Catalog.MODELS[platform]?.[model];
48
- if (!spec)
49
- throw new Error(`${platform} has no model ${model}`);
50
- return Object.entries(spec.ports).flatMap(([speed, count]) => Array.from({ length: count }, (_, i) => `${speed}-${i + 1}`));
51
- }
52
- static limits(platform, model) {
53
- const spec = Catalog.MODELS[platform]?.[model];
54
- return spec?.limits ?? {};
55
- }
56
- static spell(platform, model, port) {
57
- const spec = Catalog.MODELS[platform]?.[model];
58
- const parts = /^(\d+[gm])-(\d+)$/.exec(port);
59
- const template = parts && spec?.spelling[parts[1]];
60
- if (!template)
61
- throw new Error(`${model} has no port ${port}`);
62
- return template.replace(/\{n([+-]\d+)?\}/, (_, shift) => String(Number(parts[2]) + Number(shift ?? 0)));
63
- }
64
- }
65
-
66
8
  // src/core/topology.ts
67
9
  class Builder {
68
10
  nodes = new Map;
@@ -976,4 +918,4 @@ class StudioModel {
976
918
  }
977
919
  }
978
920
 
979
- export { Catalog, Topology, AddressSpace, StudioModel };
921
+ export { Topology, AddressSpace, StudioModel };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  StudioModel
3
- } from "./index-k38bby43.js";
3
+ } from "./index-yzw86rc8.js";
4
4
 
5
5
  // src/studio/server.ts
6
6
  import { execFile as execFile2, spawn } from "node:child_process";
package/dist/cli/index.js CHANGED
@@ -4,11 +4,13 @@ import {
4
4
  Secrets2
5
5
  } from "../chunks/index-20kcjxjg.js";
6
6
  import {
7
- Catalog,
8
7
  Topology,
9
8
  AddressSpace,
10
9
  StudioModel
11
- } from "../chunks/index-k38bby43.js";
10
+ } from "../chunks/index-yzw86rc8.js";
11
+ import {
12
+ Catalog
13
+ } from "../chunks/index-6wb41q67.js";
12
14
  import {
13
15
  Address2
14
16
  } from "../chunks/index-n7w3tg5y.js";
@@ -547,9 +549,23 @@ class Derive {
547
549
  for (const route of routing?.static ?? []) {
548
550
  if (route.bfd && !has("static-route-bfd"))
549
551
  found.push(`routing.static ${route.prefix} bfd, BFD on a static route is ${unproved}`);
552
+ if (route.table && !has("routing-tables"))
553
+ found.push(`routing.static ${route.prefix} table, a routing table beside the main one is ${unproved}`);
554
+ }
555
+ if (!has("routing-tables")) {
556
+ if (routing?.tables)
557
+ found.push(`routing.tables, a routing table beside the main one is ${unproved}`);
558
+ if (routing?.rules)
559
+ found.push(`routing.rules, routing rules are ${unproved}`);
550
560
  }
551
561
  const bgp = routing?.bgp;
562
+ for (const [name, group] of Object.entries(bgp?.groups ?? {})) {
563
+ if (group.table && !has("routing-tables"))
564
+ found.push(`routing.bgp.groups.${name}.table, a routing table beside the main one is ${unproved}`);
565
+ }
552
566
  for (const [name, neighbor] of Object.entries(bgp?.neighbors ?? {})) {
567
+ if (neighbor.table && !has("routing-tables"))
568
+ found.push(`routing.bgp.neighbors.${name}.table, a routing table beside the main one is ${unproved}`);
553
569
  const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
554
570
  const bfd = neighbor.bfd ?? group?.bfd;
555
571
  const multihop = neighbor.multihop ?? group?.multihop;
@@ -3969,28 +3985,6 @@ class Cabling {
3969
3985
  }
3970
3986
  return findings;
3971
3987
  }
3972
- static describeLinks(devices) {
3973
- const byName = new Map(devices.map((device) => [device.name, device]));
3974
- return devices.map((device) => {
3975
- const ports = Object.entries(device.ports ?? {});
3976
- if (!ports.some(([, port]) => port?.link && port.description === undefined))
3977
- return device;
3978
- const described = ports.map(([name, port]) => {
3979
- const far = port?.link ? byName.get(port.link.device) : undefined;
3980
- if (!port?.link || port.description !== undefined || !far)
3981
- return [name, port];
3982
- return [name, { ...port, description: `${far.name} ${Cabling.labelOf(far, port.link.port)}` }];
3983
- });
3984
- return { ...device, ports: Object.fromEntries(described) };
3985
- });
3986
- }
3987
- static labelOf(device, port) {
3988
- try {
3989
- return Catalog.spell(device.platform, device.model, port);
3990
- } catch {
3991
- return port;
3992
- }
3993
- }
3994
3988
  static isDevice(system, name) {
3995
3989
  return system === name || system.startsWith(`${name}.`);
3996
3990
  }
@@ -6828,13 +6822,13 @@ class BgpWait {
6828
6822
  static AFTER_APPLY = { seconds: 180, every: 10 };
6829
6823
  static async until(read, enough, limits, waiting = () => {}) {
6830
6824
  const deadline = Date.now() + limits.seconds * 1000;
6831
- let sessions = await read();
6832
- while (!enough(sessions) && Date.now() < deadline) {
6833
- waiting(sessions);
6825
+ let value = await read();
6826
+ while (!enough(value) && Date.now() < deadline) {
6827
+ waiting(value);
6834
6828
  await sleep(Math.min(limits.every * 1000, Math.max(0, deadline - Date.now())));
6835
- sessions = await read();
6829
+ value = await read();
6836
6830
  }
6837
- return sessions;
6831
+ return value;
6838
6832
  }
6839
6833
  static upCount(sessions) {
6840
6834
  return sessions.filter((session) => session.established).length;
@@ -6954,6 +6948,7 @@ class RouterOsExport {
6954
6948
  };
6955
6949
  static ORDERED = new Set([
6956
6950
  "/routing filter rule",
6951
+ "/routing rule",
6957
6952
  "/ip firewall filter",
6958
6953
  "/ipv6 firewall filter",
6959
6954
  "/ip firewall nat",
@@ -7135,6 +7130,8 @@ class RouterOsPlan {
7135
7130
  "/interface bridge filter",
7136
7131
  "/interface list",
7137
7132
  "/ip vrf",
7133
+ "/routing table",
7134
+ "/routing rule",
7138
7135
  "/ip address",
7139
7136
  "/ipv6 address",
7140
7137
  "/ip pool",
@@ -7179,6 +7176,7 @@ class RouterOsPlan {
7179
7176
  "/interface bridge vlan"
7180
7177
  ]);
7181
7178
  static CHANGED_IN_PLACE = new Set(["/interface vlan"]);
7179
+ static RESTART_ON = { "/routing bgp connection": ["routing-table"] };
7182
7180
  static OLD_RULES = "frameworkOldRules";
7183
7181
  problems = [];
7184
7182
  constructor(sendSecrets) {
@@ -7349,8 +7347,16 @@ class RouterOsPlan {
7349
7347
  const update = this.changes(entry, current, `${menu} ${entryKey}`);
7350
7348
  if (!Object.keys(update).length)
7351
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
+ }
7352
7358
  changeStep.show.push(`~ ${entryKey} ${RouterOsPlan.describe(update)}`);
7353
- changeStep.send.push(RouterOsPlan.line(menu, "set", RouterOsPlan.find(menu, RouterOsExport.identity(current)), [], update));
7359
+ changeStep.send.push(RouterOsPlan.line(menu, "set", selector, [], update));
7354
7360
  }
7355
7361
  const removed = existingEntries.filter((entry) => !kept.has(key(entry)));
7356
7362
  const wholeLists = new Set;
@@ -8297,6 +8303,8 @@ class RouterOsRender {
8297
8303
  return rules;
8298
8304
  }
8299
8305
  routing() {
8306
+ this.routingTables();
8307
+ this.routingRules();
8300
8308
  this.staticRoutes();
8301
8309
  this.rpki();
8302
8310
  if (this.device.routing?.bgp) {
@@ -8307,6 +8315,24 @@ class RouterOsRender {
8307
8315
  this.ospf();
8308
8316
  this.bfd();
8309
8317
  }
8318
+ routingTables() {
8319
+ for (const [name, table] of Object.entries(this.device.routing?.tables ?? {})) {
8320
+ this.add("/routing table", "add fib", { comment: table.description, name });
8321
+ }
8322
+ }
8323
+ routingRules() {
8324
+ for (const rule of this.device.routing?.rules ?? []) {
8325
+ const onlyInTable = rule.table !== "main" && (rule.fallback ?? "none") === "none";
8326
+ this.add("/routing rule", "add", {
8327
+ action: onlyInTable ? "lookup-only-in-table" : "lookup",
8328
+ comment: rule.description,
8329
+ "dst-address": rule.match?.dst,
8330
+ interface: rule.match?.in_interface && this.name(rule.match.in_interface),
8331
+ "src-address": rule.match?.src,
8332
+ table: rule.table
8333
+ });
8334
+ }
8335
+ }
8310
8336
  staticRoutes() {
8311
8337
  for (const route of this.device.routing?.static ?? []) {
8312
8338
  const menu = Address2.family(Address2.host(route.prefix)) === "ipv4" ? "/ip route" : "/ipv6 route";
@@ -8317,7 +8343,7 @@ class RouterOsRender {
8317
8343
  distance: route.distance,
8318
8344
  "dst-address": route.prefix,
8319
8345
  gateway,
8320
- "routing-table": route.vrf
8346
+ "routing-table": route.vrf ?? route.table
8321
8347
  });
8322
8348
  }
8323
8349
  }
@@ -8438,6 +8464,7 @@ class RouterOsRender {
8438
8464
  "output.network": originates ? RouterOsRender.NETWORKS[family] : undefined,
8439
8465
  "remote.address": `${neighbor.address}/${addressFamily === "ipv4" ? 32 : 128}`,
8440
8466
  "remote.as": settings.remote_as,
8467
+ "routing-table": settings.table,
8441
8468
  "tcp-md5-key": settings.password && Secrets2.marker(settings.password),
8442
8469
  "use-bfd": settings.bfd ? "yes" : undefined
8443
8470
  });
@@ -8669,6 +8696,10 @@ class RouterOs extends Platform {
8669
8696
  ':put ("disabled|" . [/routing bgp connection get $c name] . "|" . [/routing bgp connection get $c remote.address])',
8670
8697
  "}"
8671
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
+ }
8672
8703
  static clearLimit(address) {
8673
8704
  return `/routing bgp session clear [find remote.address=${address} and limit-exceeded] flag=limit-exceeded`;
8674
8705
  }
@@ -8695,6 +8726,42 @@ class RouterOs extends Platform {
8695
8726
  };
8696
8727
  extension = ".rsc";
8697
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 = 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 missing = networks.filter((prefix) => !inTable(prefix));
8742
+ if (!missing.length)
8743
+ continue;
8744
+ const where = `routing.bgp.neighbors.${name}`;
8745
+ const route = (prefix) => `{ prefix: "${prefix}", blackhole: true, table: "${table}" }`;
8746
+ const fix = `On RouterOS a session announces from its own table, ${table}`;
8747
+ if (missing.length === networks.length) {
8748
+ findings.push({
8749
+ level: "error",
8750
+ device: device.name,
8751
+ 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`
8752
+ });
8753
+ continue;
8754
+ }
8755
+ for (const prefix of missing) {
8756
+ findings.push({
8757
+ level: "warning",
8758
+ device: device.name,
8759
+ 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`
8760
+ });
8761
+ }
8762
+ }
8763
+ return findings;
8764
+ }
8698
8765
  unsupported(device) {
8699
8766
  const found = [];
8700
8767
  for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
@@ -8761,7 +8828,14 @@ class RouterOs extends Platform {
8761
8828
  if (route.bfd)
8762
8829
  found.push(`routing.static ${route.prefix} bfd, a static route's check-gateway takes arp, none or ping on 7.23`);
8763
8830
  }
8764
- found.push(...Derive.unproved(device, ["bfd-timers", "multihop-bfd", "static-route-bfd", "mac-limit", "ethertype-filter"]));
8831
+ found.push(...Derive.unproved(device, [
8832
+ "bfd-timers",
8833
+ "multihop-bfd",
8834
+ "static-route-bfd",
8835
+ "mac-limit",
8836
+ "ethertype-filter",
8837
+ "routing-tables"
8838
+ ]));
8765
8839
  return found;
8766
8840
  }
8767
8841
  render(device) {
@@ -8925,6 +8999,7 @@ ${certificates}` : "";
8925
8999
  const switchRules = plan.steps.some((step) => step.send.some((command) => command.startsWith(RouterOs.SWITCH_RULES)));
8926
9000
  const limits = options.bgp ?? BgpWait.AFTER_APPLY;
8927
9001
  const establishedBefore = await this.establishedSessions(device, options);
9002
+ const announcedBefore = establishedBefore.length ? await this.announcedNow(device, options) : [];
8928
9003
  return {
8929
9004
  run: async () => {
8930
9005
  if (!switchRules && !establishedBefore.length) {
@@ -8935,7 +9010,8 @@ ${certificates}` : "";
8935
9010
  try {
8936
9011
  const switchProblems = switchRules ? await RouterOs.invalidSwitchRules(session, device) : [];
8937
9012
  const bgpProblems = await this.bgpBack(device, session, establishedBefore, limits);
8938
- return [...switchProblems, ...bgpProblems];
9013
+ const announceProblems = await this.announcedBack(device, session, announcedBefore, limits);
9014
+ return [...switchProblems, ...bgpProblems, ...announceProblems];
8939
9015
  } finally {
8940
9016
  session.close();
8941
9017
  }
@@ -9003,7 +9079,51 @@ ${certificates}` : "";
9003
9079
  console.log(` BGP: all ${neighbors.length} established again`);
9004
9080
  return [];
9005
9081
  }
9006
- 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} ${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`);
9082
+ 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`);
9083
+ }
9084
+ async announcedNow(device, options) {
9085
+ if (!device.routing?.bgp?.networks?.length)
9086
+ return [];
9087
+ const session = await this.session(device, options);
9088
+ try {
9089
+ return await this.announced(device, session);
9090
+ } finally {
9091
+ session.close();
9092
+ }
9093
+ }
9094
+ async announced(device, session) {
9095
+ const bgp = device.routing?.bgp;
9096
+ const networks = bgp?.networks ?? [];
9097
+ if (!networks.length)
9098
+ return [];
9099
+ 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");
9100
+ const neighbors = Object.entries(bgp?.neighbors ?? {});
9101
+ const neighborOf = (sessionName) => {
9102
+ const remote = sessions.find(([name]) => name === sessionName)?.[1];
9103
+ const byAddress = neighbors.find(([, neighbor]) => neighbor.address === remote);
9104
+ const byName = neighbors.find(([name]) => name === sessionName.replace(/-\d+$/, ""));
9105
+ return (byAddress ?? byName)?.[0];
9106
+ };
9107
+ const output = await session.run(RouterOs.listAdvertisements(networks), 60000);
9108
+ return output.split(/\r?\n/).map((line) => line.trim().split("|")).flatMap(([marker, peer, prefix]) => {
9109
+ const neighbor = marker === "ADV" && peer ? neighborOf(peer) : undefined;
9110
+ return neighbor && prefix ? [`${neighbor} ${prefix}`] : [];
9111
+ }).sort();
9112
+ }
9113
+ async announcedBack(device, session, before, limits) {
9114
+ if (!before.length)
9115
+ return [];
9116
+ const missing = (now) => before.filter((pair) => !now.includes(pair));
9117
+ 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`));
9118
+ const gone = missing(after);
9119
+ if (!gone.length) {
9120
+ console.log(` BGP: every session announces what it announced before`);
9121
+ return [];
9122
+ }
9123
+ return gone.map((pair) => {
9124
+ const [neighbor, prefix] = pair.split(" ");
9125
+ 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'`;
9126
+ });
9007
9127
  }
9008
9128
  static async invalidSwitchRules(session, device) {
9009
9129
  const answer = (await session.run(RouterOs.COUNT_INVALID_SWITCH_RULES)).trim();
@@ -11220,9 +11340,11 @@ class Validator {
11220
11340
  const adapter = Platforms.find(device.platform);
11221
11341
  if (!adapter)
11222
11342
  report(`no adapter for platform ${device.platform}`);
11223
- else
11343
+ else {
11224
11344
  for (const field of adapter.unsupported(device))
11225
11345
  report(`${device.platform} cannot express ${field}`);
11346
+ findings.push(...adapter.checks?.(device) ?? []);
11347
+ }
11226
11348
  }
11227
11349
  findings.push(...CrossChecks.run(network.devices));
11228
11350
  for (const check of network.checks ?? [])
@@ -11433,15 +11555,21 @@ class Validator {
11433
11555
  report(`static route ${route.prefix} has bfd without via, the address BFD watches`);
11434
11556
  names.expectInterface(`static route ${route.prefix} interface`, route.interface);
11435
11557
  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)
11560
+ report(`static route ${route.prefix} has both vrf and table; a route is in one of them`);
11436
11561
  }
11562
+ Validator.checkTables(device, names, report, cidr);
11437
11563
  const bgp = routing?.bgp;
11438
11564
  const isPolicy = (policy) => policy !== undefined && (policy in policies);
11439
11565
  for (const prefix of bgp?.networks ?? [])
11440
11566
  cidr("BGP network", prefix);
11567
+ const isTable = (table) => table !== undefined && (table in (routing?.tables ?? {}));
11441
11568
  for (const [name, group] of Object.entries(bgp?.groups ?? {})) {
11442
11569
  for (const policy of [group.import, group.export]) {
11443
11570
  names.expect(`BGP group ${name} policy`, policy, isPolicy(policy));
11444
11571
  }
11572
+ names.expect(`BGP group ${name} table`, group.table, isTable(group.table));
11445
11573
  }
11446
11574
  for (const [name, neighbor] of Object.entries(bgp?.neighbors ?? {})) {
11447
11575
  ip(`neighbor ${name}`, neighbor.address);
@@ -11452,6 +11580,7 @@ class Validator {
11452
11580
  for (const policy of [neighbor.import, neighbor.export]) {
11453
11581
  names.expect(`neighbor ${name} policy`, policy, isPolicy(policy));
11454
11582
  }
11583
+ names.expect(`neighbor ${name} table`, neighbor.table, isTable(neighbor.table));
11455
11584
  const local = neighbor.local_address ?? group?.local_address;
11456
11585
  if (local && Address2.family(local) !== Address2.family(neighbor.address))
11457
11586
  report(`neighbor ${name} local_address and address differ in family`);
@@ -11463,6 +11592,48 @@ class Validator {
11463
11592
  names.expectInterface(`OSPF area ${areaId} interface`, iface);
11464
11593
  }
11465
11594
  }
11595
+ static checkTables(device, names, report, cidr) {
11596
+ const routing = device.routing;
11597
+ const tables = routing?.tables ?? {};
11598
+ const bgp = routing?.bgp;
11599
+ const installed = new Set;
11600
+ for (const route of routing?.static ?? [])
11601
+ if (route.table)
11602
+ installed.add(route.table);
11603
+ for (const neighbor of Object.values(bgp?.neighbors ?? {})) {
11604
+ const table = neighbor.table ?? (neighbor.group ? bgp?.groups?.[neighbor.group]?.table : undefined);
11605
+ if (table)
11606
+ installed.add(table);
11607
+ }
11608
+ for (const name of Object.keys(tables)) {
11609
+ if (name === "main")
11610
+ report("routing.tables has main, the main table every device has; give the table another name");
11611
+ if (name in (device.vrfs ?? {}))
11612
+ report(`routing.tables ${name} has the name of a VRF; give the table another name`);
11613
+ if (name !== "main" && !installed.has(name))
11614
+ report(`routing.tables ${name}: nothing installs routes in it; give a static route or a BGP neighbor table: "${name}", or remove the table`);
11615
+ }
11616
+ const rules = routing?.rules ?? [];
11617
+ rules.forEach((rule, index) => {
11618
+ const where = `routing.rules ${index + 1}${rule.description ? ` (${rule.description})` : ""}`;
11619
+ const match = rule.match ?? {};
11620
+ names.expect(`${where} table`, rule.table, rule.table === "main" || rule.table in tables);
11621
+ names.expectInterface(`${where} in_interface`, match.in_interface);
11622
+ if (match.src)
11623
+ cidr(where, match.src);
11624
+ if (match.dst)
11625
+ cidr(where, match.dst);
11626
+ if (match.src && match.dst && Address2.isCidr(match.src) && Address2.isCidr(match.dst)) {
11627
+ if (Address2.family(Address2.host(match.src)) !== Address2.family(Address2.host(match.dst)))
11628
+ report(`${where}: src and dst differ in family, so no packet matches both`);
11629
+ }
11630
+ if (rule.table === "main" && rule.fallback)
11631
+ report(`${where} looks up main and has fallback; fallback is for another table, so remove it`);
11632
+ const matchesAll = !match.src && !match.dst && !match.in_interface;
11633
+ if (matchesAll && index < rules.length - 1)
11634
+ report(`${where} matches every packet, so the rules after it never run; give it a match, or move it last`);
11635
+ });
11636
+ }
11466
11637
  static checkPolicies(device, names, report, cidr) {
11467
11638
  const policies = device.policies ?? {};
11468
11639
  const sets = device.prefix_sets ?? {};
@@ -11865,14 +12036,10 @@ class Cli {
11865
12036
  return new Cli(configPath, network, options);
11866
12037
  }
11867
12038
  static async load(path) {
11868
- let network;
11869
- if (process.versions.bun) {
11870
- network = (await import(path)).default;
11871
- } else {
11872
- const { createJiti } = await import("jiti");
11873
- network = await createJiti(import.meta.url).import(path, { default: true });
11874
- }
11875
- return { ...network, devices: Cabling.describeLinks(network.devices) };
12039
+ if (process.versions.bun)
12040
+ return (await import(path)).default;
12041
+ const { createJiti } = await import("jiti");
12042
+ return createJiti(import.meta.url).import(path, { default: true });
11876
12043
  }
11877
12044
  static fail(message) {
11878
12045
  console.error(message);
@@ -11916,7 +12083,12 @@ class Cli {
11916
12083
  program.command("snapshot").description("read what each device runs into the state directory").argument("[device...]", "every device when none is named").option("--console", "through the device's serial console on its jump host, instead of SSH").option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP).action(async (devices, flags) => (await Cli.open(flags.config, flags)).snapshot(devices));
11917
12084
  program.command("apply").description("read the device, plan against what it runs, and send with --confirm").argument("<device>").option("--confirm", "send the plan; without it, nothing is sent").option("--secrets", "also send every secret, to rotate one").option("--rollback <minutes>", "minutes before a device that can restore itself does", Cli.minutes, 10).option("--console", "through the device's serial console on its jump host, instead of SSH").option("--force", "send even when the pings before --confirm find a path that loses too much or is too slow").option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP).action(async (device, flags) => (await Cli.open(flags.config, flags)).apply(device, flags.confirm ?? false, flags.force ?? false));
11918
12085
  program.command("show").description("run commands that only read on a device and print what it answers, secrets hidden").argument("<device>").argument("[command...]", "each command in quotes, such as '/ip route print'").option("--file <path>", "also each line of this file; blank lines and lines starting with # are skipped").option("--timeout <seconds>", "how long each command may take", Cli.seconds, 90).option("--console", "through the device's serial console, instead of SSH").option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP).action(async (device, commands, flags) => (await Cli.open(flags.config, flags)).show(device, commands, flags.file, flags.timeout));
11919
- program.command("bgp").description("each BGP neighbor in the config next to its live session: up or down and why, prefixes, uptime").argument("[device]", "may be left out when only one device has BGP neighbors").argument("[name-regex]", "only the neighbors whose name matches").option("--wait [count]", `read again every ${BgpWait.COMMAND.every} seconds until every neighbor shown, or this many, is up`, Cli.count).option("--max <seconds>", "how long --wait waits at most", Cli.seconds, BgpWait.COMMAND.seconds).option("--log [minutes]", "also print the device's BGP log lines from the last minutes, 5 by default", Cli.minutes).option("--clear-limit", "open again the neighbors shown that closed on their prefix limit; any other is left alone").option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP).action(async (first, second, flags) => (await Cli.open(flags.config)).bgp(first, second, flags));
12086
+ program.command("bgp").description("each BGP neighbor in the config next to its live session: up or down and why, prefixes, uptime").argument("[device]", "may be left out when only one device has BGP neighbors").allowExcessArguments().option("--neighbors <regex>", "only the neighbors whose name matches").option("--wait [count]", `read again every ${BgpWait.COMMAND.every} seconds until every neighbor shown, or this many, is up`, Cli.count).option("--max <seconds>", "how long --wait waits at most", Cli.seconds, BgpWait.COMMAND.seconds).option("--log [minutes]", "also print the device's BGP log lines from the last minutes, 5 by default", Cli.minutes).option("--clear-limit", "open again the neighbors shown that closed on their prefix limit; any other is left alone").option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP).action(async (device, flags, command) => {
12087
+ const [, filter] = command.args;
12088
+ if (filter !== undefined)
12089
+ Cli.fail(`circuit bgp takes one device; filter the neighbors with --neighbors: circuit bgp ${device} --neighbors '${filter}'`);
12090
+ (await Cli.open(flags.config)).bgp(device, flags);
12091
+ });
11920
12092
  program.command("console").description("open a device's serial console here, logged in; or send it lines and print what it answers").argument("<device>").argument("[line...]", "lines to send, each in quotes; none joins the console to this terminal, Ctrl-] leaves").option("--raw", "do not log in or out: only join the console, or send the lines").option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP).action(async (device, lines, flags) => (await Cli.open(flags.config)).console(device, lines, flags.raw ?? false));
11921
12093
  program.command("refresh").description("fetch prefix sets that come from a registry onto the device, with --confirm").argument("<device>").option("--confirm", "put them on the device; without it, nothing is sent").option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP).action(async (device, flags) => (await Cli.open(flags.config)).refresh(device, flags.confirm ?? false));
11922
12094
  program.command("wireguard").description("a client config for one peer, its private key left for the peer to fill").argument("<device>").argument("<interface>").argument("<peer>").option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP).action(async (device, iface, peer, flags) => (await Cli.open(flags.config)).wireguard(device, iface, peer));
@@ -12138,8 +12310,9 @@ ${output.replace(/\r/g, "").trimEnd()}${stoppedAtLimit}
12138
12310
  if (failed)
12139
12311
  process.exitCode = 1;
12140
12312
  }
12141
- async bgp(first, second, flags) {
12142
- const { device, pattern } = this.bgpTarget(first, second);
12313
+ async bgp(deviceName, flags) {
12314
+ const device = this.bgpDevice(deviceName);
12315
+ const pattern = flags.neighbors;
12143
12316
  const adapter = this.adapterOf(device);
12144
12317
  const supported = Platforms.all().filter((platform) => platform.bgpSessions).map((platform) => platform.name).join(", ");
12145
12318
  if (!adapter.bgpSessions)
@@ -12199,17 +12372,15 @@ no neighbor shown is closed on its prefix limit, so nothing was cleared`);
12199
12372
  `);
12200
12373
  Cli.printBgp(await read());
12201
12374
  }
12202
- bgpTarget(first, second) {
12203
- if (second !== undefined || first && this.network.devices.some((device) => device.name === first)) {
12204
- return { device: this.one(first), pattern: second };
12205
- }
12375
+ bgpDevice(name) {
12376
+ if (name !== undefined)
12377
+ return this.one(name);
12206
12378
  const withBgp = this.network.devices.filter((device) => Object.keys(device.routing?.bgp?.neighbors ?? {}).length);
12207
- const named = first ? `no device ${first}, and ` : "";
12208
12379
  if (withBgp.length === 1)
12209
- return { device: withBgp[0], pattern: first };
12380
+ return withBgp[0];
12210
12381
  if (!withBgp.length)
12211
- Cli.fail(`${named}no device has BGP neighbors`);
12212
- return Cli.fail(`${named}several devices have BGP neighbors, so name one: ${withBgp.map((device) => device.name).join(", ")}`);
12382
+ Cli.fail("no device has BGP neighbors");
12383
+ return Cli.fail(`several devices have BGP neighbors, so name one: ${withBgp.map((device) => device.name).join(", ")}`);
12213
12384
  }
12214
12385
  static printBgp(sessions) {
12215
12386
  const width = Math.max(...sessions.map((session) => session.neighbor.length));
@@ -12677,7 +12848,7 @@ Renamed. Run circuit validate, then circuit apply ${to}: the device keeps its ol
12677
12848
  const port = Number(flags.port);
12678
12849
  if (!Number.isInteger(port) || port < 1 || port > 65535)
12679
12850
  Cli.fail("--port takes a number from 1 to 65535");
12680
- const { StudioServer } = await import("../chunks/server-ggertnce.js");
12851
+ const { StudioServer } = await import("../chunks/server-cxr9ynxc.js");
12681
12852
  const host = flags.host;
12682
12853
  const allowHosts = (flags.allowHost ?? "").split(",").filter(Boolean);
12683
12854
  if (!StudioServer.isLoopback(host) && !allowHosts.length) {