@takodotid/circuit 0.4.7 → 0.6.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 +1 -1
- package/package.json +3 -2
- package/src/cli/index.ts +19 -7
- package/src/core/merge.ts +19 -0
- package/src/index.ts +1 -0
- package/src/presets/index.ts +211 -0
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.
|
|
3
|
+
"version": "0.6.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
|
-
|
|
32
|
+
--config <path> the network's config file, circuit.config.ts in the working directory by default`;
|
|
33
33
|
|
|
34
|
-
const
|
|
35
|
-
|
|
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(
|
|
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,19 @@
|
|
|
1
|
+
// Combining records from several sources, such as a device's own interfaces and those a module derives for it.
|
|
2
|
+
|
|
3
|
+
type Merged<T extends readonly object[]> = T extends readonly [infer Head, ...infer Tail extends readonly object[]]
|
|
4
|
+
? Head & Merged<Tail>
|
|
5
|
+
: unknown;
|
|
6
|
+
|
|
7
|
+
/** Combine records into one. Unlike spreading them, a name two of them hold is an error rather than one silently replacing the other. */
|
|
8
|
+
export function merge<const T extends readonly Record<string, unknown>[]>(...records: T): Merged<T> {
|
|
9
|
+
const result: Record<string, unknown> = {};
|
|
10
|
+
|
|
11
|
+
for (const record of records) {
|
|
12
|
+
for (const [key, value] of Object.entries(record)) {
|
|
13
|
+
if (key in result) throw new Error(`merge: ${key} is declared twice`);
|
|
14
|
+
result[key] = value;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return result as Merged<T>;
|
|
19
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Building blocks most networks write the same way: address lists, edge filters, a BGP import sanity policy, checks, and helpers for checks of your own. 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
|
+
export 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
|
+
/** The VLANs a port carries, untagged or tagged. */
|
|
107
|
+
export const vlansOf = (port: { access_vlan?: string; trunk_vlans?: readonly string[] }): string[] =>
|
|
108
|
+
[port.access_vlan, ...(port.trunk_vlans ?? [])].filter((vlan): vlan is string => vlan !== undefined);
|
|
109
|
+
|
|
110
|
+
/** Where traffic from outside, before anything filters it, may go. */
|
|
111
|
+
export type TrustBoundary = {
|
|
112
|
+
/** VLANs carrying traffic straight from outside: transits, exchanges, an ISP handoff. */
|
|
113
|
+
untrusted: readonly string[];
|
|
114
|
+
/** VLANs that must not meet them on a port: customers, servers, management. */
|
|
115
|
+
trusted: readonly string[];
|
|
116
|
+
/** The devices that filter: the only ones that may give an untrusted VLAN an address, and the only ones a port carrying both may face. */
|
|
117
|
+
routers: readonly string[];
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** A check: untrusted traffic reaches trusted VLANs only through a router. A port carrying both faces a router or is on one, and only a router gives an untrusted VLAN an address. */
|
|
121
|
+
export const trustBoundary =
|
|
122
|
+
(boundary: TrustBoundary): Check =>
|
|
123
|
+
(devices) => {
|
|
124
|
+
const untrusted = new Set(boundary.untrusted);
|
|
125
|
+
const trusted = new Set(boundary.trusted);
|
|
126
|
+
const routers = new Set(boundary.routers);
|
|
127
|
+
const findings: Finding[] = [];
|
|
128
|
+
|
|
129
|
+
for (const device of devices) {
|
|
130
|
+
const isRouter = routers.has(device.name);
|
|
131
|
+
const ports = Object.entries(device.ports ?? {}).flatMap(([name, port]) => (port ? [{ name, port }] : []));
|
|
132
|
+
|
|
133
|
+
// What carries VLANs: a port on its own, or a LAG, which faces whatever its members link to.
|
|
134
|
+
const carriers = [
|
|
135
|
+
...ports
|
|
136
|
+
.filter(({ port }) => !port.lag)
|
|
137
|
+
.map(({ name, port }) => ({
|
|
138
|
+
what: `port ${name}`,
|
|
139
|
+
carried: vlansOf(port),
|
|
140
|
+
faces: port.link ? [port.link.device] : [],
|
|
141
|
+
})),
|
|
142
|
+
...Object.entries(device.interfaces ?? {}).flatMap(([name, iface]) =>
|
|
143
|
+
iface.type === "lag"
|
|
144
|
+
? [
|
|
145
|
+
{
|
|
146
|
+
what: `LAG ${name}`,
|
|
147
|
+
carried: vlansOf(iface),
|
|
148
|
+
faces: ports.filter(({ port }) => port.lag === name && port.link).map(({ port }) => port.link!.device),
|
|
149
|
+
},
|
|
150
|
+
]
|
|
151
|
+
: []
|
|
152
|
+
),
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
for (const { what, carried, faces } of carriers) {
|
|
156
|
+
const mixes = carried.some((vlan) => untrusted.has(vlan)) && carried.some((vlan) => trusted.has(vlan));
|
|
157
|
+
const facesRouter = isRouter || faces.some((name) => routers.has(name));
|
|
158
|
+
|
|
159
|
+
if (mixes && !facesRouter) {
|
|
160
|
+
findings.push({
|
|
161
|
+
level: "error",
|
|
162
|
+
device: device.name,
|
|
163
|
+
message: `${what} carries an untrusted VLAN beside a trusted one and does not face a router`,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (isRouter) continue;
|
|
169
|
+
|
|
170
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
171
|
+
if (iface.type === "vlan" && untrusted.has(iface.vlan)) {
|
|
172
|
+
findings.push({
|
|
173
|
+
level: "error",
|
|
174
|
+
device: device.name,
|
|
175
|
+
message: `interface ${name} puts an address on untrusted VLAN ${iface.vlan}; only a router may`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return findings;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/** 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. */
|
|
185
|
+
export const tunnelsOutsideOffered: Check = (devices) => {
|
|
186
|
+
const findings: Finding[] = [];
|
|
187
|
+
|
|
188
|
+
for (const device of devices) {
|
|
189
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
190
|
+
if (iface.type !== "gre" && iface.type !== "vxlan") continue;
|
|
191
|
+
|
|
192
|
+
const subnets = (iface.addresses ?? []).map((entry) => (typeof entry === "string" ? entry : entry.address));
|
|
193
|
+
|
|
194
|
+
for (const neighbor of neighborPolicies(device)) {
|
|
195
|
+
if (!subnets.some((subnet) => contains(subnet, neighbor.address))) continue;
|
|
196
|
+
|
|
197
|
+
for (const prefix of offered(device, neighbor.export)) {
|
|
198
|
+
if (contains(prefix, iface.local)) {
|
|
199
|
+
findings.push({
|
|
200
|
+
level: "error",
|
|
201
|
+
device: device.name,
|
|
202
|
+
message: `${name} is sourced from ${iface.local}, inside ${prefix}, which ${neighbor.name} is offered`,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return findings;
|
|
211
|
+
};
|