@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
@@ -121,36 +121,6 @@ export class Cabling {
121
121
  return findings;
122
122
  }
123
123
 
124
- /**
125
- * Each linked port without a description gets one from its far end: the far device and the far port as printed on it, such as `firewall-1 port6`. The far device's own file has its platform and model, so the label is never written twice. A port whose far device is not in the network keeps none.
126
- */
127
- static describeLinks(devices: readonly Device[]): Device[] {
128
- const byName = new Map(devices.map((device) => [device.name, device]));
129
-
130
- return devices.map((device) => {
131
- const ports = Object.entries(device.ports ?? {});
132
- if (!ports.some(([, port]) => port?.link && port.description === undefined)) return device;
133
-
134
- const described = ports.map(([name, port]) => {
135
- const far = port?.link ? byName.get(port.link.device) : undefined;
136
- if (!port?.link || port.description !== undefined || !far) return [name, port];
137
-
138
- return [name, { ...port, description: `${far.name} ${Cabling.labelOf(far, port.link.port)}` }];
139
- });
140
-
141
- return { ...device, ports: Object.fromEntries(described) } as Device;
142
- });
143
- }
144
-
145
- /** A port as printed on the device, or Circuit's name for it when the model has no spelling for it. */
146
- private static labelOf(device: Device, port: string): string {
147
- try {
148
- return Catalog.spell(device.platform, device.model, port);
149
- } catch {
150
- return port;
151
- }
152
- }
153
-
154
124
  /** A system name is the device's name, alone or with a domain after it. */
