@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 +9 -0
- package/dist/cli/index.js +33 -10
- package/package.json +1 -1
- package/src/adapters/devices/vrp/index.ts +5 -0
- package/src/core/across.ts +36 -11
- package/templates/single-site/router.ts +4 -7
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,
|
|
3638
|
+
function carried(device, settings) {
|
|
3633
3639
|
const id = (vlan) => vlan ? device.vlans?.[vlan]?.id : undefined;
|
|
3634
3640
|
return {
|
|
3635
|
-
access: id(
|
|
3636
|
-
native: id(
|
|
3637
|
-
trunk: (
|
|
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
|
|
3699
|
-
const
|
|
3700
|
-
|
|
3701
|
-
|
|
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
|
@@ -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
|
}
|
package/src/core/across.ts
CHANGED
|
@@ -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,
|
|
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(
|
|
33
|
-
native: id(
|
|
34
|
-
trunk: (
|
|
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
|
|
118
|
-
const
|
|
119
|
-
|
|
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
|
-
|
|
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
|
},
|