@takodotid/circuit 0.41.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) 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 +121 -47
  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/derive.d.ts +1 -1
  13. package/dist/types/platforms/fortios/routing.d.ts +2 -2
  14. package/dist/types/platforms/junos/bgp.d.ts +2 -2
  15. package/dist/types/platforms/routeros/render.d.ts +4 -0
  16. package/dist/types/schema/device.d.ts +4 -4
  17. package/dist/types/schema/interfaces.d.ts +1 -1
  18. package/dist/types/schema/routing.d.ts +43 -10
  19. package/package.json +1 -1
  20. package/src/cli/index.ts +26 -29
  21. package/src/core/cabling.ts +0 -30
  22. package/src/core/define.ts +3 -1
  23. package/src/core/validator.ts +60 -0
  24. package/src/helpers/cables.ts +20 -15
  25. package/src/helpers/exchange.ts +9 -3
  26. package/src/helpers/upstreams.ts +9 -4
  27. package/src/platforms/derive.ts +23 -1
  28. package/src/platforms/fortios/routing.ts +2 -2
  29. package/src/platforms/junos/bgp.ts +2 -2
  30. package/src/platforms/routeros/TRAPS.md +4 -0
  31. package/src/platforms/routeros/export.ts +1 -0
  32. package/src/platforms/routeros/index.ts +11 -2
  33. package/src/platforms/routeros/plan.ts +3 -0
  34. package/src/platforms/routeros/render.ts +28 -3
  35. package/src/schema/device.ts +4 -3
  36. package/src/schema/interfaces.ts +1 -1
  37. package/src/schema/routing.ts +46 -10
  38. package/templates/colocation/checks.ts +14 -11
  39. package/templates/colocation/edge-01.ts +2 -2
  40. package/templates/colocation/tenancy.ts +50 -71
  41. package/templates/colocation/tenants/acme.ts +10 -10
  42. package/templates/colocation/tenants/index.ts +2 -1
  43. package/templates/colocation/tor-01.ts +2 -2
  44. package/templates/project/AGENTS.md +15 -15
@@ -59,7 +59,7 @@ export type Upstream = {
59
59
  address: IP;
60
60
  local_address?: IP;
61
61
  }>>;
62
- /** 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. */
62
+ /** 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. */
63
63
  group?: string;
64
64
  /** The sessions are more than one hop away, from this address of ours. */
