@takodotid/circuit 0.5.0 → 0.7.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.
@@ -0,0 +1,97 @@
1
+ // What a tenant is, and what each device carries for it. A tenant's file says what is the tenant's own; its VLANs and names follow from its name and number.
2
+
3
+ import type { AclRule, Interface, IP, Port, Prefix, Vlan } from "@takodotid/circuit";
4
+ import { familyOf } from "@takodotid/circuit";
5
+
6
+ /** A tenant's networks. `internet` carries its servers' public addresses and reaches the router. `private` connects its own servers to each other and stays on the switches. */
7
+ type TenantNetwork = "internet" | "private";
8
+
9
+ export type Tenant<N extends string = string> = {
10
+ /** Lowercase, and never reused. Names everything the tenant has. */
11
+ name: N;
12
+ /** A number no other tenant has. Its internet VLAN is 1000 + number, its private VLAN 2000 + number. */
13
+ number: number;
14
+ /** Public blocks routed to the tenant. `gateway` is the router's address inside the block, which the tenant's servers use as their gateway. */
15
+ blocks: readonly { prefix: Prefix; gateway: IP }[];
16
+ /** Rules for traffic toward the tenant's IPv4 blocks from the transit and the exchange, such as a cap on UDP. Everything reaches the tenant when absent. */
17
+ acl?: readonly (Omit<AclRule<never>, "match"> & { match?: Omit<NonNullable<AclRule<never>["match"]>, "vlan" | "dst"> })[];
18
+ /** The tenant's ports, by switch, then by port. A port in `private` gives the tenant a private network on that switch. */
19
+ ports: Readonly<
20
+ Record<string, Readonly<Record<string, { description: string; access?: TenantNetwork; trunk?: readonly TenantNetwork[] }>>>
21
+ >;
22
+ };
23
+
24
+ /** Declare a tenant. */
25
+ export const tenant = <const N extends string>(spec: Tenant<N>): Tenant<N> => spec;
26
+
27
+ const internetVlan = <N extends string>(t: Tenant<N>) => `${t.name}-internet` as const;
28
+ const privateVlan = <N extends string>(t: Tenant<N>) => `${t.name}-private` as const;
29
+
30
+ /** Every tenant's internet VLAN. The router has them all, and so does each switch, to carry them up to the router. */
31
+ export const internetVlans = <N extends string>(tenants: readonly Tenant<N>[]) =>
32
+ Object.fromEntries(tenants.map((t) => [internetVlan(t), { id: 1000 + t.number, description: `Tenant ${t.name} internet` }])) as Record<
33
+ `${N}-internet`,
34
+ Vlan
35
+ >;
36
+
37
+ /** The names of every tenant's internet VLAN, for a trunk toward the router. */
38
+ export const internetVlanNames = <N extends string>(tenants: readonly Tenant<N>[]) => tenants.map(internetVlan);
39
+
40
+ /** The private VLANs one switch has: those of tenants with a port on it in their private network. */
41
+ export const privateVlans = <N extends string>(tenants: readonly Tenant<N>[], device: string) =>
42
+ Object.fromEntries(
43
+ tenants
44
+ .filter((t) =>
45
+ Object.values(t.ports[device] ?? {}).some((port) => port.access === "private" || port.trunk?.includes("private"))
46
+ )
47
+ .map((t) => [privateVlan(t), { id: 2000 + t.number, description: `Tenant ${t.name} private` }])
48
+ ) as Record<`${N}-private`, Vlan>;
49
+
50
+ /** The tenants' ports on one switch. */
51
+ export function portsOn<N extends string>(tenants: readonly Tenant<N>[], device: string) {
52
+ const ports: Record<string, Port<`${N}-internet` | `${N}-private`, string, string, string>> = {};
53
+
54
+ for (const t of tenants) {
55
+ const vlanOf = (network: TenantNetwork) => (network === "internet" ? internetVlan(t) : privateVlan(t));
56
+
57
+ for (const [name, { access, trunk, description }] of Object.entries(t.ports[device] ?? {})) {
58
+ ports[name] = {
59
+ description,
60
+ ...(access ? { access_vlan: vlanOf(access) } : {}),
61
+ ...(trunk ? { trunk_vlans: trunk.map(vlanOf) } : {}),
62
+ };
63
+ }
64
+ }
65
+
66
+ return ports;
67
+ }
68
+
69
+ /** The router's interface on each tenant's internet VLAN, holding the tenant's gateways. */
70
+ export function gateways<N extends string>(tenants: readonly Tenant<N>[]) {
71
+ const interfaces = {} as Record<`${N}-internet`, Interface<`${N}-internet`, never>>;
72
+
73
+ for (const t of tenants) {
74
+ interfaces[internetVlan(t)] = {
75
+ type: "vlan",
76
+ vlan: internetVlan(t),
77
+ description: `Tenant ${t.name}`,
78
+ addresses: t.blocks.map(({ prefix, gateway }) => `${gateway}/${prefix.split("/")[1]}`),
79
+ };
80
+ }
81
+
82
+ return interfaces;
83
+ }
84
+
85
+ /** Each tenant's `acl` rules on one VLAN from outside, one per rule and IPv4 block, each limited to traffic toward that block. */
86
+ export const edgeRules = <V extends string>(tenants: readonly Tenant[], vlan: V): AclRule<V>[] =>
87
+ tenants.flatMap((t) =>
88
+ (t.acl ?? []).flatMap((rule) =>
89
+ t.blocks
90
+ .filter(({ prefix }) => familyOf(prefix) === "ipv4")
91
+ .map(({ prefix }) => ({
92
+ ...rule,
93
+ ...(rule.description ? { description: `${vlan}: ${rule.description}` } : {}),
94
+ match: { ...rule.match, vlan, dst: prefix },
95
+ }))
96
+ )
97
+ );
@@ -0,0 +1,19 @@
1
+ // An example tenant. Copy this file for each new tenant, and add it to index.ts.
2
+
3
+ import { tenant } from "../tenancy";
4
+
5
+ export default tenant({
6
+ name: "acme",
7
+ number: 1,
8
+ blocks: [
9
+ { prefix: "198.51.100.8/29", gateway: "198.51.100.9" },
10
+ { prefix: "2001:db8:100:1::/64", gateway: "2001:db8:100:1::1" },
11
+ ],
12
+ acl: [{ description: "UDP to acme above 1G", match: { protocol: "udp" }, action: "accept", rate: "1G" }],
13
+ ports: {
14
+ "tor-01": {
15
+ "10g-1": { description: "acme server 1", trunk: ["internet", "private"] },
16
+ "10g-2": { description: "acme server 2", trunk: ["internet", "private"] },
17
+ },
18
+ },
19
+ });
@@ -0,0 +1,5 @@
1
+ // Every tenant. A tenant joins by being added here, and leaves by being removed: the next apply takes away everything it had.
2
+
3
+ import acme from "./acme";
4
+
5
+ export const TENANTS = [acme] as const;
@@ -0,0 +1,33 @@
1
+ // The top-of-rack switch: the tenants' servers, and one uplink to the router.
2
+
3
+ import { defineDevice, merge, secret } from "@takodotid/circuit";
4
+ import { internetVlanNames, internetVlans, portsOn, privateVlans } from "./tenancy";
5
+ import { TENANTS } from "./tenants";
6
+
7
+ export default defineDevice({
8
+ name: "tor-01",
9
+ platform: "vrp",
10
+ model: "CE6855-48S6Q-HI",
11
+ connection: { host: "10.0.99.2", user: "operator" },
12
+ users: { operator: { role: "admin", password: secret("TOR_01_PASSWORD") } },
13
+
14
+ system: { timezone: "Asia/Jakarta", ntp: { servers: ["10.0.99.1"] } },
15
+ management: { allow: ["10.0.0.0/24"], ssh: {} },
16
+
17
+ lldp: true,
18
+ stp: {},
19
+
20
+ vlans: merge({ mgmt: { id: 99, description: "Management" } }, internetVlans(TENANTS), privateVlans(TENANTS, "tor-01")),
21
+
22
+ // Every tenant's ports, from its file in tenants/, beside the switch's own.
23
+ ports: merge(portsOn(TENANTS, "tor-01"), {
24
+ "40g-1": {
25
+ description: "edge-01",
26
+ trunk_vlans: ["mgmt", ...internetVlanNames(TENANTS)],
27
+ link: { device: "edge-01", port: "100g-1" },
28
+ },
29
+ }),
30
+
31
+ interfaces: { mgmt: { type: "vlan", vlan: "mgmt", addresses: ["10.0.99.2/24"] } },
32
+ routing: { static: [{ prefix: "0.0.0.0/0", via: "10.0.99.1" }] },
33
+ });
@@ -0,0 +1,20 @@
1
+ // Rules of your own. `circuit validate` runs them beside Circuit's own, and an error stops `diff` and `apply`.
2
+
3
+ import type { Check } from "@takodotid/circuit";
4
+ import { exportsEndInReject, neighborPolicies, trustBoundary } from "@takodotid/circuit/presets";
5
+
6
+ export default [
7
+ // Nothing leaves by accident: every export ends by rejecting what it did not accept.
8
+ exportsEndInReject,
9
+
10
+ // Traffic from the transit and the exchange reaches the rest of the network only through the router.
11
+ trustBoundary({ untrusted: ["transit", "ix"], routers: ["edge-01"] }),
12
+
13
+ // Every BGP session filters what it takes in.
14
+ (devices) =>
15
+ devices.flatMap((device) =>
16
+ neighborPolicies(device)
17
+ .filter((neighbor) => !neighbor.import)
18
+ .map((neighbor) => ({ level: "error" as const, device: device.name, message: `${neighbor.name} has no import policy` }))
19
+ ),
20
+ ] satisfies Check[];
@@ -0,0 +1,8 @@
1
+ // The network: every device Circuit manages, your own checks, and what is published about the network.
2
+
3
+ import { defineNetwork } from "@takodotid/circuit";
4
+ import checks from "./checks";
5
+ import edge from "./edge-01";
6
+ import { ASN, communities } from "./routing";
7
+
8
+ export default defineNetwork({ devices: [edge], checks, asn: ASN, communities: communities.catalogue });
@@ -0,0 +1,108 @@
1
+ // The edge router: BGP with an IP transit and an internet exchange, filtered on the way in and on the way out.
2
+
3
+ import type { Prefix } from "@takodotid/circuit";
4
+ import { defineDevice, secret } from "@takodotid/circuit";
5
+ import { antiSpoofing, badTcpFlags, bgpSanity, MARTIANS, PRIVATE_RANGES } from "@takodotid/circuit/presets";
6
+ import { ASN, communities } from "./routing";
7
+
8
+ /** The address space this router announces. */
9
+ const OURS = ["198.51.100.0/24", "2001:db8:100::/48"] as const satisfies readonly Prefix[];
10
+
11
+ /** Where the router is managed from. */
12
+ const MANAGEMENT = "10.0.0.0/24" satisfies Prefix;
13
+
14
+ /** Sources that never arrive from the internet: our own space, and addresses that are not on the internet at all. */
15
+ const SPOOFED = [OURS[0], ...MARTIANS, ...PRIVATE_RANGES];
16
+
17
+ export default defineDevice({
18
+ name: "edge-01",
19
+ platform: "routeros",
20
+ model: "CCR2216-1G-12XS-2XQ",
21
+ connection: { host: "10.0.0.1", user: "operator" },
22
+ users: { operator: { role: "admin", password: secret("EDGE_01_PASSWORD") } },
23
+
24
+ system: { timezone: "Asia/Jakarta", ntp: { servers: ["pool.ntp.org"] } },
25
+ management: { allow: [MANAGEMENT], ssh: {} },
26
+
27
+ vlans: {
28
+ transit: { id: 300, description: "IP transit from Example Transit, AS64501" },
29
+ ix: { id: 200, description: "Peering LAN of Example IX" },
30
+ },
31
+
32
+ ports: {
33
+ "1g-1": { description: "Management", addresses: ["10.0.0.1/24"] },
34
+ "25g-1": { description: "Example Transit", access_vlan: "transit", acl: "edge" },
35
+ "25g-2": { description: "Example IX", access_vlan: "ix", acl: "edge" },
36
+ },
37
+
38
+ interfaces: {
39
+ transit: { type: "vlan", vlan: "transit", addresses: ["203.0.113.2/30"] },
40
+ ix: { type: "vlan", vlan: "ix", addresses: ["192.0.2.10/24", "2001:db8:ffff::10/64"] },
41
+ },
42
+
43
+ routing: {
44
+ router_id: "203.0.113.2",
45
+ // A route for each block we announce, so it is in the table even when nothing inside it is up.
46
+ static: OURS.map((prefix) => ({ prefix, blackhole: true, description: "Originate" })),
47
+ bgp: {
48
+ asn: ASN,
49
+ networks: [...OURS],
50
+ neighbors: {
51
+ "example-transit": {
52
+ address: "203.0.113.1",
53
+ remote_as: 64501,
54
+ local_role: "customer",
55
+ import: "TRANSIT-IN",
56
+ export: "OUT",
57
+ max_prefixes: 1_200_000,
58
+ },
59
+ "example-ix-rs-v4": {
60
+ address: "192.0.2.1",
61
+ remote_as: 64502,
62
+ local_role: "rs-client",
63
+ import: "IX-IN",
64
+ export: "OUT",
65
+ max_prefixes: 100_000,
66
+ },
67
+ "example-ix-rs-v6": {
68
+ address: "2001:db8:ffff::1",
69
+ remote_as: 64502,
70
+ local_role: "rs-client",
71
+ import: "IX-IN",
72
+ export: "OUT",
73
+ max_prefixes: 50_000,
74
+ },
75
+ },
76
+ },
77
+ },
78
+
79
+ policies: {
80
+ SANITY: bgpSanity(),
81
+ // A route from the exchange is preferred over the same route from the transit: higher local preference wins.
82
+ "TRANSIT-IN": [{ call: "SANITY" }, communities.tag("transit", 64501), { set: { local_pref: 100 } }, { action: "accept" }],
83
+ "IX-IN": [{ call: "SANITY" }, communities.tag("exchange", 64502), { set: { local_pref: 200 } }, { action: "accept" }],
84
+ OUT: [
85
+ ...OURS.map((prefix) => ({ description: `Announce ${prefix}`, match: { prefix }, action: "accept" as const })),
86
+ { description: "Nothing else leaves", action: "reject" },
87
+ ],
88
+ },
89
+
90
+ firewall: {
91
+ address_sets: { "bgp-neighbors": ["203.0.113.1", "192.0.2.1", "2001:db8:ffff::1"] },
92
+ filter: {
93
+ input: {
94
+ default: "drop",
95
+ rules: [
96
+ { description: "Replies", match: { state: ["established", "related"] }, action: "accept" },
97
+ { description: "Ping", match: { protocol: "icmp" }, action: "accept" },
98
+ { description: "Management", match: { src: MANAGEMENT }, action: "accept" },
99
+ { description: "BGP", match: { protocol: "tcp", dst_port: 179, src_set: "bgp-neighbors" }, action: "accept" },
100
+ ],
101
+ },
102
+ forward: { rules: [...badTcpFlags()] },
103
+ },
104
+ },
105
+
106
+ // In the switch chip, before a packet reaches the router's CPU.
107
+ acls: { edge: [...antiSpoofing("transit", SPOOFED), ...antiSpoofing("ix", SPOOFED)] },
108
+ });
@@ -0,0 +1,13 @@
1
+ // What your network is on the internet: its AS number and its BGP communities.
2
+
3
+ import { communityScheme } from "@takodotid/circuit/presets";
4
+
5
+ /** Your AS number. */
6
+ export const ASN = 64500;
7
+
8
+ /** What your BGP communities mean. The router tags each route it learns with them, and `circuit communities` publishes them. */
9
+ export const communities = communityScheme({
10
+ asn: ASN,
11
+ learned_from: { function: 1, classes: { transit: 1, exchange: 2 } },
12
+ learned_from_as: 3,
13
+ });
@@ -0,0 +1,44 @@
1
+ # AGENTS.md
2
+
3
+ This repository describes a computer network with [Circuit](https://circuit.tako.id). Each network device, such as a router or a switch, has a TypeScript file that says how it should be configured. Circuit compares that file with what the device runs, and changes the device to match.
4
+
5
+ You are helping the owner of this network. Changes you make here can take a network down, so work carefully and let the owner decide.
6
+
7
+ ## How Circuit works
8
+
9
+ 1. `circuit.config.ts` lists every device and the checks to run.
10
+ 2. Each device file calls `defineDevice`. What the file says is everything the device runs: a port, a user or a service that the file does not mention is removed or turned off when the device is applied.
11
+ 3. A field is only written when it has a value. There is no `enabled: false`; leave a field out to turn it off.
12
+ 4. Secrets are never written in a file. A file says `secret("NAME")`, and the value comes from `.env` or the environment. Never put a password, a key or a token in any other file, and never print one.
13
+ 5. `.circuit/` is written by Circuit. Never edit it. `.circuit/state/` holds what each device ran when Circuit last read it.
14
+
15
+ What each field means is written as a comment on the field, in `node_modules/@takodotid/circuit/src/schema/`. Read it before using a field you have not used before. The documentation is at https://circuit.tako.id.
16
+
17
+ ## Commands
18
+
19
+ | Command | What it does | Changes a device |
20
+ | -------------------------------------- | -------------------------------------------------------------------- | ---------------- |
21
+ | `bun circuit validate` | Checks every file and every rule in the checks | no |
22
+ | `bun circuit diff [device]` | Shows what would change, compared with the last snapshot | no |
23
+ | `bun circuit build <device>` | Prints the full configuration the device should run | no |
24
+ | `bun circuit snapshot [device]` | Reads what each device runs now into `.circuit/state/` | no, only reads |
25
+ | `bun circuit apply <device>` | Reads the device, then shows what would change on it | no |
26
+ | `bun circuit apply <device> --confirm` | Sends the change to the device | **yes** |
27
+ | `bun circuit secrets` | Lists every secret the config uses, and whether each one can be read | no |
28
+
29
+ ## Making a change
30
+
31
+ 1. Edit the device's file.
32
+ 2. Run `bun circuit validate`. Fix every error.
33
+ 3. Run `bun circuit diff <device>` and read the result.
34
+ 4. Show the owner what will change and why, in plain words.
35
+ 5. Run `bun circuit apply <device>` to see the plan against the live device.
36
+ 6. **Stop.** Only run `apply <device> --confirm` after the owner says yes to that exact plan. Never confirm on your own, even if you were told to finish the task.
37
+ 7. After the apply, commit the device file together with the updated `.circuit/state/`.
38
+
39
+ ## Rules
40
+
41
+ - Never run `apply --confirm`, `refresh --confirm` or `peeringdb --confirm` without the owner's explicit approval for that run.
42
+ - If a plan removes something you did not expect, stop and ask. It usually means the file is missing something the device needs.
43
+ - Be careful with anything that carries your own access to the device: the management address, the management VLAN, the uplink, `management.allow`, and the login user. A mistake there locks everyone out.
44
+ - When you are not sure what a field does, or what the owner wants, ask instead of guessing.
@@ -0,0 +1 @@
1
+ @AGENTS.md
@@ -0,0 +1,15 @@
1
+ # Network
2
+
3
+ This repository describes the network with [Circuit](https://circuit.tako.id). Each device has a file; Circuit makes the device match it.
4
+
5
+ ```bash
6
+ bun install # once
7
+ bun circuit secrets # check every secret can be read
8
+ bun circuit validate # check the files
9
+ bun circuit snapshot # read what each device runs now
10
+ bun circuit diff # see what would change
11
+ bun circuit apply <device> # see the plan against the device
12
+ bun circuit apply <device> --confirm
13
+ ```
14
+
15
+ Secrets go in `.env`, which is never committed. `.circuit/` is written by Circuit; commit it, but never edit it.
@@ -0,0 +1,5 @@
1
+ node_modules/
2
+
3
+ # Secrets, or references to them. Never commit these.
4
+ .env
5
+ *.key.pem
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "lib": ["ESNext"],
4
+ "target": "ESNext",
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "types": ["bun"],
8
+ "strict": true,
9
+ "noEmit": true,
10
+ "skipLibCheck": true,
11
+ "verbatimModuleSyntax": true
12
+ }
13
+ }
@@ -0,0 +1,26 @@
1
+ // Rules of your own. `circuit validate` runs them beside Circuit's own, and an error stops `diff` and `apply`.
2
+
3
+ import type { Check } from "@takodotid/circuit";
4
+ import { vlansOf } from "@takodotid/circuit/presets";
5
+
6
+ export default [
7
+ // A port without a description is hard to trace later. A warning, so it does not block anything.
8
+ (devices) =>
9
+ devices.flatMap((device) =>
10
+ Object.entries(device.ports ?? {})
11
+ .filter(([, port]) => port && !port.description)
12
+ .map(([name]) => ({ level: "warning" as const, device: device.name, message: `port ${name} has no description` }))
13
+ ),
14
+
15
+ // Someone plugging into a wall socket must not land on the management network. Only a trunk between devices carries it.
16
+ (devices) =>
17
+ devices.flatMap((device) =>
18
+ Object.entries(device.ports ?? {})
19
+ .filter(([, port]) => port?.access_vlan && vlansOf(port).includes("mgmt"))
20
+ .map(([name]) => ({
21
+ level: "error" as const,
22
+ device: device.name,
23
+ message: `port ${name} gives management to whatever plugs in`,
24
+ }))
25
+ ),
26
+ ] satisfies Check[];
@@ -0,0 +1,8 @@
1
+ // The network: every device Circuit manages, and your own checks.
2
+
3
+ import { defineNetwork } from "@takodotid/circuit";
4
+ import checks from "./checks";
5
+ import router from "./router";
6
+ import sw from "./switch";
7
+
8
+ export default defineNetwork({ devices: [router, sw], checks });
@@ -0,0 +1,89 @@
1
+ // The router: the internet connection, a gateway and DHCP for each VLAN, and the firewall.
2
+
3
+ import { defineDevice, secret } from "@takodotid/circuit";
4
+ import { MANAGEMENT, ROUTER, TIMEZONE, vlans } from "./site";
5
+
6
+ export default defineDevice({
7
+ name: "router",
8
+ platform: "routeros",
9
+ model: "CCR2216-1G-12XS-2XQ",
10
+ connection: { host: ROUTER, user: "operator" },
11
+ users: { operator: { role: "admin", password: secret("ROUTER_PASSWORD") } },
12
+
13
+ system: {
14
+ timezone: TIMEZONE,
15
+ dns: { servers: ["1.1.1.1", "1.0.0.1"], serve: true },
16
+ ntp: { servers: ["pool.ntp.org"], serve: true },
17
+ },
18
+ management: { allow: [MANAGEMENT], ssh: {} },
19
+
20
+ vlans,
21
+
22
+ ports: {
23
+ "1g-1": { description: "Internet provider", addresses: ["203.0.113.2/30"] },
24
+ "25g-1": {
25
+ description: "Switch",
26
+ speed: "10g",
27
+ trunk_vlans: ["mgmt", "home", "guests"],
28
+ link: { device: "switch", port: "10g-1" },
29
+ },
30
+ },
31
+
32
+ // The gateway of each VLAN.
33
+ interfaces: {
34
+ mgmt: { type: "vlan", vlan: "mgmt", addresses: [`${ROUTER}/24`] },
35
+ home: { type: "vlan", vlan: "home", addresses: ["192.168.20.1/24"] },
36
+ guests: { type: "vlan", vlan: "guests", addresses: ["192.168.30.1/24"] },
37
+ },
38
+
39
+ routing: { static: [{ prefix: "0.0.0.0/0", via: "203.0.113.1", description: "Internet provider" }] },
40
+
41
+ dhcp: {
42
+ home: {
43
+ interface: "home",
44
+ network: "192.168.20.0/24",
45
+ gateway: "192.168.20.1",
46
+ dns: ["192.168.20.1"],
47
+ pool: ["192.168.20.100", "192.168.20.200"],
48
+ },
49
+ guests: {
50
+ interface: "guests",
51
+ network: "192.168.30.0/24",
52
+ gateway: "192.168.30.1",
53
+ dns: ["192.168.30.1"],
54
+ pool: ["192.168.30.100", "192.168.30.200"],
55
+ lease_time: 3600,
56
+ },
57
+ },
58
+
59
+ firewall: {
60
+ filter: {
61
+ // Traffic to the router itself.
62
+ input: {
63
+ default: "drop",
64
+ rules: [
65
+ { description: "Replies", match: { state: ["established", "related"] }, action: "accept" },
66
+ { description: "Ping", match: { protocol: "icmp" }, action: "accept" },
67
+ { description: "Management", match: { src: MANAGEMENT }, action: "accept" },
68
+ { description: "DNS from home", match: { in_interface: "home", protocol: "udp", dst_port: 53 }, action: "accept" },
69
+ { description: "DNS from guests", match: { in_interface: "guests", protocol: "udp", dst_port: 53 }, action: "accept" },
70
+ ],
71
+ },
72
+ // Traffic passing through the router.
73
+ forward: {
74
+ rules: [
75
+ { 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" },
82
+ ],
83
+ },
84
+ },
85
+ nat: {
86
+ source: [{ description: "Share the public address", match: { out_interface: "1g-1" }, action: "masquerade" }],
87
+ },
88
+ },
89
+ });
@@ -0,0 +1,18 @@
1
+ // Values both devices share. Change them to your own.
2
+
3
+ import type { Prefix, Vlan } from "@takodotid/circuit";
4
+
5
+ /** The management network. Only addresses in it can log in to the devices. */
6
+ export const MANAGEMENT = "192.168.10.0/24" satisfies Prefix;
7
+
8
+ /** The router's address on the management network, the switch's gateway. */
9
+ export const ROUTER = "192.168.10.1";
10
+
11
+ export const TIMEZONE = "Asia/Jakarta";
12
+
13
+ /** Every VLAN on the site. Each device lists the ones it carries. */
14
+ export const vlans = {
15
+ mgmt: { id: 10, description: "Management" },
16
+ home: { id: 20, description: "Home" },
17
+ guests: { id: 30, description: "Guests" },
18
+ } as const satisfies Record<string, Vlan>;
@@ -0,0 +1,34 @@
1
+ // The switch: a port for each device on the site, on the VLAN it belongs to.
2
+
3
+ import { defineDevice, secret } from "@takodotid/circuit";
4
+ import { MANAGEMENT, ROUTER, TIMEZONE, vlans } from "./site";
5
+
6
+ export default defineDevice({
7
+ name: "switch",
8
+ platform: "vrp",
9
+ model: "CE6855-48S6Q-HI",
10
+ connection: { host: "192.168.10.2", user: "operator" },
11
+ users: { operator: { role: "admin", password: secret("SWITCH_PASSWORD") } },
12
+
13
+ system: { timezone: TIMEZONE, ntp: { servers: [ROUTER] } },
14
+ management: { allow: [MANAGEMENT], ssh: {} },
15
+
16
+ lldp: true,
17
+ stp: {},
18
+
19
+ vlans,
20
+
21
+ ports: {
22
+ "10g-1": {
23
+ description: "Router",
24
+ trunk_vlans: ["mgmt", "home", "guests"],
25
+ link: { device: "router", port: "25g-1" },
26
+ },
27
+ "10g-2": { description: "Desk", access_vlan: "home", stp: { edge: true } },
28
+ "10g-3": { description: "Living room", access_vlan: "home", stp: { edge: true } },
29
+ "10g-4": { description: "Guest access point", access_vlan: "guests", stp: { edge: true } },
30
+ },
31
+
32
+ interfaces: { mgmt: { type: "vlan", vlan: "mgmt", addresses: ["192.168.10.2/24"] } },
33
+ routing: { static: [{ prefix: "0.0.0.0/0", via: ROUTER }] },
34
+ });