@takodotid/circuit 0.23.0 → 0.25.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.
@@ -609,6 +609,7 @@ export function unsupported(device: Device): string[] {
609
609
  if (iface.type !== "vlan" && iface.type !== "lag") found.push(`interfaces.${name}, a ${iface.type}`);
610
610
  if (iface.type === "lag" && iface.addresses?.length) found.push(`interfaces.${name}.addresses, LAGs are switched`);
611
611
  if (iface.mtu !== undefined) found.push(`interfaces.${name}.mtu`);
612
+ if (iface.ipv6_ra) found.push(`interfaces.${name}.ipv6_ra`);
612
613
  for (const group of iface.vrrp ?? []) if (familyOf(group.address) === "ipv6") found.push(`interfaces.${name}.vrrp for IPv6`);
613
614
  }
614
615
 
@@ -621,6 +622,10 @@ export function unsupported(device: Device): string[] {
621
622
  if (routing?.rpki) found.push("routing.rpki");
622
623
  if (routing?.ospf?.families?.includes("ipv6")) found.push("routing.ospf for IPv6");
623
624
 
625
+ for (const [index, route] of (routing?.static ?? []).entries()) {
626
+ if (route.description && familyOf(split(route.prefix).addr) === "ipv6") found.push(`routing.static[${index}].description for IPv6`);
627
+ }
628
+
624
629
  for (const field of ["firewall", "dhcp", "acls", "policies", "prefix_sets", "hardware_offload", "certificates"] as const) {
625
630
  if (device[field] !== undefined) found.push(field);
626
631
  }
@@ -25,16 +25,28 @@ export function checkAcross(devices: readonly Device[]): Finding[] {
25
25
  ];
26
26
  }
27
27
 
