@takodotid/circuit 0.23.0 → 0.24.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.
package/README.md CHANGED
@@ -25,6 +25,15 @@ Documentation: [circuit.tako.id](https://circuit.tako.id)
25
25
  <img src="docs/public/screenshots/studio-light.png" alt="Studio's VLAN graph with devices that differ from their snapshots marked by orange dashed borders, beside the layer details and legend.">
26
26
  </picture>
27
27
 
28
+ ## Circuit Academy
29
+
30
+ New to networking? [Circuit Academy](https://circuit.tako.id/academy/) teaches it from the very start, in lessons you try in the browser rather than only read: packets, addresses, switches and routers first, then VLANs, DHCP, NAT and firewalls written down in Circuit, up to BGP and an edge router of your own. A certification is coming.
31
+
32
+ <picture>
33
+ <source media="(prefers-color-scheme: dark)" srcset="docs/public/screenshots/academy-dark.png">
34
+ <img src="docs/public/screenshots/academy-light.png" alt="A Circuit Academy lesson on switches: a frame from a laptop to a printer has just been flooded out of every port, and the switch has learned which port the laptop is behind.">
35
+ </picture>
36
+
28
37
  ## Start
29
38
 
30
39
  You need [Node.js](https://nodejs.org) 20.12 or newer, or [Bun](https://bun.sh). Then:
package/dist/cli/index.js CHANGED
@@ -3256,6 +3256,8 @@ function unsupported3(device) {
3256
3256
  found.push(`interfaces.${name}.addresses, LAGs are switched`);
3257
3257
  if (iface.mtu !== undefined)
3258
3258
  found.push(`interfaces.${name}.mtu`);
3259
+ if (iface.ipv6_ra)
3260
+ found.push(`interfaces.${name}.ipv6_ra`);
3259
3261
  for (const group of iface.vrrp ?? [])
3260
3262
  if (familyOf2(group.address) === "ipv6")
3261
3263
  found.push(`interfaces.${name}.vrrp for IPv6`);
@@ -3271,6 +3273,10 @@ function unsupported3(device) {
3271
3273
  found.push("routing.rpki");
3272
3274
  if (routing?.ospf?.families?.includes("ipv6"))
3273
3275
  found.push("routing.ospf for IPv6");
3276
+ for (const [index, route] of (routing?.static ?? []).entries()) {
3277
+ if (route.description && familyOf2(split(route.prefix).addr) === "ipv6")
3278
+ found.push(`routing.static[${index}].description for IPv6`);
3279
+ }
3274
3280
  for (const field of ["firewall", "dhcp", "acls", "policies", "prefix_sets", "hardware_offload", "certificates"]) {
3275
3281
  if (device[field] !== undefined)
3276
3282
  found.push(field);
@@ -3629,14 +3635,25 @@ function checkAcross(devices) {
3629
3635
  ...checkTunnels(devices, owners)
3630
3636
  ];
3631
3637
  }
3632
- function carried(device, port) {
3638
+ function carried(device, settings) {
3633
3639
  const id = (vlan) => vlan ? device.vlans?.[vlan]?.id : undefined;
3634
3640
  return {
3635
- access: id(port.access_vlan),
3636
- native: id(port.native_vlan),
3637
- trunk: (port.trunk_vlans ?? []).map(id).sort((a, b) => (a ?? 0) - (b ?? 0))
3641
+ access: id(settings.access_vlan),
3642
+ native: id(settings.native_vlan),
3643
+ trunk: (settings.trunk_vlans ?? []).map(id).sort((a, b) => (a ?? 0) - (b ?? 0))
3638
3644
  };
3639
3645
  }
3646
+ function describeCarried({ access, native, trunk }) {
3647
+ const parts = [];
3648
+ const tagged = trunk.filter((id) => id !== undefined);
3649
+ if (access !== undefined)
3650
+ parts.push(`VLAN ${access} untagged`);
3651
+ if (tagged.length)
3652
+ parts.push(`${tagged.length === 1 ? "VLAN" : "VLANs"} ${tagged.join(", ")} tagged`);
3653
+ if (native !== undefined)
3654
+ parts.push(`VLAN ${native} untagged as native`);
3655
+ return parts.length ? parts.join(" and ") : "no VLANs";
3656
+ }
3640
3657
  function vlanNames(device, settings) {
3641
3658
  const names = [settings.access_vlan, settings.native_vlan, ...settings.trunk_vlans ?? []].filter((name) => name !== undefined);
3642
3659
  return new Map(names.flatMap((name) => device.vlans?.[name] ? [[device.vlans[name].id, name]] : []));
@@ -3695,15 +3712,21 @@ function checkLinks(devices) {
3695
3712
  report(`${link.device} ${link.port} does not link back to it`);
3696
3713
  continue;
3697
3714
  }
3698
- const here = JSON.stringify(carried(device, port));
3699
- const there = JSON.stringify(carried(far, farPort));
3700
- if (here !== there)
3701
- report(`carries ${here}, and ${link.device} ${link.port} carries ${there}`);
3715
+ const near = carrierOf(device, portName, port);
3716
+ const away = carrierOf(far, link.port, farPort);
3717
+ const inLag = near.name !== portName;
3718
+ const farInLag = away.name !== link.port;
3719
+ if (inLag !== farInLag) {
3720
+ report(inLag ? `${portName} is in ${near.name}, and ${link.device} ${link.port} is in no LAG` : `${portName} is in no LAG, and ${link.device} ${link.port} is in ${away.name}`);
3721
+ }
3722
+ const here = carried(device, near.settings);
3723
+ const there = carried(far, away.settings);
3724
+ if (JSON.stringify(here) !== JSON.stringify(there)) {
3725
+ report(`${near.name} carries ${describeCarried(here)}, and ${link.device} ${away.name} carries ${describeCarried(there)}`);
3726
+ }
3702
3727
  if (port.speed && farPort.speed && port.speed !== farPort.speed) {
3703
3728
  report(`speed ${port.speed}, and ${link.device} ${link.port} is ${farPort.speed}`);
3704
3729
  }
3705
- const near = carrierOf(device, portName, port);
3706
- const away = carrierOf(far, link.port, farPort);
3707
3730
  const mtu = near.settings.mtu ?? 1500;
3708
3731
  const farMtu = away.settings.mtu ?? 1500;
3709
3732
  if (mtu !== farMtu)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takodotid/circuit",
3
- "version": "0.23.0",
3
+ "version": "0.24.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",
@@ -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}`);
@@ -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
  },