@takodotid/circuit 0.5.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takodotid/circuit",
3
- "version": "0.5.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",
@@ -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
@@ -1,4 +1,5 @@
1
1
  export { defineDevice, defineNetwork, secret, secretFile } from "./core/define";
2
+ export { merge } from "./core/merge";
2
3
  export type { Check, Community, Finding, Network } from "./core/define";
3
4
  export type * from "./schema";
4
5
 
@@ -1,4 +1,4 @@
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.
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
2
 
3
3
  import { contains } from "../core/addr";
4
4
  import type { Check, Finding } from "../core/define";
@@ -67,7 +67,7 @@ export function bgpSanity(lengths: SanityLengths = {}): PolicyRule<never, never>
67
67
  }
68
68
 
69
69
  /** Each BGP neighbor's own policies, or its group's. */
70
- function neighborPolicies(device: Device) {
70
+ export function neighborPolicies(device: Device) {
71
71
  const bgp = device.routing?.bgp;
72
72
 
73
73
  return Object.entries(bgp?.neighbors ?? {}).map(([name, neighbor]) => {
@@ -103,6 +103,84 @@ export const exportsEndInReject: Check = (devices) => {
103
103
  return findings;
104
104
  };
105
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
+
106
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. */
107
185
  export const tunnelsOutsideOffered: Check = (devices) => {
108
186
  const findings: Finding[] = [];