65
65
  multihop?: {
@@ -67,6 +67,8 @@ export type Upstream = {
67
67
  families?: readonly Family[];
68
68
  password?: Secret;
69
69
  };
70
+ /** The routing table its sessions install the routes they learn in, one of the router's `routing.tables`: a transit that carries only one block's traffic out. The main table when absent. */
71
+ 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?: {
72
74
  ipv4?: number;
@@ -143,6 +145,12 @@ type GroupName<C> = Values<{
143
145
  group: infer N extends string;
144
146
  } ? N : never;
145
147
  }>;
148
+ /** Each routing table name. */
149
+ type TableName<C> = Values<{
150
+ [K in keyof C]: C[K] extends {
151
+ table: infer N extends string;
152
+ } ? N : never;
153
+ }>;
146
154
  /** A network's upstreams written once, and each device's part built from them. */
147
155
  export declare class Upstreams {
148
156
  /**
@@ -160,8 +168,8 @@ export declare class Upstreams {
160
168
  ports: (device: string) => Record<string, Port<NativeName<C> | VlanName<C>, never, never, never>>;
161
169
  interfaces: () => { [K in HandoffInterface<C>]: VlanInterface<VlanName<C>, never>; } & { [K in TunnelInterface<C>]: Gre<never>; };
162
170
  uplinks: () => HandoffInterface<C>[];
163
- groups: () => Record<GroupName<C>, NeighborSettings<PolicyName<C>>>;
164
- neighbors: () => Record<string, Neighbor<PolicyName<C>, GroupName<C>>>;
171
+ groups: () => Record<GroupName<C>, NeighborSettings<PolicyName<C>, TableName<C>>>;
172
+ neighbors: () => Record<string, Neighbor<PolicyName<C>, GroupName<C>, TableName<C>>>;
165
173
  policies: () => Record<PolicyName<C>, PolicyRule<never, never>[]>;
166
174
  untrusted: () => (NativeName<C> | VlanName<C>)[];
167
175
  natives: () => NativeName<C>[];
@@ -1,6 +1,6 @@
1
1
  import type { Device } from "../schema/index.js";
2
2
  /** A feature a platform renders once proved on one of its devices. */
3
- export type ProvedFeature = "bfd-timers" | "static-route-bfd" | "multihop-bfd" | "alarm-severity" | "mac-limit" | "ethertype-filter" | "management-vdom";
3
+ export type ProvedFeature = "bfd-timers" | "static-route-bfd" | "multihop-bfd" | "alarm-severity" | "mac-limit" | "ethertype-filter" | "management-vdom" | "routing-tables";
4
4
  export declare class Derive {
5
5
  /** Each LAG's number: its own `id`, or its position among the device's LAGs. */
6
6
  static lagIds(device: Device): Map<string, number>;
@@ -1,7 +1,7 @@
1
1
  import type { Device, Family, Neighbor, NeighborSettings, PolicyRule } from "../../schema/index.js";
2
2
  import { FortiOsBlock } from "./tree.js";
3
3
  type AnyRule = PolicyRule<string, string>;
4
- type AnyNeighbor = Neighbor<string, string>;
4
+ type AnyNeighbor = Neighbor<string, string, string>;
5
5
  /** The lengths a prefix list's rule takes within its prefix. */
6
6
  type Lengths = {
7
7
  ge?: number;
@@ -23,7 +23,7 @@ export declare class FortiOsRouting {
23
23
  readonly ipv6: 128;
24
24
  };
25
25
  /** A neighbor's settings with its group's beneath them. */
26
- static neighborSettings(device: Device, neighbor: AnyNeighbor): NeighborSettings<string>;
26
+ static neighborSettings(device: Device, neighbor: AnyNeighbor): NeighborSettings<string, string>;
27
27
  /** The families a session carries: its own, or the family of its address. */
28
28
  static familiesOfNeighbor(device: Device, neighbor: AnyNeighbor): Family[];
29
29
  /** Whether a static route, OSPF or BGP detects a dead neighbor with BFD, which the device then runs. */
@@ -1,6 +1,6 @@
1
1
  import type { Device, Neighbor, NeighborSettings } from "../../schema/index.js";
2
2
  import { type JunosStatements } from "./render.js";
3
- type AnyNeighbor = Neighbor<string, string>;
3
+ type AnyNeighbor = Neighbor<string, string, string>;
4
4
  /** BGP sessions, the policies they use, prefix sets and RPKI servers, added to one device's statements. */
5
5
  export declare class JunosBgp {
6
6
  private readonly out;
@@ -19,7 +19,7 @@ export declare class JunosBgp {
19
19
  /** Add the device's prefix sets, RPKI servers, BGP sessions and the policies they use. */
20
20
  static render(out: JunosStatements, device: Device): void;
21
21
  /** A neighbor's settings with its group's beneath them. */
22
- static settingsOf(device: Device, neighbor: AnyNeighbor): NeighborSettings<string>;
22
+ static settingsOf(device: Device, neighbor: AnyNeighbor): NeighborSettings<string, string>;
23
23
  /** A name made of a value's words, for a community or a path-length list: `64500:*` becomes `64500-any`. */
24
24
  private static slug;
25
25
  /** A community as the platform writes a member: a large community carries the `large:` prefix. */
@@ -199,6 +199,10 @@ export declare class RouterOsRender {
199
199
  /** Every switch rule the device's hardware ACLs make, in order: one for each port with an ACL, each rule of it, and each source and destination port or range of the rule. */
200
200
  static switchRules(device: Device): SwitchRule[];
201
201
  private routing;
202
+ /** A table beside the main one. `fib` puts its routes in the forwarding table, so traffic can be routed by them. */
203
+ private routingTables;
204
+ /** 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. */
205
+ private routingRules;
202
206
  private staticRoutes;
203
207
  private rpki;
204
208
  /** The default instance exists on every device; it is stated in the form the export prints it. */
@@ -65,9 +65,9 @@ export type Vrf = {
65
65
  /**
66
66
  * Everything one device runs. What is not written here is removed from the device or returned to its default.
67
67
  *
68
- * 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.
68
+ * 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.
69
69
  */
70
- export type Device<Pl extends string = string, M extends string = string, N extends string = string, V extends string = string, I extends string = string, P extends string = string, S extends string = string, G extends string = string, A extends string = string, AS extends string = string, U extends string = string, F extends string = string, C extends string = string> = {
70
+ export type Device<Pl extends string = string, M extends string = string, N extends string = string, V extends string = string, I extends string = string, P extends string = string, S extends string = string, G extends string = string, A extends string = string, AS extends string = string, U extends string = string, F extends string = string, C extends string = string, T extends string = string> = {
71
71
  /** Hostname. */
72
72
  name: string;
73
73
  /** Operating system. It decides which adapter renders and applies the device. */
@@ -92,8 +92,8 @@ export type Device<Pl extends string = string, M extends string = string, N exte
92
92
  interfaces?: Record<I, Interface<NoInfer<V>, NoInfer<F>>>;
93
93
  /** Separate routing tables, keyed by name. */
94
94
  vrfs?: Record<F, Vrf>;
95
- /** Static routes, BGP, OSPF and route validation. */
96
- routing?: Routing<NoInfer<P>, G, NoInfer<I | N>, NoInfer<F>>;
95
+ /** Routing tables and rules, static routes, BGP, OSPF and route validation. */
96
+ routing?: Routing<NoInfer<P>, G, NoInfer<I | N>, NoInfer<F>, T>;
97
97
  /** Route policies, keyed by name. */
98
98
  policies?: Record<P, RoutePolicy<NoInfer<P>, NoInfer<S>>>;
99
99
  /** Prefix sets that policies match on, keyed by name. */
@@ -109,7 +109,7 @@ export type Port<V extends string, A extends string, L extends string, F extends
109
109
  acl?: A;
110
110
  /** The LAG this port is in. Every other setting goes on the LAG. */
111
111
  lag?: L;
112
- /** 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`. */
112
+ /** Another of our devices at the other end of the cable. */
113
113
  link?: Link;
114
114
  };
115
115
  /** Settings every interface that is not a physical port shares. */
@@ -1,6 +1,6 @@
1
1
  import type { Family, IP, Prefix, Secret } from "./common.js";
2
2
  /** A route the device has without learning it. */
3
- export type StaticRoute<I extends string, F extends string> = {
3
+ export type StaticRoute<I extends string, F extends string, T extends string = never> = {
4
4
  /** Destination. */
5
5
  prefix: Prefix;
6
6
  /** Next-hop address. */
@@ -13,8 +13,10 @@ export type StaticRoute<I extends string, F extends string> = {
13
13
  bfd?: true;
14
14
  /** Preference against other routes to the same prefix. Lower wins. 1 when absent. */
15
15
  distance?: number;
16
- /** The VRF the route is in. The default table when absent. */
16
+ /** The VRF the route is in. The main table when absent. */
17
17
  vrf?: F;
18
+ /** The table the route is in, one of `routing.tables`. The main table when absent. A route has a `vrf` or a `table`, not both. */
19
+ table?: T;
18
20
  /** Why the route exists. */
19
21
  description?: string;
20
22
  };
@@ -23,7 +25,7 @@ export type StaticRoute<I extends string, F extends string> = {
23
25
  */
24
26
  export type BgpRole = "provider" | "customer" | "peer" | "rs" | "rs-client";
25
27
  /** Settings a BGP neighbor can set itself or take from its group. */
26
- export type NeighborSettings<P extends string> = {
28
+ export type NeighborSettings<P extends string, T extends string = never> = {
27
29
  /** The neighbor's AS. Our own AS makes the session internal. */
28
30
  remote_as?: number;
29
31
  /** Who the neighbor is. */
@@ -52,24 +54,26 @@ export type NeighborSettings<P extends string> = {
52
54
  passive?: boolean;
53
55
  /** Detect a dead link in under a second with BFD. */
54
56
  bfd?: boolean;
57
+ /** The table the routes learned are installed in, one of `routing.tables`. The main table when absent. What is announced still comes from `networks` and the main table. */
58
+ table?: T;
55
59
  };
56
60
  /** One BGP session. Settings it does not set come from its group. */
57
- export type Neighbor<P extends string, G extends string> = NeighborSettings<P> & {
61
+ export type Neighbor<P extends string, G extends string, T extends string = never> = NeighborSettings<P, T> & {
58
62
  /** The neighbor's address. */
59
63
  address: IP;
60
64
  /** The group this neighbor takes a setting from when it does not set that setting itself. */
61
65
  group?: NoInfer<G>;
62
66
  };
63
67
  /** BGP on this device. */
64
- export type Bgp<P extends string, G extends string> = {
68
+ export type Bgp<P extends string, G extends string, T extends string = never> = {
65
69
  /** Our AS number. */
66
70
  asn: number;
67
71
  /** Prefixes this device originates. Each needs a route, for example a static blackhole. Export policy still decides who receives them. */
68
72
  networks?: readonly Prefix[];
69
73
  /** Settings shared by several neighbors, keyed by group name. */
70
- groups?: Record<G, NeighborSettings<P>>;
74
+ groups?: Record<G, NeighborSettings<P, T>>;
71
75
  /** Sessions keyed by name. */
72
- neighbors?: Record<string, Neighbor<P, G>>;
76
+ neighbors?: Record<string, Neighbor<P, G, T>>;
73
77
  };
74
78
  /** How fast BFD notices a dead neighbor: after `multiplier` packets in a row are missed, so in `interval` times `multiplier` milliseconds. */
75
79
  export type Bfd = {
@@ -111,14 +115,43 @@ export type Ospf<I extends string> = {
111
115
  /** Routes from other sources announced into OSPF. */
112
116
  redistribute?: readonly ("connected" | "static")[];
113
117
  };
118
+ /** 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. */
119
+ export type RoutingTable = {
120
+ /** What the table is for. */
121
+ description?: string;
122
+ };
123
+ /** Traffic a routing rule matches: all of what is given. Every packet when absent. */
124
+ export type RoutingRuleMatch<I extends string> = {
125
+ /** Source prefix. */
126
+ src?: Prefix;
127
+ /** Destination prefix. */
128
+ dst?: Prefix;
129
+ /** The interface the traffic arrives on. */
130
+ in_interface?: I;
131
+ };
132
+ /** One routing rule: traffic it matches is routed by `table` instead of the main table. */
133
+ export type RoutingRule<I extends string, T extends string> = {
134
+ /** Why the rule exists. */
135
+ description?: string;
136
+ /** What the traffic must match. Every packet when absent, so such a rule is the last one. */
137
+ match?: RoutingRuleMatch<I>;
138
+ /** The table that routes the traffic: one of `routing.tables`, or `main`. */
139
+ table: T | "main";
140
+ /** 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`. */
141
+ fallback?: "none" | "main";
142
+ };
114
143
  /** Routing on this device. */
115
- export type Routing<P extends string, G extends string, I extends string, F extends string> = {
144
+ export type Routing<P extends string, G extends string, I extends string, F extends string, T extends string = never> = {
116
145
  /** Identifies this router to its neighbors. */
117
146
  router_id?: IP;
147
+ /** Routing tables beside the main one, keyed by name. Not `main`, and not the name of a VRF. */
148
+ tables?: Record<T, RoutingTable>;
149
+ /** 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. */
150
+ rules?: readonly RoutingRule<I, NoInfer<T>>[];
118
151
  /** Routes the device has without learning them. */
119
- static?: readonly StaticRoute<I, F>[];
152
+ static?: readonly StaticRoute<I, F, NoInfer<T>>[];
120
153
  /** BGP sessions and what they exchange. */
121
- bgp?: Bgp<P, G>;
154
+ bgp?: Bgp<P, G, NoInfer<T>>;
122
155
  /** OSPF areas and interfaces. */
123
156
  ospf?: Ospf<I>;
124
157
  /** Validators answering route origin validation. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takodotid/circuit",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "description": "Declarative, vendor-neutral network configuration: describe each device in TypeScript, and Circuit makes the device match. Alpha.",
5
5
  "keywords": [
6
6
  "network",
package/src/cli/index.ts CHANGED
@@ -103,20 +103,12 @@ class Cli {
103
103
  return new Cli(configPath, network, options);
104
104
  }
105
105
 
106
- /**
107
- * The config, a TypeScript file. Bun imports it as it is; Node needs jiti, which also resolves imports written without an extension. A linked port without a description is given one from its far end.
108
- */
106
+ /** The config, a TypeScript file. Bun imports it as it is; Node needs jiti, which also resolves imports written without an extension. */
109
107
  private static async load(path: string): Promise<Network> {
110
- let network: Network;
111
-
112
- if (process.versions.bun) {
113
- network = (await import(path)).default;
114
- } else {
115
- const { createJiti } = await import("jiti");
116
- network = await createJiti(import.meta.url).import<Network>(path, { default: true });
117
- }
108
+ if (process.versions.bun) return (await import(path)).default;
118
109
 
119
- return { ...network, devices: Cabling.describeLinks(network.devices) };
110
+ const { createJiti } = await import("jiti");
111
+ return createJiti(import.meta.url).import(path, { default: true });
120
112
  }
121
113
 
122
114
  private static fail(message: string): never {
@@ -238,7 +230,9 @@ class Cli {
238
230
  .command("bgp")
239
231
  .description("each BGP neighbor in the config next to its live session: up or down and why, prefixes, uptime")
240
232
  .argument("[device]", "may be left out when only one device has BGP neighbors")
241
- .argument("[name-regex]", "only the neighbors whose name matches")
233
+ // A second argument was the name filter before 0.42; it is refused below with the way to write it now.
234
+ .allowExcessArguments()
235
+ .option("--neighbors <regex>", "only the neighbors whose name matches")
242
236
  .option(
243
237
  "--wait [count]",
244
238
  `read again every ${BgpWait.COMMAND.every} seconds until every neighbor shown, or this many, is up`,
@@ -248,7 +242,14 @@ class Cli {
248
242
  .option("--log [minutes]", "also print the device's BGP log lines from the last minutes, 5 by default", Cli.minutes)
249
243
  .option("--clear-limit", "open again the neighbors shown that closed on their prefix limit; any other is left alone")
250
244
  .option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
251
- .action(async (first, second, flags) => (await Cli.open(flags.config)).bgp(first, second, flags));
245
+ .action(async (device, flags, command) => {
246
+ const [, filter] = command.args;
247
+ if (filter !== undefined)
248
+ Cli.fail(
249
+ `circuit bgp takes one device; filter the neighbors with --neighbors: circuit bgp ${device} --neighbors '${filter}'`
250
+ );
251
+ (await Cli.open(flags.config)).bgp(device, flags);
252
+ });
252
253
 
253
254
  program
254
255
  .command("console")
@@ -613,14 +614,14 @@ class Cli {
613
614
  }
614
615
 
615
616
  /**
616
- * Each BGP neighbor in the config next to its live session. `first` is the device, or, when it names none and only one device has BGP neighbors, the name pattern. With `wait`, reads again until every neighbor shown, or that many, is up, and fails when time runs out. With `clearLimit`, opens again the neighbors shown that closed on their prefix limit, and fails when none did.
617
+ * Each BGP neighbor in the config next to its live session, on the device named or the only one with BGP neighbors, and only those whose name matches `neighbors` when given. With `wait`, reads again until every neighbor shown, or that many, is up, and fails when time runs out. With `clearLimit`, opens again the neighbors shown that closed on their prefix limit, and fails when none did.
617
618
  */
618
619
  private async bgp(
619
- first: string | undefined,
620
- second: string | undefined,
621
- flags: { wait?: true | number; max: number; log?: true | number; clearLimit?: true }
620
+ deviceName: string | undefined,
621
+ flags: { neighbors?: string; wait?: true | number; max: number; log?: true | number; clearLimit?: true }
622
622
  ): Promise<void> {
623
- const { device, pattern } = this.bgpTarget(first, second);
623
+ const device = this.bgpDevice(deviceName);
624
+ const pattern = flags.neighbors;
624
625
  const adapter = this.adapterOf(device);
625
626
 
626
627
  const supported = Platforms.all()
@@ -697,19 +698,15 @@ class Cli {
697
698
  Cli.printBgp(await read());
698
699
  }
699
700
 
700
- /** The device `circuit bgp` reads, and the name pattern, from its two optional arguments. */
701
- private bgpTarget(first: string | undefined, second: string | undefined): { device: Device; pattern: string | undefined } {
702
- if (second !== undefined || (first && this.network.devices.some((device) => device.name === first))) {
703
- return { device: this.one(first!), pattern: second };
704
- }
701
+ /** The device `circuit bgp` reads: the one named, or the only device with BGP neighbors. */
702
+ private bgpDevice(name: string | undefined): Device {
703
+ if (name !== undefined) return this.one(name);
705
704
 
706
705
  const withBgp = this.network.devices.filter((device) => Object.keys(device.routing?.bgp?.neighbors ?? {}).length);
707
- const named = first ? `no device ${first}, and ` : "";
708
-
709
- if (withBgp.length === 1) return { device: withBgp[0]!, pattern: first };
710
- if (!withBgp.length) Cli.fail(`${named}no device has BGP neighbors`);
706
+ if (withBgp.length === 1) return withBgp[0]!;
707
+ if (!withBgp.length) Cli.fail("no device has BGP neighbors");
711
708
 
712
- return Cli.fail(`${named}several devices have BGP neighbors, so name one: ${withBgp.map((device) => device.name).join(", ")}`);
709
+ return Cli.fail(`several devices have BGP neighbors, so name one: ${withBgp.map((device) => device.name).join(", ")}`);
713
710
  }
714
711
 
715
712
  /** One line per neighbor: name, up or down, prefixes received, uptime, remote address. Then how many are up. */
@@ -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;
@@ -291,17 +291,29 @@ export class Validator {
291
291
  if (route.bfd && !route.via) report(`static route ${route.prefix} has bfd without via, the address BFD watches`);
292
292
  names.expectInterface(`static route ${route.prefix} interface`, route.interface);
293
293
  names.expect(`static route ${route.prefix} VRF`, route.vrf, route.vrf !== undefined && route.vrf in (device.vrfs ?? {}));
294
+ names.expect(
295
+ `static route ${route.prefix} table`,
296
+ route.table,
297
+ route.table !== undefined && route.table in (routing?.tables ?? {})
298
+ );
299
+ if (route.vrf && route.table) report(`static route ${route.prefix} has both vrf and table; a route is in one of them`);
294
300
  }
295
301
 
302
+ Validator.checkTables(device, names, report, cidr);
303
+
296
304
  const bgp = routing?.bgp;
297
305
  const isPolicy = (policy: string | undefined) => policy !== undefined && policy in policies;
298
306
 
299
307
  for (const prefix of bgp?.networks ?? []) cidr("BGP network", prefix);
300
308
 
309
+ const isTable = (table: string | undefined) => table !== undefined && table in (routing?.tables ?? {});
310
+
301
311
  for (const [name, group] of Object.entries(bgp?.groups ?? {})) {
302
312
  for (const policy of [group.import, group.export]) {
303
313
  names.expect(`BGP group ${name} policy`, policy, isPolicy(policy));
304
314
  }
315
+
316
+ names.expect(`BGP group ${name} table`, group.table, isTable(group.table));
305
317
  }
306
318
 
307
319
  for (const [name, neighbor] of Object.entries(bgp?.neighbors ?? {})) {
@@ -316,6 +328,8 @@ export class Validator {
316
328
  names.expect(`neighbor ${name} policy`, policy, isPolicy(policy));
317
329
  }
318
330
 
331
+ names.expect(`neighbor ${name} table`, neighbor.table, isTable(neighbor.table));
332
+
319
333
  const local = neighbor.local_address ?? group?.local_address;
320
334
  if (local && Address.family(local) !== Address.family(neighbor.address))
321
335
  report(`neighbor ${name} local_address and address differ in family`);
@@ -327,6 +341,52 @@ export class Validator {
327
341
  }
328
342
  }
329
343
 
344
+ /** Routing tables have names of their own and something installing routes in them, and rules name a table and can each be reached. */
345
+ private static checkTables(device: Device, names: Names, report: Report, cidr: Checker): void {
346
+ const routing = device.routing;
347
+ const tables = routing?.tables ?? {};
348
+ const bgp = routing?.bgp;
349
+
350
+ const installed = new Set<string>();
351
+ for (const route of routing?.static ?? []) if (route.table) installed.add(route.table);
352
+ for (const neighbor of Object.values(bgp?.neighbors ?? {})) {
353
+ const table = neighbor.table ?? (neighbor.group ? bgp?.groups?.[neighbor.group]?.table : undefined);
354
+ if (table) installed.add(table);
355
+ }
356
+
357
+ for (const name of Object.keys(tables)) {
358
+ if (name === "main") report("routing.tables has main, the main table every device has; give the table another name");
359
+ if (name in (device.vrfs ?? {})) report(`routing.tables ${name} has the name of a VRF; give the table another name`);
360
+ if (name !== "main" && !installed.has(name))
361
+ report(
362
+ `routing.tables ${name}: nothing installs routes in it; give a static route or a BGP neighbor table: "${name}", or remove the table`
363
+ );
364
+ }
365
+
366
+ const rules = routing?.rules ?? [];
367
+ rules.forEach((rule, index) => {
368
+ const where = `routing.rules ${index + 1}${rule.description ? ` (${rule.description})` : ""}`;
369
+ const match = rule.match ?? {};
370
+
371
+ names.expect(`${where} table`, rule.table, rule.table === "main" || rule.table in tables);
372
+ names.expectInterface(`${where} in_interface`, match.in_interface);
373
+ if (match.src) cidr(where, match.src);
374
+ if (match.dst) cidr(where, match.dst);
375
+
376
+ if (match.src && match.dst && Address.isCidr(match.src) && Address.isCidr(match.dst)) {
377
+ if (Address.family(Address.host(match.src)) !== Address.family(Address.host(match.dst)))
378
+ report(`${where}: src and dst differ in family, so no packet matches both`);
379
+ }
380
+
381
+ if (rule.table === "main" && rule.fallback)
382
+ report(`${where} looks up main and has fallback; fallback is for another table, so remove it`);
383
+
384
+ const matchesAll = !match.src && !match.dst && !match.in_interface;
385
+ if (matchesAll && index < rules.length - 1)
386
+ report(`${where} matches every packet, so the rules after it never run; give it a match, or move it last`);
387
+ });
388
+ }
389
+
330
390
  private static checkPolicies(device: Device, names: Names, report: Report, cidr: Checker): void {
331
391
  const policies = device.policies ?? {};
332
392
  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 } : {}),