155
125
  private static isDevice(system: string, name: string): boolean {
156
126
  return system === name || system.startsWith(`${name}.`);
@@ -28,6 +28,7 @@ export function defineDevice<
28
28
  const Users extends string = never,
29
29
  const Vrfs extends string = never,
30
30
  const Certificates extends string = never,
31
+ const Tables extends string = never,
31
32
  >(
32
33
  device: Device<
33
34
  Platform,
@@ -42,7 +43,8 @@ export function defineDevice<
42
43
  AddressSets,
43
44
  Users,
44
45
  Vrfs,
45
- Certificates
46
+ Certificates,
47
+ Tables
46
48
  >
47
49
  ): Device {
48
50
  return device as unknown as Device;
@@ -63,7 +63,10 @@ export class Validator {
63
63
 
64
64
  const adapter = Platforms.find(device.platform);
65
65
  if (!adapter) report(`no adapter for platform ${device.platform}`);
66
- else for (const field of adapter.unsupported(device)) report(`${device.platform} cannot express ${field}`);
66
+ else {
67
+ for (const field of adapter.unsupported(device)) report(`${device.platform} cannot express ${field}`);
68
+ findings.push(...(adapter.checks?.(device) ?? []));
69
+ }
67
70
  }
68
71
 
69
72
  findings.push(...CrossChecks.run(network.devices));
@@ -291,17 +294,29 @@ export class Validator {
291
294
  if (route.bfd && !route.via) report(`static route ${route.prefix} has bfd without via, the address BFD watches`);
292
295
  names.expectInterface(`static route ${route.prefix} interface`, route.interface);
293
296
  names.expect(`static route ${route.prefix} VRF`, route.vrf, route.vrf !== undefined && route.vrf in (device.vrfs ?? {}));
297
+ names.expect(
298
+ `static route ${route.prefix} table`,
299
+ route.table,
300
+ route.table !== undefined && route.table in (routing?.tables ?? {})
301
+ );
302
+ if (route.vrf && route.table) report(`static route ${route.prefix} has both vrf and table; a route is in one of them`);
294
303
  }
295
304
 
305
+ Validator.checkTables(device, names, report, cidr);
306
+
296
307
  const bgp = routing?.bgp;
297
308
  const isPolicy = (policy: string | undefined) => policy !== undefined && policy in policies;
298
309
 
299
310
  for (const prefix of bgp?.networks ?? []) cidr("BGP network", prefix);
300
311
 
312
+ const isTable = (table: string | undefined) => table !== undefined && table in (routing?.tables ?? {});
313
+
301
314
  for (const [name, group] of Object.entries(bgp?.groups ?? {})) {
302
315
  for (const policy of [group.import, group.export]) {
303
316
  names.expect(`BGP group ${name} policy`, policy, isPolicy(policy));
304
317
  }
318
+
319
+ names.expect(`BGP group ${name} table`, group.table, isTable(group.table));
305
320
  }
306
321
 
307
322
  for (const [name, neighbor] of Object.entries(bgp?.neighbors ?? {})) {
@@ -316,6 +331,8 @@ export class Validator {
316
331
  names.expect(`neighbor ${name} policy`, policy, isPolicy(policy));
317
332
  }
318
333
 
334
+ names.expect(`neighbor ${name} table`, neighbor.table, isTable(neighbor.table));
335
+
319
336
  const local = neighbor.local_address ?? group?.local_address;
320
337
  if (local && Address.family(local) !== Address.family(neighbor.address))
321
338
  report(`neighbor ${name} local_address and address differ in family`);
@@ -327,6 +344,52 @@ export class Validator {
327
344
  }
328
345
  }
329
346
 
347
+ /** Routing tables have names of their own and something installing routes in them, and rules name a table and can each be reached. */
348
+ private static checkTables(device: Device, names: Names, report: Report, cidr: Checker): void {
349
+ const routing = device.routing;
350
+ const tables = routing?.tables ?? {};
351
+ const bgp = routing?.bgp;
352
+
353
+ const installed = new Set<string>();
354
+ for (const route of routing?.static ?? []) if (route.table) installed.add(route.table);
355
+ for (const neighbor of Object.values(bgp?.neighbors ?? {})) {
356
+ const table = neighbor.table ?? (neighbor.group ? bgp?.groups?.[neighbor.group]?.table : undefined);
357
+ if (table) installed.add(table);
358
+ }
359
+
360
+ for (const name of Object.keys(tables)) {
361
+ if (name === "main") report("routing.tables has main, the main table every device has; give the table another name");
362
+ if (name in (device.vrfs ?? {})) report(`routing.tables ${name} has the name of a VRF; give the table another name`);
363
+ if (name !== "main" && !installed.has(name))
364
+ report(
365
+ `routing.tables ${name}: nothing installs routes in it; give a static route or a BGP neighbor table: "${name}", or remove the table`
366
+ );
367
+ }
368
+
369
+ const rules = routing?.rules ?? [];
370
+ rules.forEach((rule, index) => {
371
+ const where = `routing.rules ${index + 1}${rule.description ? ` (${rule.description})` : ""}`;
372
+ const match = rule.match ?? {};
373
+
374
+ names.expect(`${where} table`, rule.table, rule.table === "main" || rule.table in tables);
375
+ names.expectInterface(`${where} in_interface`, match.in_interface);
376
+ if (match.src) cidr(where, match.src);
377
+ if (match.dst) cidr(where, match.dst);
378
+
379
+ if (match.src && match.dst && Address.isCidr(match.src) && Address.isCidr(match.dst)) {
380
+ if (Address.family(Address.host(match.src)) !== Address.family(Address.host(match.dst)))
381
+ report(`${where}: src and dst differ in family, so no packet matches both`);
382
+ }
383
+
384
+ if (rule.table === "main" && rule.fallback)
385
+ report(`${where} looks up main and has fallback; fallback is for another table, so remove it`);
386
+
387
+ const matchesAll = !match.src && !match.dst && !match.in_interface;
388
+ if (matchesAll && index < rules.length - 1)
389
+ report(`${where} matches every packet, so the rules after it never run; give it a match, or move it last`);
390
+ });
391
+ }
392
+
330
393
  private static checkPolicies(device: Device, names: Names, report: Report, cidr: Checker): void {
331
394
  const policies = device.policies ?? {};
332
395
  const sets = device.prefix_sets ?? {};
@@ -1,11 +1,16 @@
1
- /** The far end of a port, for a description of your own: the device and its port by Circuit's name. */
2
- export type FarEnd = { device: string; port: string };
1
+ import { Catalog } from "../platforms/catalog";
2
+
3
+ /** The far end of a port, for a description of your own: the device, its port by Circuit's name, and the port as printed on the device when that end names its platform and model. */
4
+ export type FarEnd = { device: string; port: string; label?: string };
5
+
6
+ /** One end of a cable entry: the device, and its platform and model if a description should name its ports as printed on it. */
7
+ type End = { device: string; platform?: string; model?: string };
3
8
 
4
9
  /** What every cable of one entry shares. */
5
10
  type Shared = {
6
11
  /** What is between the two ends, such as a carrier's local loop. Both ends get it on their `link`. */
7
12
  through?: string;
8
- /** A port's description from its far end. When absent, Circuit writes the far device and the far port as printed on it, such as `firewall-1 port6`, from the far device's own file. */
13
+ /** A port's description from its far end, such as `(far) => \`${far.device} ${far.label}\``. No description when absent. */
9
14
  describe?: (far: FarEnd) => string;
10
15
  };
11
16
 
@@ -26,12 +31,10 @@ export class Cables {
26
31
  * // router-1: ports: { ...uplink.a }
27
32
  * ```
28
33
  */
29
- static link<const NA extends string, const NB extends string>(
30
- spec: Shared & { a: { device: string; port: NA }; b: { device: string; port: NB } }
31
- ) {
34
+ static link<const NA extends string, const NB extends string>(spec: Shared & { a: End & { port: NA }; b: End & { port: NB } }) {
32
35
  return {
33
- a: { [spec.a.port]: Cables.end(spec, spec.b.device, spec.b.port) } as Record<NA, CablePort<never>>,
34
- b: { [spec.b.port]: Cables.end(spec, spec.a.device, spec.a.port) } as Record<NB, CablePort<never>>,
36
+ a: { [spec.a.port]: Cables.end(spec, spec.b, spec.b.port) } as Record<NA, CablePort<never>>,
37
+ b: { [spec.b.port]: Cables.end(spec, spec.a, spec.a.port) } as Record<NB, CablePort<never>>,
35
38
  };
36
39
  }
37
40
 
@@ -39,7 +42,7 @@ export class Cables {
39
42
  * The cables of a LAG, both ends, from one list read from end A: `{ "25g-1": "10g-2" }` is a cable from A's 25g-1 to B's 10g-2. Each port is a member of its end's LAG. Spread each end into its device's `ports`; the LAG itself stays on each device.
40
43
  */
41
44
  static lag<const LA extends string, const LB extends string, const C extends Readonly<Record<string, string>>>(
42
- spec: Shared & { a: { device: string; lag: LA }; b: { device: string; lag: LB }; cables: C }
45
+ spec: Shared & { a: End & { lag: LA }; b: End & { lag: LB }; cables: C }
43
46
  ) {
44
47
  const pairs = Object.entries(spec.cables);
45
48
 
@@ -50,8 +53,8 @@ export class Cables {
50
53
  taken.set(far, near);
51
54
  }
52
55
 
53
- const a = Object.fromEntries(pairs.map(([near, far]) => [near, { ...Cables.end(spec, spec.b.device, far), lag: spec.a.lag }]));
54
- const b = Object.fromEntries(pairs.map(([near, far]) => [far, { ...Cables.end(spec, spec.a.device, near), lag: spec.b.lag }]));
56
+ const a = Object.fromEntries(pairs.map(([near, far]) => [near, { ...Cables.end(spec, spec.b, far), lag: spec.a.lag }]));
57
+ const b = Object.fromEntries(pairs.map(([near, far]) => [far, { ...Cables.end(spec, spec.a, near), lag: spec.b.lag }]));
55
58
 
56
59
  return {
57
60
  a: a as Record<keyof C & string, CablePort<LA>>,
@@ -59,11 +62,13 @@ export class Cables {
59
62
  };
60
63
  }
61
64
 
62
- /** A port facing `port` on the far device. */
63
- private static end(shared: Shared, device: string, port: string): CablePort<never> {
65
+ /** A port facing `port` on the far end. */
66
+ private static end(shared: Shared, far: End, port: string): CablePort<never> {
67
+ const label = far.platform && far.model ? Catalog.spell(far.platform, far.model, port) : undefined;
68
+
64
69
  return {
65
- link: { device, port, ...(shared.through ? { through: shared.through } : {}) },
66
- ...(shared.describe ? { description: shared.describe({ device, port }) } : {}),
70
+ link: { device: far.device, port, ...(shared.through ? { through: shared.through } : {}) },
71
+ ...(shared.describe ? { description: shared.describe({ device: far.device, port, ...(label ? { label } : {}) }) } : {}),
67
72
  };
68
73
  }
69
74
  }
@@ -30,6 +30,10 @@ export type InternetExchange<V extends string> = {
30
30
  mac_limit?: number;
31
31
  /** The only kinds of frame a member port takes in, usually `ipv4`, `ipv6` and `arp`. Every kind when absent. No platform renders it yet, so `validate` refuses it. */
32
32
  ethertypes?: readonly Ethertype[];
33
+ /** LLDP on member ports. Off when absent: a member's router has no business learning the fabric. */
34
+ lldp?: boolean;
35
+ /** Spanning tree on member ports. Off when absent: a member's port faces a router, which must not join the fabric's spanning tree. */
36
+ stp?: boolean;
33
37
  };
34
38
 
35
39
  /** An internet exchange's fabric: its member ports, a check of its members, and its route servers' config. */
@@ -37,7 +41,9 @@ export class Exchange {
37
41
  /**
38
42
  * Member ports and a check for an internet exchange's fabric.
39
43
  *
40
- * - `ports(device)`: every member's port on one switch. A member port is in the peering VLAN and nothing else, takes no part in spanning tree or LLDP, and lets only a trickle of broadcast, multicast and unknown unicast through. With `mac_limit` and `ethertypes`, it also learns that many MAC addresses at most and takes in only those kinds of frame.
44
+ * - `ports(device)`: every member's port on one switch, each written as
45
+ * `{ description: "<name>, AS<asn>", access_vlan: vlan, stp: false, lldp: false, storm_control: { broadcast, multicast, unknown_unicast: { percent: storm_percent } } }`,
46
+ * plus `mac_limit` and `ethertypes` when the exchange sets them. `stp` and `lldp` are the exchange's own when it sets them, and storm control is 1 % when `storm_percent` is absent.
41
47
  * - `check`: members never share a name, an AS, an address or a port; every address is inside the peering LAN and not reserved; every member port carries the peering VLAN alone.
42
48
  * - `bird(server)`: one route server's BIRD 2 config, with a session to every member. Write it to a file for the server; Circuit never logs in to a route server.
43
49
  * - `openbgpd(server)`: the same route server's config for OpenBGPD, its `bgpd.conf`, with the same sessions and the same rules.
@@ -55,8 +61,8 @@ export class Exchange {
55
61
  result[port] = {
56
62
  description: `${member.name}, AS${member.asn}`,
57
63
  access_vlan: exchange.vlan,
58
- stp: false,
59
- lldp: false,
64
+ stp: exchange.stp ?? false,
65
+ lldp: exchange.lldp ?? false,
60
66
  storm_control: { broadcast: { percent }, multicast: { percent }, unknown_unicast: { percent } },
61
67
  ...(exchange.mac_limit !== undefined ? { mac_limit: exchange.mac_limit } : {}),
62
68
  ...(exchange.ethertypes ? { ethertypes: exchange.ethertypes } : {}),
@@ -63,10 +63,12 @@ export type Upstream = {
63
63
  tunnel?: { interface: string; local: IP; remote: IP; addresses: readonly InterfaceAddress[]; mtu?: number };
64
64
  /** Our BGP sessions with it, by name: its address, and ours when it is not the interface's. */
65
65
  sessions: Readonly<Record<string, { address: IP; local_address?: IP }>>;
66
- /** A BGP group its sessions share, by this name: the group holds the AS, role, policies and multihop settings, and each session names it. Each session holds them itself when absent. */
66
+ /** A BGP group its sessions share, by this name: the group holds the AS, role, policies, table and multihop settings, and each session names it. Each session holds them itself when absent. */
67
67
  group?: string;
68
68
  /** The sessions are more than one hop away, from this address of ours. */
69
69
  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. */
71
+ table?: string;
70
72
  /** The most prefixes a session may send, by its family: about twice what it sends. Over it, the session closes until it is cleared. */
71
73
  limits?: { ipv4?: number; ipv6?: number };
72
74
  /** What we take, as its policy. Nothing when absent. */
@@ -105,6 +107,8 @@ type PolicyName<C> = Values<{
105
107
  }>;
106
108
  /** Each BGP group name. */
107
109
  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 }>;
108
112
 
109
113
  /** A network's upstreams written once, and each device's part built from them. */
110
114
  export class Upstreams {
@@ -217,7 +221,7 @@ export class Upstreams {
217
221
  const uplinks = () => all.flatMap(([, upstream]) => (upstream.handoff ? [upstream.handoff.interface as HandoffInterface<C>] : []));
218
222
 
219
223
  /** What a session, or the group it is in, takes from its upstream. */
220
- const settings = (key: string, upstream: Upstream): NeighborSettings<PolicyName<C>> => {
224
+ const settings = (key: string, upstream: Upstream): NeighborSettings<PolicyName<C>, TableName<C>> => {
221
225
  const multihop = upstream.multihop;
222
226
  return {
223
227
  remote_as: upstream.asn,
@@ -225,6 +229,7 @@ export class Upstreams {
225
229
  role: upstream.role,
226
230
  ...(upstream.import ? { import: upstream.import.name as PolicyName<C> } : {}),
227
231
  export: upstream.export.name as PolicyName<C>,
232
+ ...(upstream.table ? { table: upstream.table as TableName<C> } : {}),
228
233
  ...(multihop
229
234
  ? {
230
235
  local_address: multihop.local_address,
@@ -239,7 +244,7 @@ export class Upstreams {
239
244
  const groups = () =>
240
245
  Object.fromEntries(
241
246
  all.flatMap(([key, upstream]) => (upstream.group ? [[upstream.group, settings(key, upstream)]] : []))
242
- ) as Record<GroupName<C>, NeighborSettings<PolicyName<C>>>;
247
+ ) as Record<GroupName<C>, NeighborSettings<PolicyName<C>, TableName<C>>>;
243
248
 
244
249
  const neighbors = () =>
245
250
  Object.fromEntries(
@@ -256,7 +261,7 @@ export class Upstreams {
256
261
  ];
257
262
  })
258
263
  )
259
- ) as Record<string, Neighbor<PolicyName<C>, GroupName<C>>>;
264
+ ) as Record<string, Neighbor<PolicyName<C>, GroupName<C>, TableName<C>>>;
260
265
 
261
266
  /**
262
267
  * Each upstream's import and export, by name. The policies and prefix sets their rules name are checked by `validate`, not by the editor, so the rules are typed to fit any device.
@@ -15,23 +15,23 @@ export class BgpWait {
15
15
  */
16
16
  static readonly AFTER_APPLY: WaitLimits = { seconds: 180, every: 10 };
17
17
 
18
- /** Read the sessions until `enough` holds or time runs out, and give the last read. `waiting` is told of each read that fell short, before the pause. */
19
- static async until(
20
- read: () => Promise<BgpSession[]>,
21
- enough: (sessions: BgpSession[]) => boolean,
18
+ /** Read until `enough` holds or time runs out, and give the last read: the sessions, or what they announce. `waiting` is told of each read that fell short, before the pause. */
19
+ static async until<T>(
20
+ read: () => Promise<T>,
21
+ enough: (value: T) => boolean,
22
22
  limits: WaitLimits,
23
- waiting: (sessions: BgpSession[]) => void = () => {}
24
- ): Promise<BgpSession[]> {
23
+ waiting: (value: T) => void = () => {}
24
+ ): Promise<T> {
25
25
  const deadline = Date.now() + limits.seconds * 1000;
26
- let sessions = await read();
26
+ let value = await read();
27
27
 
28
- while (!enough(sessions) && Date.now() < deadline) {
29
- waiting(sessions);
28
+ while (!enough(value) && Date.now() < deadline) {
29
+ waiting(value);
30
30
  await sleep(Math.min(limits.every * 1000, Math.max(0, deadline - Date.now())));
31
- sessions = await read();
31
+ value = await read();
32
32
  }
33
33
 
34
- return sessions;
34
+ return value;
35
35
  }
36
36
 
37
37
  /** How many of these sessions are established. */
@@ -5,7 +5,14 @@ import type { Device } from "../schema";
5
5
 
6
6
  /** A feature a platform renders once proved on one of its devices. */
7
7
  export type ProvedFeature =
8
- "bfd-timers" | "static-route-bfd" | "multihop-bfd" | "alarm-severity" | "mac-limit" | "ethertype-filter" | "management-vdom";
8
+ | "bfd-timers"
9
+ | "static-route-bfd"
10
+ | "multihop-bfd"
11
+ | "alarm-severity"
12
+ | "mac-limit"
13
+ | "ethertype-filter"
14
+ | "management-vdom"
15
+ | "routing-tables";
9
16
 
10
17
  export class Derive {
11
18
  /** Each LAG's number: its own `id`, or its position among the device's LAGs. */
@@ -47,11 +54,26 @@ export class Derive {
47
54
  for (const route of routing?.static ?? []) {
48
55
  if (route.bfd && !has("static-route-bfd"))
49
56
  found.push(`routing.static ${route.prefix} bfd, BFD on a static route is ${unproved}`);
57
+ if (route.table && !has("routing-tables"))
58
+ found.push(`routing.static ${route.prefix} table, a routing table beside the main one is ${unproved}`);
59
+ }
60
+
61
+ if (!has("routing-tables")) {
62
+ if (routing?.tables) found.push(`routing.tables, a routing table beside the main one is ${unproved}`);
63
+ if (routing?.rules) found.push(`routing.rules, routing rules are ${unproved}`);
50
64
  }
51
65
 
52
66
  const bgp = routing?.bgp;
53
67
 
68
+ for (const [name, group] of Object.entries(bgp?.groups ?? {})) {
69
+ if (group.table && !has("routing-tables"))
70
+ found.push(`routing.bgp.groups.${name}.table, a routing table beside the main one is ${unproved}`);
71
+ }
72
+
54
73
  for (const [name, neighbor] of Object.entries(bgp?.neighbors ?? {})) {
74
+ if (neighbor.table && !has("routing-tables"))
75
+ found.push(`routing.bgp.neighbors.${name}.table, a routing table beside the main one is ${unproved}`);
76
+
55
77
  const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
56
78
  const bfd = neighbor.bfd ?? group?.bfd;
57
79
  const multihop = neighbor.multihop ?? group?.multihop;
@@ -7,7 +7,7 @@ import { FortiOsNames } from "./names";
7
7
  import { FortiOsBlock, FortiOsTree } from "./tree";
8
8
 
9
9
  type AnyRule = PolicyRule<string, string>;
10
- type AnyNeighbor = Neighbor<string, string>;
10
+ type AnyNeighbor = Neighbor<string, string, string>;
11
11
 
12
12
  /** The lengths a prefix list's rule takes within its prefix. */
13
13
  type Lengths = { ge?: number; le?: number };
@@ -167,7 +167,7 @@ export class FortiOsRouting {
167
167
  static readonly BITS = { ipv4: 32, ipv6: 128 } as const;
168
168
 
169
169
  /** A neighbor's settings with its group's beneath them. */
170
- static neighborSettings(device: Device, neighbor: AnyNeighbor): NeighborSettings<string> {
170
+ static neighborSettings(device: Device, neighbor: AnyNeighbor): NeighborSettings<string, string> {
171
171
  const group = neighbor.group ? device.routing?.bgp?.groups?.[neighbor.group] : undefined;
172
172
  return { ...group, ...neighbor };
173
173
  }
@@ -6,7 +6,7 @@ import type { Device, Family, Neighbor, NeighborSettings, PolicyRule } from "../
6
6
  import { JunosRender, type JunosStatements } from "./render";
7
7
 
8
8
  type AnyRule = PolicyRule<string, string>;
9
- type AnyNeighbor = Neighbor<string, string>;
9
+ type AnyNeighbor = Neighbor<string, string, string>;
10
10
 
11
11
  /** BGP sessions, the policies they use, prefix sets and RPKI servers, added to one device's statements. */
12
12
  export class JunosBgp {
@@ -41,7 +41,7 @@ export class JunosBgp {
41
41
  }
42
42
 
43
43
  /** A neighbor's settings with its group's beneath them. */
44
- static settingsOf(device: Device, neighbor: AnyNeighbor): NeighborSettings<string> {
44
+ static settingsOf(device: Device, neighbor: AnyNeighbor): NeighborSettings<string, string> {
45
45
  const group = neighbor.group ? device.routing?.bgp?.groups?.[neighbor.group] : undefined;
46
46
  return { ...group, ...neighbor };
47
47
  }
@@ -1,4 +1,5 @@
1
1
  import type { Neighbor } from "../core/cabling";
2
+ import type { Finding } from "../core/define";
2
3
  import type { Device, Family } from "../schema";
3
4
  import type { ConsoleDialect } from "../transport/console";
4
5
  import type { WaitLimits } from "./bgp-wait";
@@ -100,6 +101,8 @@ export abstract class Platform {
100
101
  readonly console: ConsoleDialect | undefined = undefined;
101
102
  /** Fields this platform cannot express. Validation fails on any of them, so nothing is skipped silently. */
102
103
  abstract unsupported(device: Device): string[];
104
+ /** What the platform does with a config that it may not do elsewhere, each as `validate` reports it: an error stops `diff` and `apply`, a warning is reported only. Absent on a platform with none. */
105
+ checks?(device: Device): Finding[];
103
106
  /** The switch rules the render makes from the device's hardware ACLs, so `validate` can hold them to the model's limits. Absent on a platform whose rules are not counted. */
104
107
  switchRules?(device: Device): SwitchRules;
105
108
  /** The whole configuration the device should run. Secrets appear as markers, never values. */
@@ -63,3 +63,9 @@ Behaviour that looks like something else. Each one has been hit on a real device
63
63
  - `use-bfd=no`, set on a connection that had `use-bfd=yes`, is printed by the export; it is the default, and the plan sets it to take BFD off.
64
64
  - No MAC learning limit on 7.23.5: `/interface bridge port` has `learn` but no limit, and `/interface ethernet switch port` has none (CCR2216). `mac_limit` stays refused.
65
65
  - A switch rule matches a kind of frame with `mac-protocol` (`ip`, `ipv6`, `arp` among 23 kinds), but cannot match "not" one. An ethertype filter is a rule that lets each kind through and a last one with an empty `new-dst-ports` for the rest, after the port's ACL rules, which match only `ip` or `ipv6`. Four such rules on a free port took the router to 70 switch rules, none invalid (CCR2216, 7.23.5).
66
+ - A routing table holds no routes the router forwards by unless it has `fib`: `/routing table add fib name=...`. The export prints the flag between the properties, `add comment=... fib name=...`.
67
+ - A BGP connection's `routing-table` is where the session installs what it learns and also where it announces from: an `output.network` prefix is announced only while that table has a route for it, and a route in main alone is not enough. On a CCR2216 on 7.23.5, a fresh session with `routing-table=<table>` installed its routes there, active, with the next hop resolved through main, and announced nothing until the table had the prefix too; then it did. Circuit's validate refuses a session in a table that holds none of `networks`, and warns for each it lacks. Routing filters cannot move a route to a table: `set routing-table` and `set table` are `invalid argument`.
68
+ - A running session takes a new `routing-table` only when it restarts, but its announcements stop at once: set from main to a table, it withdrew everything within seconds, kept its learned routes in main, and announced nothing even once the table had the prefix; set back to main, it still announced nothing until it restarted. `/routing bgp session resend afi=ipv6` did not help either, on the router where this was found (#157). Disabling and enabling the connection restarted it in the new table. The plan does exactly that, written on the plan's line, and after an apply Circuit checks that every session still announces what it announced of `networks` before (CCR2216, 7.23.5).
69
+ - A passive connection without `local.address` listens only on its default local address, `local.default-address`, which may be a public one: a peer connecting to another address of the router gets a TCP reset (CCR2216, 7.23.5).
70
+ - `/routing rule` is an ordered list without names, like a filter chain, and the plan rewrites it whole in the config's order. `lookup-only-in-table` drops a packet its table has no route for (a ping printed `no route`); `lookup` routes it by main instead (the ping left). An interface to match is `interface=`. A `drop` or `unreachable` rule is printed with `!realm`, which Circuit does not write (CCR2216, 7.23.5).
71
+ - An eBGP connection to an address on no connected network, without `multihop=yes`, takes the TCP connection, logs `connection <local> from <remote>`, and closes it with no OPEN and nothing else in the log, even with `bgp,debug` logging on (CCR2216, 7.23.5).
@@ -137,6 +137,7 @@ export class RouterOsExport {
137
137
  /** Menus whose entries are an ordered list without names. They are rewritten whole, in the order the render gives. */
138
138
  static readonly ORDERED = new Set([
139
139
  "/routing filter rule",
140
+ "/routing rule",
140
141
  "/ip firewall filter",
141
142
  "/ipv6 firewall filter",
142
143
  "/ip firewall nat",