@takodotid/circuit 0.4.7 → 0.5.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
@@ -49,7 +49,7 @@ export default defineDevice({
49
49
 
50
50
  Ports are named `<speed>-<position>`: `10g-1` is the first 10G cage. The model decides which exist; the adapter knows what the platform calls each one. Everything a device can hold is in `src/schema/`, with a comment on every field.
51
51
 
52
- A network is a list of devices in `circuit.config.ts` at the root of the project:
52
+ A network is a list of devices in `circuit.config.ts` at the root of the project, or any file named with `--config <path>`:
53
53
 
54
54
  ```ts
55
55
  import { defineNetwork } from "@takodotid/circuit";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takodotid/circuit",
3
- "version": "0.4.7",
3
+ "version": "0.5.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",
@@ -39,7 +39,8 @@
39
39
  ],
40
40
  "type": "module",
41
41
  "exports": {
42
- ".": "./src/index.ts"
42
+ ".": "./src/index.ts",
43
+ "./presets": "./src/presets/index.ts"
43
44
  },
44
45
  "bin": {
45
46
  "circuit": "src/cli/index.ts"
package/src/cli/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { createPrivateKey, createPublicKey } from "node:crypto";
5
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
- import { join, resolve } from "node:path";
6
+ import { dirname, join, resolve } from "node:path";
7
7
  import { adapters } from "../adapters/devices";
8
8
  import type { ApplyOptions, DeviceAdapter, Plan } from "../adapters/devices/types";
9
9
  import { registries } from "../adapters/registries";
@@ -27,21 +27,33 @@ const HELP = `usage: circuit <command> [device...] [options]
27
27
  wireguard <device> <interface> <peer>
28
28
  a client config for one peer, its private key left for the peer to fill
29
29
  communities the network's BGP communities, one per line, for a looking glass or bgp.tools
30
- peeringdb bring PeeringDB's exchange records in line with the config, with --confirm`;
30
+ peeringdb bring PeeringDB's exchange records in line with the config, with --confirm
31
31
 
32
- const CONFIG_FILE = "circuit.config.ts";
32
+ --config <path> the network's config file, circuit.config.ts in the working directory by default`;
33
33
 
34
- const root = process.cwd();
35
- const configPath = resolve(root, CONFIG_FILE);
34
+ const DEFAULT_CONFIG = "circuit.config.ts";
35
+
36
+ const [command, ...args] = process.argv.slice(2);
37
+
38
+ // `--config <path>` and `--config=<path>` both name the config file; neither is a device name.
39
+ const configAt = args.indexOf("--config");
40
+ const configArgument = configAt >= 0 ? args[configAt + 1] : args.find((arg) => arg.startsWith("--config="))?.slice("--config=".length);
41
+ const rest = args.filter((arg, index) => index !== configAt && (configAt < 0 || index !== configAt + 1) && !arg.startsWith("--config="));
42
+
43
+ const configPath = resolve(process.cwd(), configArgument ?? DEFAULT_CONFIG);
36
44
 
37
45
  if (!existsSync(configPath)) {
38
- console.error(`no ${CONFIG_FILE} in ${root}`);
46
+ console.error(
47
+ configArgument ? `no config at ${configPath}` : `no ${DEFAULT_CONFIG} in ${process.cwd()}; name another with --config <path>`
48
+ );
39
49
  process.exit(1);
40
50
  }
41
51
 
52
+ /** The directory of the config file. The state directory is relative to it. */
53
+ const root = dirname(configPath);
54
+
42
55
  const network = (await import(configPath)).default as Network;
43
56
 
44
- const [command, ...rest] = process.argv.slice(2);
45
57
  const flags = rest.filter((arg) => arg.startsWith("--"));
46
58
  const names = rest.filter((arg) => !arg.startsWith("--"));
47
59
  const confirmed = flags.includes("--confirm");
@@ -0,0 +1,133 @@
1
+ // Building blocks most networks write the same way: address lists, edge filters, a BGP import sanity policy, and checks. Each returns plain config to place where it belongs, so nothing here applies itself.
2
+
3
+ import { contains } from "../core/addr";
4
+ import type { Check, Finding } from "../core/define";
5
+ import type { AclRule, Device, FilterRule, PolicyRule, Prefix, TcpFlag } from "../schema";
6
+
7
+ /** IPv4 sources that never arrive from the internet: loopback, link-local and "this network", after RFC 6890. */
8
+ export const MARTIANS = ["127.0.0.0/8", "169.254.0.0/16", "0.0.0.0/8"] as const satisfies readonly Prefix[];
9
+
10
+ /** IPv4 private space, RFC 1918. */
11
+ export const PRIVATE_RANGES = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"] as const satisfies readonly Prefix[];
12
+
13
+ /** Hardware ACL rules dropping traffic arriving on a VLAN from sources that cannot be there: your own space, martians, private ranges. One rule per source. */
14
+ export const antiSpoofing = <V extends string>(vlan: V, sources: readonly Prefix[]): AclRule<V>[] =>
15
+ sources.map((src) => ({
16
+ description: `${vlan}: drop source ${src}`,
17
+ match: { vlan, src },
18
+ action: "drop",
19
+ }));
20
+
21
+ /** TCP flag combinations no legitimate packet carries: name, flags set, flags unset. */
22
+ const BAD_TCP_FLAGS = [
23
+ ["FIN+SYN", ["fin", "syn"], []],
24
+ ["SYN+RST", ["syn", "rst"], []],
25
+ ["FIN+RST", ["fin", "rst"], []],
26
+ ["FIN without ACK", ["fin"], ["ack"]],
27
+ ["FIN+URG", ["fin", "urg"], []],
28
+ ["Xmas scan", ["fin", "syn", "psh", "urg"], []],
29
+ ] as const satisfies readonly (readonly [string, readonly TcpFlag[], readonly TcpFlag[]])[];
30
+
31
+ /** Firewall rules dropping packets with a TCP flag combination no legitimate packet carries, for a forward chain. */
32
+ export const badTcpFlags = (): FilterRule<never, never>[] =>
33
+ BAD_TCP_FLAGS.map(([name, set, unset]) => ({
34
+ description: `Bad flags ${name}`,
35
+ match: { protocol: "tcp", tcp_flags: { set, unset } },
36
+ action: "drop",
37
+ }));
38
+
39
+ /** The prefix lengths a BGP import accepts. */
40
+ export type SanityLengths = {
41
+ /** Shortest and longest IPv4 prefix accepted. /8 to /24 when absent. */
42
+ ipv4?: { min: number; max: number };
43
+ /** Shortest and longest IPv6 prefix accepted. /16 to /48 when absent. */
44
+ ipv6?: { min: number; max: number };
45
+ };
46
+
47
+ /** Route policy rules rejecting what no neighbor should send: prefixes too short or too long for the global table, and RPKI invalid. Place it first in an import, or as a policy other imports call. */
48
+ export function bgpSanity(lengths: SanityLengths = {}): PolicyRule<never, never>[] {
49
+ const ipv4 = lengths.ipv4 ?? { min: 8, max: 24 };
50
+ const ipv6 = lengths.ipv6 ?? { min: 16, max: 48 };
51
+
52
+ return [
53
+ {
54
+ description: `IPv4 shorter than /${ipv4.min}`,
55
+ match: { family: "ipv4", prefix_length: { max: ipv4.min - 1 } },
56
+ action: "reject",
57
+ },
58
+ { description: `IPv4 longer than /${ipv4.max}`, match: { family: "ipv4", prefix_length: { min: ipv4.max + 1 } }, action: "reject" },
59
+ {
60
+ description: `IPv6 shorter than /${ipv6.min}`,
61
+ match: { family: "ipv6", prefix_length: { max: ipv6.min - 1 } },
62
+ action: "reject",
63
+ },
64
+ { description: `IPv6 longer than /${ipv6.max}`, match: { family: "ipv6", prefix_length: { min: ipv6.max + 1 } }, action: "reject" },
65
+ { description: "RPKI invalid", match: { rpki: "invalid" }, action: "reject" },
66
+ ];
67
+ }
68
+
69
+ /** Each BGP neighbor's own policies, or its group's. */
70
+ function neighborPolicies(device: Device) {
71
+ const bgp = device.routing?.bgp;
72
+
73
+ return Object.entries(bgp?.neighbors ?? {}).map(([name, neighbor]) => {
74
+ const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
75
+ return { name, address: neighbor.address, import: neighbor.import ?? group?.import, export: neighbor.export ?? group?.export };
76
+ });
77
+ }
78
+
79
+ /** The prefixes an export policy accepts by name. */
80
+ export function offered(device: Device, policy: string | undefined): string[] {
81
+ const rules = policy ? (device.policies?.[policy] ?? []) : [];
82
+ return rules.filter((rule) => rule.action === "accept" && rule.match?.prefix).map((rule) => rule.match!.prefix!);
83
+ }
84
+
85
+ /** Every BGP export ends in a reject that matches everything, so nothing leaves by accident. */
86
+ export const exportsEndInReject: Check = (devices) => {
87
+ const findings: Finding[] = [];
88
+
89
+ for (const device of devices) {
90
+ for (const neighbor of neighborPolicies(device)) {
91
+ const last = neighbor.export ? device.policies?.[neighbor.export]?.at(-1) : undefined;
92
+
93
+ if (neighbor.export && (last?.action !== "reject" || last.match)) {
94
+ findings.push({
95
+ level: "error",
96
+ device: device.name,
97
+ message: `${neighbor.name}: export ${neighbor.export} does not end in an unconditional reject`,
98
+ });
99
+ }
100
+ }
101
+ }
102
+
103
+ return findings;
104
+ };
105
+
106
+ /** A GRE or VXLAN tunnel's local address is outside what the neighbors over it are offered, or their replies route back into the tunnel. */
107
+ export const tunnelsOutsideOffered: Check = (devices) => {
108
+ const findings: Finding[] = [];
109
+
110
+ for (const device of devices) {
111
+ for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
112
+ if (iface.type !== "gre" && iface.type !== "vxlan") continue;
113
+
114
+ const subnets = (iface.addresses ?? []).map((entry) => (typeof entry === "string" ? entry : entry.address));
115
+
116
+ for (const neighbor of neighborPolicies(device)) {
117
+ if (!subnets.some((subnet) => contains(subnet, neighbor.address))) continue;
118
+
119
+ for (const prefix of offered(device, neighbor.export)) {
120
+ if (contains(prefix, iface.local)) {
121
+ findings.push({
122
+ level: "error",
123
+ device: device.name,
124
+ message: `${name} is sourced from ${iface.local}, inside ${prefix}, which ${neighbor.name} is offered`,
125
+ });
126
+ }
127
+ }
128
+ }
129
+ }
130
+ }
131
+
132
+ return findings;
133
+ };