28
- /** The VLAN ids a port carries, by how it carries them, so two ends can be compared across devices. */
29
- function carried(device: Device, port: Port<string, string, string, string>) {
28
+ /** The VLAN ids a port or LAG carries, by how it carries them, so two ends can be compared across devices. */
29
+ function carried(device: Device, settings: Switched<string>) {
30
30
  const id = (vlan: string | undefined) => (vlan ? device.vlans?.[vlan]?.id : undefined);
31
31
  return {
32
- access: id(port.access_vlan),
33
- native: id(port.native_vlan),
34
- trunk: (port.trunk_vlans ?? []).map(id).sort((a, b) => (a ?? 0) - (b ?? 0)),
32
+ access: id(settings.access_vlan),
33
+ native: id(settings.native_vlan),
34
+ trunk: (settings.trunk_vlans ?? []).map(id).sort((a, b) => (a ?? 0) - (b ?? 0)),
35
35
  };
36
36
  }
37
37
 
38
+ /** How a port carries its VLANs, in words: "VLANs 10, 20 tagged and VLAN 30 untagged as native". */
39
+ function describeCarried({ access, native, trunk }: ReturnType<typeof carried>): string {
40
+ const parts: string[] = [];
41
+ const tagged = trunk.filter((id) => id !== undefined);
42
+
43
+ if (access !== undefined) parts.push(`VLAN ${access} untagged`);
44
+ if (tagged.length) parts.push(`${tagged.length === 1 ? "VLAN" : "VLANs"} ${tagged.join(", ")} tagged`);
45
+ if (native !== undefined) parts.push(`VLAN ${native} untagged as native`);
46
+
47
+ return parts.length ? parts.join(" and ") : "no VLANs";
48
+ }
49
+
38
50
  /** VLAN names by id, for the VLANs a port or LAG carries. */
39
51
  function vlanNames(device: Device, settings: Switched<string>): Map<number, string> {
40
52
  const names = [settings.access_vlan, settings.native_vlan, ...(settings.trunk_vlans ?? [])].filter((name) => name !== undefined);
@@ -114,17 +126,30 @@ function checkLinks(devices: readonly Device[]): Finding[] {
114
126
  continue;
115
127
  }
116
128
 
117
- const here = JSON.stringify(carried(device, port));
118
- const there = JSON.stringify(carried(far, farPort));
119
- if (here !== there) report(`carries ${here}, and ${link.device} ${link.port} carries ${there}`);
129
+ const near = carrierOf(device, portName, port);
130
+ const away = carrierOf(far, link.port, farPort);
131
+
132
+ // A LAG's VLANs are on the LAG, so its member ports are compared by what their LAGs carry.
133
+ const inLag = near.name !== portName;
134
+ const farInLag = away.name !== link.port;
135
+ if (inLag !== farInLag) {
136
+ report(
137
+ inLag
138
+ ? `${portName} is in ${near.name}, and ${link.device} ${link.port} is in no LAG`
139
+ : `${portName} is in no LAG, and ${link.device} ${link.port} is in ${away.name}`
140
+ );
141
+ }
142
+
143
+ const here = carried(device, near.settings);
144
+ const there = carried(far, away.settings);
145
+ if (JSON.stringify(here) !== JSON.stringify(there)) {
146
+ report(`${near.name} carries ${describeCarried(here)}, and ${link.device} ${away.name} carries ${describeCarried(there)}`);
147
+ }
120
148
 
121
149
  if (port.speed && farPort.speed && port.speed !== farPort.speed) {
122
150
  report(`speed ${port.speed}, and ${link.device} ${link.port} is ${farPort.speed}`);
123
151
  }
124
152
 
125
- const near = carrierOf(device, portName, port);
126
- const away = carrierOf(far, link.port, farPort);
127
-
128
153
  const mtu = near.settings.mtu ?? 1500;
129
154
  const farMtu = away.settings.mtu ?? 1500;
130
155
  if (mtu !== farMtu) report(`MTU ${mtu} on ${near.name}, and ${link.device} ${away.name} has ${farMtu}`);
@@ -144,7 +169,7 @@ function checkLinks(devices: readonly Device[]): Finding[] {
144
169
  return findings;
145
170
  }
146
171
 
147
- /** No address is held by two devices. Two loopbacks may hold one on purpose, for anycast, so that is a warning. */
172
+ /** No address is held by two devices in one VRF. Two loopbacks may hold one on purpose, for anycast, so that is a warning. */
148
173
  function checkAddresses(holders: Holder[]): Finding[] {
149
174
  const findings: Finding[] = [];
150
175
  const first = new Map<string, Holder>();
@@ -152,7 +177,7 @@ function checkAddresses(holders: Holder[]): Finding[] {
152
177
  const isLoopback = (holder: Holder) => holder.device.interfaces?.[holder.name]?.type === "loopback";
153
178
 
154
179
  for (const holder of holders) {
155
- const key = keyOf(host(holder.address))!;
180
+ const key = `${holder.vrf ?? ""} ${keyOf(host(holder.address))!}`;
156
181
  const earlier = first.get(key);
157
182
 
158
183
  if (!earlier) {
@@ -296,13 +321,16 @@ function checkTunnels(devices: readonly Device[], owners: Map<string, Holder>):
296
321
  findings.push({ level: "error", device: end.device.name, message: `interface ${end.name}: ${message}` });
297
322
  const kind = end.iface.type === "gre" ? "GRE" : "VXLAN";
298
323
 
299
- const mirror = ends.find(
324
+ // Two routers may run several VXLAN tunnels between them, one for each VNI, so a tunnel pairs with the one of its own VNI first.
325
+ const back = ends.filter(
300
326
  (other) =>
301
327
  other.device === far &&
302
328
  other.iface.type === end.iface.type &&
303
329
  same(other.iface.local, end.iface.remote) &&
304
330
  same(other.iface.remote, end.iface.local)
305
331
  );
332
+ const vniOf = (iface: Gre<string> | Vxlan<string>) => (iface.type === "vxlan" ? iface.vni : undefined);
333
+ const mirror = back.find((other) => vniOf(other.iface) === vniOf(end.iface)) ?? back[0];
306
334
 
307
335
  if (!mirror) {
308
336
  report(`${end.iface.remote} is ${far.name}'s, which has no ${kind} tunnel from there back to ${end.iface.local}`);
package/src/core/graph.ts CHANGED
@@ -72,6 +72,8 @@ export type Holder = {
72
72
  /** The far end of a point-to-point address. */
73
73
  peer?: string;
74
74
  vrrp: NonNullable<Routed<string>["vrrp"]>;
75
+ /** The VRF the address is in. The default table when absent. */
76
+ vrf?: string;
75
77
  };
76
78
 
77
79
  export const deviceId = (name: string) => `device:${name}`;
@@ -124,7 +126,7 @@ export function holdersOf(devices: readonly Device[]): Holder[] {
124
126
  if (!isCidr(address)) return [];
125
127
 
126
128
  const peer = typeof entry === "string" ? undefined : entry.peer;
127
- return [{ device, name, address, peer, vrrp: settings.vrrp ?? [] }];
129
+ return [{ device, name, address, peer, vrrp: settings.vrrp ?? [], vrf: settings.vrf }];
128
130
  })
129
131
  )
130
132
  );
@@ -101,7 +101,7 @@ function checkVlans(device: Device, report: Report): void {
101
101
  }
102
102
  }
103
103
 
104
- /** Every address is valid, and no address sits on two interfaces. */
104
+ /** Every address is valid, and no address sits on two interfaces of one routing table. Two VRFs may hold the same address. */
105
105
  function checkAddresses(device: Device, report: Report, cidr: Checker, ip: Checker): void {
106
106
  const holderOf = new Map<string, string>();
107
107
  const holders = [
@@ -116,8 +116,9 @@ function checkAddresses(device: Device, report: Report, cidr: Checker, ip: Check
116
116
  if (typeof entry !== "string") ip(where, entry.peer);
117
117
 
118
118
  const bare = address.split("/")[0]!;
119
- if (holderOf.has(bare)) report(`${bare} is on both ${holderOf.get(bare)} and ${where}`);
120
- holderOf.set(bare, where);
119
+ const key = `${settings?.vrf ?? ""} ${bare}`;
120
+ if (holderOf.has(key)) report(`${bare} is on both ${holderOf.get(key)} and ${where}`);
121
+ holderOf.set(key, where);
121
122
  }
122
123
 
123
124
  for (const group of settings?.vrrp ?? []) {
@@ -496,7 +496,7 @@ export type InternetExchange<V extends string> = {
496
496
  * Member ports and a check for an internet exchange's fabric.
497
497
  *
498
498
  * - `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.
499
- * - `check`: members never share a name, an AS or an address; every address is inside the peering LAN and not reserved; every member port carries the peering VLAN alone.
499
+ * - `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.
500
500
  */
501
501
  export function internetExchange<const V extends string>(exchange: InternetExchange<V>, members: readonly ExchangeMember[]) {
502
502
  const percent = exchange.storm_percent ?? 1;
@@ -524,6 +524,8 @@ export function internetExchange<const V extends string>(exchange: InternetExcha
524
524
  const findings: Finding[] = [];
525
525
  const error = (message: string, device?: string) => findings.push({ level: "error", ...(device ? { device } : {}), message });
526
526
  const taken = new Map<string, string>((exchange.reserved ?? []).map((address) => [address, "reserved"]));
527
+ // `ports()` writes one port per name, so a second member on a port would silently replace the first.
528
+ const portHolders = new Map<string, string>();
527
529
 
528
530
  members.forEach((member, index) => {
529
531
  for (const other of members.slice(index + 1)) {
@@ -544,6 +546,10 @@ export function internetExchange<const V extends string>(exchange: InternetExcha
544
546
  }
545
547
 
546
548
  for (const [deviceName, portName] of Object.entries(member.ports)) {
549
+ const holder = portHolders.get(`${deviceName} ${portName}`);
550
+ if (holder) error(`port ${portName} is given to both ${holder} and ${member.name}`, deviceName);
551
+ portHolders.set(`${deviceName} ${portName}`, member.name);
552
+
547
553
  const device = devices.find((candidate) => candidate.name === deviceName);
548
554
  const port = device?.ports?.[portName as keyof typeof device.ports];
549
555
  const carried = port ? vlansOf(port) : [];
@@ -12,7 +12,7 @@ export default [
12
12
  // Traffic from the transit and the exchange reaches the tenants only through the router, which filters it.
13
13
  trustBoundary({ untrusted: ["transit", "ix"], routers: ["edge-01"] }),
14
14
 
15
- // Tenants never share a name, a number or an address.
15
+ // Tenants never share a name, a number, an address or a port. A port given twice would go to the last tenant alone.
16
16
  () => {
17
17
  const findings: Finding[] = [];
18
18
  const error = (message: string) => findings.push({ level: "error", message });
@@ -28,6 +28,13 @@ export default [
28
28
  }
29
29
  }
30
30
 
31
+ for (const [device, ports] of Object.entries(t.ports)) {
32
+ for (const port of Object.keys(ports)) {
33
+ const sharer = TENANTS.slice(index + 1).find((other) => other.ports[device]?.[port]);
34
+ if (sharer) error(`port ${port} on ${device} is given to both ${t.name} and ${sharer.name}`);
35
+ }
36
+ }
37
+
31
38
  for (const { prefix, gateway } of t.blocks) {
32
39
  if (!contains(prefix, gateway)) error(`the gateway ${gateway} of ${t.name} is outside ${prefix}`);
33
40
  }
@@ -69,16 +69,13 @@ export default defineDevice({
69
69
  { description: "DNS from guests", match: { in_interface: "guests", protocol: "udp", dst_port: 53 }, action: "accept" },
70
70
  ],
71
71
  },
72
- // Traffic passing through the router.
72
+ // Traffic passing through the router: home and guests reach the internet, and nothing else gets through.
73
73
  forward: {
74
+ default: "drop",
74
75
  rules: [
75
76
  { description: "Replies", match: { state: ["established", "related"] }, action: "accept", offload: true },
76
- {
77
- description: "Guests stay off the home network",
78
- match: { in_interface: "guests", out_interface: "home" },
79
- action: "drop",
80
- },
81
- { description: "Guests stay off management", match: { in_interface: "guests", out_interface: "mgmt" }, action: "drop" },
77
+ { description: "Home to the internet", match: { in_interface: "home", out_interface: "1g-1" }, action: "accept" },
78
+ { description: "Guests to the internet", match: { in_interface: "guests", out_interface: "1g-1" }, action: "accept" },
82
79
  ],
83
80
  },
84
81
  },