@takodotid/circuit 0.4.3 → 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.3",
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,10 +39,11 @@
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
- "circuit": "./src/cli/index.ts"
46
+ "circuit": "src/cli/index.ts"
46
47
  },
47
48
  "files": [
48
49
  "src",
@@ -10,6 +10,8 @@ export const INSTALL_CERTIFICATE = "#install-certificate ";
10
10
 
11
11
  /** Dependency order. A menu not listed sorts after all of these. The longest matching prefix decides. */
12
12
  const ORDER = [
13
+ // Certificates depend on nothing, and services name them.
14
+ "/certificate",
13
15
  "/interface bridge",
14
16
  "/interface ethernet",
15
17
  "/interface bonding",
@@ -42,7 +44,6 @@ const ORDER = [
42
44
  "/ipv6",
43
45
  "/routing",
44
46
  "/interface ethernet switch",
45
- "/certificate",
46
47
  "/snmp",
47
48
  "/tool",
48
49
  "/user",
@@ -257,7 +258,7 @@ function planEntries(menu: string, wanted: Command[], existing: Command[], sendS
257
258
  }
258
259
 
259
260
  /**
260
- * Certificates are installed from the config when missing or different, after the old one of that name is removed. One the config does not hold is removed in the last phase.
261
+ * Certificates are installed from the config when missing or different, after the old one of that name is removed. One the device holds under another name, with the same fingerprint and key, is renamed rather than imported a second time. One the config does not hold is removed in the last phase.
261
262
  */
262
263
  function planCertificates(wanted: Command[], existing: Command[]): MenuPlan {
263
264
  const menu = "/certificate";
@@ -265,19 +266,37 @@ function planCertificates(wanted: Command[], existing: Command[]): MenuPlan {
265
266
  const install: Step = { title: menu, show: [], send: [] };
266
267
  const remove: Step = { title: menu, show: [], send: [] };
267
268
  const existingByName = new Map(existing.map((item) => [item.attrs.name, item]));
269
+ const wantedNames = new Set(wanted.map((item) => item.attrs.name));
270
+ const renamed = new Set<string>();
268
271
 
269
272
  for (const entry of wanted) {
270
273
  const name = entry.attrs.name!;
271
274
  const current = existingByName.get(name);
272
275
  if (current && canonical(current) === canonical(entry)) continue;
273
276
 
277
+ const sameCertificate = existing.find(
278
+ (item) =>
279
+ !wantedNames.has(item.attrs.name) &&
280
+ !renamed.has(item.attrs.name!) &&
281
+ item.attrs.fingerprint === entry.attrs.fingerprint &&
282
+ item.attrs["private-key"] === entry.attrs["private-key"]
283
+ );
284
+
285
+ if (!current && sameCertificate) {
286
+ const oldName = sameCertificate.attrs.name!;
287
+ renamed.add(oldName);
288
+ install.show.push(`~ rename ${oldName} to ${name}`);
289
+ install.send.push(`${menu} set [ find name=${quote(oldName)} ] name=${quote(name)}`);
290
+ continue;
291
+ }
292
+
274
293
  install.show.push(`${current ? "~" : "+"} install ${name}`);
275
294
  if (current) install.send.push(`${menu} remove [ find name=${quote(name)} ]`);
276
295
  install.send.push(INSTALL_CERTIFICATE + name);
277
296
  }
278
297
 
279
298
  for (const entry of existing) {
280
- if (wanted.some((candidate) => candidate.attrs.name === entry.attrs.name)) continue;
299
+ if (wantedNames.has(entry.attrs.name) || renamed.has(entry.attrs.name!)) continue;
281
300
 
282
301
  remove.show.push(`- ${entry.attrs.name}`);
283
302
  remove.send.push(`${menu} remove [ find name=${quote(entry.attrs.name!)} ]`);
@@ -20,3 +20,4 @@ Behaviour that looks like something else. Each one has been hit on a CE6855 runn
20
20
  - `abort` is not a command here. `clear configuration candidate` discards uncommitted changes.
21
21
  - sFlow samples per port, `sflow sampling rate N`, N from 4096; the default is 8192 and an explicit 8192 is kept as written. A port names a collector only once the collector exists globally; until then it is refused with "collector does not exist".
22
22
  - `vlan batch` holds every VLAN on one line, and undoing a list that names a VLAN with an L3 interface is refused. A change sends only the VLANs added and `undo vlan batch` for the ones removed.
23
+ - A VLAN name is unique across VLANs: a new VLAN cannot take a name another still holds. VLANs no longer used, and their VLAN interfaces, are removed first, in the same candidate.
@@ -722,21 +722,52 @@ export async function apply(device: Device, _network: readonly Device[], plan: P
722
722
  console.log(" committed, login proved, saved");
723
723
  }
724
724
 
725
+ /**
726
+ * What changes, in the order the platform accepts.
727
+ *
728
+ * A VLAN list changes by ID. VLANs no longer used go first, with their VLAN interfaces, so their names and numbers are free before a new VLAN takes them: a VLAN name is unique, and a VLAN with an interface cannot be removed. The whole plan is one candidate, so going first leaves nothing half done.
729
+ */
730
+ function plan(desired: string, current: string): Plan {
731
+ const groups = compare(desired, current, dialect);
732
+ vlanListChange(
733
+ groups,
734
+ "vlan batch ",
735
+ batchIds,
736
+ (ids) => [`vlan batch ${vlanBatch(ids)}`],
737
+ (ids) => [`vlan batch ${vlanBatch(ids)}`]
738
+ );
739
+
740
+ const top = groups.find((group) => group.path.length === 0);
741
+ const removedBatch = top?.remove.find((line) => line.startsWith("vlan batch ")) ?? "";
742
+ const removedIds = removedBatch ? batchIds(removedBatch) : [];
743
+
744
+ const firstRemovals = [
745
+ ...removedIds.map((id) => `interface Vlanif${id}`).filter((line) => top?.remove.includes(line)),
746
+ ...removedIds.map((id) => `vlan ${id}`).filter((line) => top?.remove.includes(line)),
747
+ ...(removedBatch ? [removedBatch] : []),
748
+ ];
749
+
750
+ if (top) top.remove = top.remove.filter((line) => !firstRemovals.includes(line));
751
+
752
+ const planned = steps(
753
+ groups.filter((group) => group.add.length || group.remove.length),
754
+ dialect
755
+ );
756
+
757
+ if (firstRemovals.length) {
758
+ // `undo vlan batch` removes a VLAN's own settings with it, so only the interfaces and the list are sent.
759
+ const sent = firstRemovals.filter((line) => !/^vlan \d+$/.test(line)).map(undo);
760
+ planned.unshift({ title: "VLANs no longer used", show: firstRemovals.map((line) => `- ${line}`), send: sent });
761
+ }
762
+
763
+ return { steps: planned, problems: [] };
764
+ }
765
+
725
766
  export const vrp: DeviceAdapter = {
726
767
  extension: ".cfg",
727
768
  unsupported,
728
769
  render,
729
770
  read,
730
- plan: (desired, current) => {
731
- const groups = compare(desired, current, dialect);
732
- vlanListChange(
733
- groups,
734
- "vlan batch ",
735
- batchIds,
736
- (ids) => [`vlan batch ${vlanBatch(ids)}`],
737
- (ids) => [`vlan batch ${vlanBatch(ids)}`]
738
- );
739
- return { steps: steps(groups, dialect), problems: [] };
740
- },
771
+ plan,
741
772
  apply,
742
773
  };
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
+ };