@takodotid/circuit 0.4.3
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/LICENSE +106 -0
- package/README.md +195 -0
- package/package.json +83 -0
- package/src/adapters/devices/catalog.ts +38 -0
- package/src/adapters/devices/derive.ts +27 -0
- package/src/adapters/devices/index.ts +7 -0
- package/src/adapters/devices/lines.ts +212 -0
- package/src/adapters/devices/raisecom-ros/TRAPS.md +22 -0
- package/src/adapters/devices/raisecom-ros/index.ts +603 -0
- package/src/adapters/devices/raisecom-ros/models.ts +7 -0
- package/src/adapters/devices/routeros/TRAPS.md +33 -0
- package/src/adapters/devices/routeros/export.ts +319 -0
- package/src/adapters/devices/routeros/index.ts +261 -0
- package/src/adapters/devices/routeros/models.ts +8 -0
- package/src/adapters/devices/routeros/plan.ts +370 -0
- package/src/adapters/devices/routeros/render.ts +1431 -0
- package/src/adapters/devices/types.ts +31 -0
- package/src/adapters/devices/vrp/TRAPS.md +22 -0
- package/src/adapters/devices/vrp/index.ts +742 -0
- package/src/adapters/devices/vrp/models.ts +4 -0
- package/src/adapters/registries/index.ts +19 -0
- package/src/adapters/registries/peeringdb.ts +169 -0
- package/src/cli/index.ts +295 -0
- package/src/core/addr.ts +145 -0
- package/src/core/define.ts +94 -0
- package/src/core/secrets.ts +68 -0
- package/src/core/time.ts +15 -0
- package/src/core/validate.ts +380 -0
- package/src/index.ts +6 -0
- package/src/schema/common.ts +32 -0
- package/src/schema/device.ts +95 -0
- package/src/schema/firewall.ts +128 -0
- package/src/schema/index.ts +7 -0
- package/src/schema/interfaces.ts +192 -0
- package/src/schema/policy.ts +88 -0
- package/src/schema/routing.ts +120 -0
- package/src/schema/system.ts +191 -0
- package/src/transport/ssh.ts +125 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { ModelName, PlatformName, PortName } from "../adapters/devices/catalog";
|
|
2
|
+
import type { Device, Secret } from "../schema";
|
|
3
|
+
|
|
4
|
+
/** A value resolved by name when a device is applied, from the environment or `.env.local`. Never the value itself. */
|
|
5
|
+
export const secret = (name: string): Secret => ({ secret: name });
|
|
6
|
+
|
|
7
|
+
/** A value read from a file when a device is applied, such as a private key in PEM. The path is relative to the project root; keep the file out of git. */
|
|
8
|
+
export const secretFile = (path: string): Secret => {
|
|
9
|
+
if (!/^[A-Za-z0-9_./-]+$/.test(path)) throw new Error(`secret file path ${path} may hold only letters, digits, _ . / and -`);
|
|
10
|
+
return { secret_file: path };
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Declare one device.
|
|
15
|
+
*
|
|
16
|
+
* The names it declares (VLANs, interfaces, policies and the rest) are the only names its other fields accept, and the editor suggests them. The port names come from the model.
|
|
17
|
+
*/
|
|
18
|
+
export function defineDevice<
|
|
19
|
+
const Platform extends PlatformName,
|
|
20
|
+
const Model extends ModelName<Platform>,
|
|
21
|
+
const Vlans extends string = never,
|
|
22
|
+
const Interfaces extends string = never,
|
|
23
|
+
const Policies extends string = never,
|
|
24
|
+
const PrefixSets extends string = never,
|
|
25
|
+
const BgpGroups extends string = never,
|
|
26
|
+
const Acls extends string = never,
|
|
27
|
+
const AddressSets extends string = never,
|
|
28
|
+
const Users extends string = never,
|
|
29
|
+
const Vrfs extends string = never,
|
|
30
|
+
const Certificates extends string = never,
|
|
31
|
+
>(
|
|
32
|
+
device: Device<
|
|
33
|
+
Platform,
|
|
34
|
+
Model,
|
|
35
|
+
PortName<Platform, Model>,
|
|
36
|
+
Vlans,
|
|
37
|
+
Interfaces,
|
|
38
|
+
Policies,
|
|
39
|
+
PrefixSets,
|
|
40
|
+
BgpGroups,
|
|
41
|
+
Acls,
|
|
42
|
+
AddressSets,
|
|
43
|
+
Users,
|
|
44
|
+
Vrfs,
|
|
45
|
+
Certificates
|
|
46
|
+
>
|
|
47
|
+
): Device {
|
|
48
|
+
return device as unknown as Device;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Something a check found. */
|
|
52
|
+
export type Finding = {
|
|
53
|
+
/** An error stops `diff` and `apply`; a warning is reported only. */
|
|
54
|
+
level: "error" | "warning";
|
|
55
|
+
/** The device it is about, when it is about one. */
|
|
56
|
+
device?: string;
|
|
57
|
+
/** What is wrong, in a sentence. */
|
|
58
|
+
message: string;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** A rule of your own design, run by `validate` beside the framework's. */
|
|
62
|
+
export type Check = (devices: readonly Device[]) => readonly Finding[];
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One BGP community the network defines, in the community description format of the NLNOG Ring looking glass, which bgp.tools also reads.
|
|
66
|
+
*
|
|
67
|
+
* `community` takes `nnn` for any number, `x` for one digit and `a-b` for a range, such as `65535:0:nnn`. `description` refers to what each wildcard matched as `$0`, `$1` and so on, such as `do not announce to AS$0`.
|
|
68
|
+
*/
|
|
69
|
+
export type Community = {
|
|
70
|
+
community: string;
|
|
71
|
+
description: string;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** The whole network. */
|
|
75
|
+
export type Network = {
|
|
76
|
+
/** Every device the framework manages. */
|
|
77
|
+
devices: readonly Device[];
|
|
78
|
+
/** Rules of your own design. */
|
|
79
|
+
checks?: readonly Check[];
|
|
80
|
+
/** Where snapshots of what each device runs are kept, relative to the config file. */
|
|
81
|
+
state: string;
|
|
82
|
+
/** The AS the network operates, for what is published about it. */
|
|
83
|
+
asn?: number;
|
|
84
|
+
/** The BGP communities the network defines, printed by `circuit communities` for a looking glass or bgp.tools. */
|
|
85
|
+
communities?: readonly Community[];
|
|
86
|
+
/** Keeps the network's PeeringDB record in step with the config: `circuit peeringdb`. Needs `asn`. */
|
|
87
|
+
peeringdb?: {
|
|
88
|
+
/** An API key with write access to the network's record. */
|
|
89
|
+
api_key: Secret;
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** Declare the network, in `circuit.config.ts`. */
|
|
94
|
+
export const defineNetwork = (network: Network): Network => network;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Secrets are references in the config and values only at the moment a command is sent. A reference names a variable in the environment or `.env.local`, or a file kept out of the repository.
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { Secret } from "../schema";
|
|
6
|
+
|
|
7
|
+
let localFile: Record<string, string> | undefined;
|
|
8
|
+
|
|
9
|
+
/** `.env.local` in the working directory, read once. */
|
|
10
|
+
function local(): Record<string, string> {
|
|
11
|
+
if (localFile) return localFile;
|
|
12
|
+
|
|
13
|
+
localFile = {};
|
|
14
|
+
const path = join(process.cwd(), ".env.local");
|
|
15
|
+
if (!existsSync(path)) return localFile;
|
|
16
|
+
|
|
17
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
18
|
+
const trimmed = line.trim();
|
|
19
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
20
|
+
|
|
21
|
+
const separator = trimmed.indexOf("=");
|
|
22
|
+
if (separator <= 0) continue;
|
|
23
|
+
|
|
24
|
+
localFile[trimmed.slice(0, separator).trim()] = trimmed.slice(separator + 1).trim();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return localFile;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const isSecret = (value: unknown): value is Secret =>
|
|
31
|
+
typeof value === "object" && value !== null && ("secret" in value || "secret_file" in value);
|
|
32
|
+
|
|
33
|
+
/** How a reference is written inside a marker: a variable's name, or `file:` and a path. */
|
|
34
|
+
const referenceOf = (secret: Secret) => ("secret" in secret ? secret.secret : `file:${secret.secret_file}`);
|
|
35
|
+
|
|
36
|
+
/** The value behind a reference, as `referenceOf` writes it. */
|
|
37
|
+
function valueOf(reference: string): string {
|
|
38
|
+
if (reference.startsWith("file:")) {
|
|
39
|
+
const path = reference.slice("file:".length);
|
|
40
|
+
const fullPath = join(process.cwd(), path);
|
|
41
|
+
if (!existsSync(fullPath)) throw new Error(`secret file ${path} does not exist`);
|
|
42
|
+
|
|
43
|
+
const value = readFileSync(fullPath, "utf8").trimEnd();
|
|
44
|
+
if (!value) throw new Error(`secret file ${path} is empty`);
|
|
45
|
+
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const value = process.env[reference] ?? local()[reference];
|
|
50
|
+
if (!value) throw new Error(`secret ${reference} is not set in the environment or .env.local`);
|
|
51
|
+
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The value of a secret, or of a variable named directly. */
|
|
56
|
+
export const resolve = (secret: Secret | string): string => valueOf(typeof secret === "string" ? secret : referenceOf(secret));
|
|
57
|
+
|
|
58
|
+
/** How a secret appears in rendered config. Replaced with its value only when sent to a device. */
|
|
59
|
+
export const marker = (secret: Secret) => `<secret:${referenceOf(secret)}>`;
|
|
60
|
+
|
|
61
|
+
/** A marker anywhere in a command. Its group is the reference. */
|
|
62
|
+
export const MARKER = /<secret:([A-Za-z0-9_./:-]+)>/g;
|
|
63
|
+
|
|
64
|
+
/** Replace every marker in a command with its value. */
|
|
65
|
+
export const substitute = (text: string) => text.replace(MARKER, (_, reference: string) => valueOf(reference));
|
|
66
|
+
|
|
67
|
+
/** The value behind one marker's reference, for a platform that takes a secret interactively. */
|
|
68
|
+
export const valueOfReference = valueOf;
|
package/src/core/time.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Minutes east of UTC an IANA zone is at now, for platforms that take an offset rather than a zone. */
|
|
2
|
+
export function offset(zone: string): number {
|
|
3
|
+
const name = new Intl.DateTimeFormat("en-US", {
|
|
4
|
+
timeZone: zone,
|
|
5
|
+
timeZoneName: "longOffset",
|
|
6
|
+
})
|
|
7
|
+
.formatToParts(new Date())
|
|
8
|
+
.find((entry) => entry.type === "timeZoneName")!.value;
|
|
9
|
+
const found = /GMT([+-])(\d{2}):(\d{2})/.exec(name);
|
|
10
|
+
if (!found) return 0;
|
|
11
|
+
|
|
12
|
+
const [, sign, hours, minutes] = found;
|
|
13
|
+
const sizeInMinutes = Number(hours) * 60 + Number(minutes);
|
|
14
|
+
return sign === "-" ? -sizeInMinutes : sizeInMinutes;
|
|
15
|
+
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// Rules every network has to satisfy, whatever its design. Types catch most of this at compile time; these run again at load, because config is data and may not come from TypeScript.
|
|
2
|
+
|
|
3
|
+
import { X509Certificate } from "node:crypto";
|
|
4
|
+
import { adapters } from "../adapters/devices";
|
|
5
|
+
import { portsOf } from "../adapters/devices/catalog";
|
|
6
|
+
import type { Device } from "../schema";
|
|
7
|
+
import { contains, familyOf, isAddress, isCidr } from "./addr";
|
|
8
|
+
import type { Finding, Network } from "./define";
|
|
9
|
+
|
|
10
|
+
type Report = (message: string) => void;
|
|
11
|
+
|
|
12
|
+
export function validate(network: Network): Finding[] {
|
|
13
|
+
const findings: Finding[] = [];
|
|
14
|
+
|
|
15
|
+
const seen = new Set<string>();
|
|
16
|
+
|
|
17
|
+
for (const device of network.devices) {
|
|
18
|
+
if (seen.has(device.name)) findings.push({ level: "error", device: device.name, message: "declared twice" });
|
|
19
|
+
seen.add(device.name);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
for (const device of network.devices) {
|
|
23
|
+
const report: Report = (message) => findings.push({ level: "error", device: device.name, message });
|
|
24
|
+
checkDevice(device, report);
|
|
25
|
+
|
|
26
|
+
const adapter = adapters[device.platform];
|
|
27
|
+
if (!adapter) report(`no adapter for platform ${device.platform}`);
|
|
28
|
+
else for (const field of adapter.unsupported(device)) report(`${device.platform} cannot express ${field}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
findings.push(...checkLinks(network.devices));
|
|
32
|
+
for (const check of network.checks ?? []) findings.push(...check(network.devices));
|
|
33
|
+
return findings;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Names a device declares, and helpers to report a reference to one it does not. */
|
|
37
|
+
class Names {
|
|
38
|
+
readonly ports: string[];
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
readonly device: Device,
|
|
42
|
+
private readonly report: Report
|
|
43
|
+
) {
|
|
44
|
+
let ports: string[] = [];
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
ports = portsOf(device.platform, device.model);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
report((error as Error).message);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
this.ports = ports;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
isInterface(name: string): boolean {
|
|
56
|
+
return name in (this.device.interfaces ?? {}) || this.ports.includes(name);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Report `name` when it is set and not declared. */
|
|
60
|
+
expect(what: string, name: string | undefined, declared: boolean): void {
|
|
61
|
+
if (name !== undefined && !declared) this.report(`${what} '${name}' is not declared`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
expectInterface(what: string, name: string | undefined): void {
|
|
65
|
+
this.expect(what, name, name !== undefined && this.isInterface(name));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type Checker = (where: string, value: string) => void;
|
|
70
|
+
|
|
71
|
+
function checkDevice(device: Device, report: Report): void {
|
|
72
|
+
const names = new Names(device, report);
|
|
73
|
+
const cidr: Checker = (where, value) => {
|
|
74
|
+
if (!isCidr(value)) report(`${where}: '${value}' is not an address with a length`);
|
|
75
|
+
};
|
|
76
|
+
const ip: Checker = (where, value) => {
|
|
77
|
+
if (!isAddress(value)) report(`${where}: '${value}' is not an address`);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
if (!device.users?.[device.connection.user]) report(`connection user '${device.connection.user}' is not one of its users`);
|
|
81
|
+
|
|
82
|
+
checkVlans(device, report);
|
|
83
|
+
checkAddresses(device, report, cidr, ip);
|
|
84
|
+
checkPorts(device, names, report);
|
|
85
|
+
checkInterfaces(device, names, report, ip);
|
|
86
|
+
checkRouting(device, names, report, cidr, ip);
|
|
87
|
+
checkPolicies(device, names, report, cidr);
|
|
88
|
+
checkFirewall(device, names, cidr);
|
|
89
|
+
checkServices(device, names, report, cidr);
|
|
90
|
+
checkCertificates(device, names, report);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function checkVlans(device: Device, report: Report): void {
|
|
94
|
+
const ids = new Map<number, string>();
|
|
95
|
+
|
|
96
|
+
for (const [name, vlan] of Object.entries(device.vlans ?? {})) {
|
|
97
|
+
if (!Number.isInteger(vlan.id) || vlan.id < 1 || vlan.id > 4094) report(`VLAN ${name} id ${vlan.id} is outside 1-4094`);
|
|
98
|
+
if (ids.has(vlan.id)) report(`VLANs ${ids.get(vlan.id)} and ${name} share id ${vlan.id}`);
|
|
99
|
+
ids.set(vlan.id, name);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Every address is valid, and no address sits on two interfaces. */
|
|
104
|
+
function checkAddresses(device: Device, report: Report, cidr: Checker, ip: Checker): void {
|
|
105
|
+
const holderOf = new Map<string, string>();
|
|
106
|
+
const holders = [
|
|
107
|
+
...Object.entries(device.ports ?? {}).map(([name, settings]) => ({ where: `port ${name}`, settings })),
|
|
108
|
+
...Object.entries(device.interfaces ?? {}).map(([name, settings]) => ({ where: `interface ${name}`, settings })),
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
for (const { where, settings } of holders) {
|
|
112
|
+
for (const entry of settings?.addresses ?? []) {
|
|
113
|
+
const address = typeof entry === "string" ? entry : entry.address;
|
|
114
|
+
cidr(where, address);
|
|
115
|
+
if (typeof entry !== "string") ip(where, entry.peer);
|
|
116
|
+
|
|
117
|
+
const bare = address.split("/")[0]!;
|
|
118
|
+
if (holderOf.has(bare)) report(`${bare} is on both ${holderOf.get(bare)} and ${where}`);
|
|
119
|
+
holderOf.set(bare, where);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (const group of settings?.vrrp ?? []) {
|
|
123
|
+
ip(`${where} VRRP ${group.id}`, group.address);
|
|
124
|
+
if (group.id < 1 || group.id > 255) report(`${where} VRRP id ${group.id} is outside 1-255`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function checkPorts(device: Device, names: Names, report: Report): void {
|
|
130
|
+
const vlans = device.vlans ?? {};
|
|
131
|
+
|
|
132
|
+
for (const [name, port] of Object.entries(device.ports ?? {})) {
|
|
133
|
+
if (!port) continue;
|
|
134
|
+
|
|
135
|
+
const switched = Boolean(port.access_vlan || port.trunk_vlans);
|
|
136
|
+
|
|
137
|
+
if (!names.ports.includes(name)) report(`port ${name} does not exist on ${device.model}`);
|
|
138
|
+
if (port.access_vlan && port.trunk_vlans) report(`port ${name} is both access and trunk`);
|
|
139
|
+
if (port.native_vlan && !port.trunk_vlans) report(`port ${name} has a native VLAN but is not a trunk`);
|
|
140
|
+
if (switched && port.addresses?.length) report(`port ${name} is switched and routed at once`);
|
|
141
|
+
|
|
142
|
+
if (port.lag && (switched || port.addresses)) {
|
|
143
|
+
report(`port ${name} is in a LAG and states its own switching or addresses; the LAG holds them`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for (const vlan of [port.access_vlan, port.native_vlan, ...(port.trunk_vlans ?? [])]) {
|
|
147
|
+
names.expect(`port ${name} VLAN`, vlan, vlan === undefined || vlan in vlans);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
names.expect(`port ${name} ACL`, port.acl, port.acl !== undefined && port.acl in (device.acls ?? {}));
|
|
151
|
+
names.expect(`port ${name} LAG`, port.lag, port.lag !== undefined && device.interfaces?.[port.lag]?.type === "lag");
|
|
152
|
+
names.expect(`port ${name} VRF`, port.vrf, port.vrf !== undefined && port.vrf in (device.vrfs ?? {}));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function checkInterfaces(device: Device, names: Names, report: Report, ip: Checker): void {
|
|
157
|
+
const vlans = device.vlans ?? {};
|
|
158
|
+
|
|
159
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
160
|
+
if (names.ports.includes(name)) report(`interface ${name} has the name of a port`);
|
|
161
|
+
names.expect(`interface ${name} VRF`, iface.vrf, iface.vrf !== undefined && iface.vrf in (device.vrfs ?? {}));
|
|
162
|
+
|
|
163
|
+
if (iface.type === "vlan") names.expect(`interface ${name} VLAN`, iface.vlan, iface.vlan in vlans);
|
|
164
|
+
if (iface.type === "gre" || iface.type === "vxlan") {
|
|
165
|
+
ip(`interface ${name}`, iface.local);
|
|
166
|
+
ip(`interface ${name}`, iface.remote);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (iface.type === "lag") {
|
|
170
|
+
const members = Object.values(device.ports ?? {}).filter((port) => port?.lag === name);
|
|
171
|
+
if (!members.length) report(`LAG ${name} has no member ports`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (iface.type === "wireguard") {
|
|
175
|
+
for (const peer of iface.peers ?? []) {
|
|
176
|
+
for (const address of peer.allowed_addresses) {
|
|
177
|
+
if (!isCidr(address)) report(`interface ${name} peer ${peer.name}: '${address}' is not an address with a length`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function checkRouting(device: Device, names: Names, report: Report, cidr: Checker, ip: Checker): void {
|
|
185
|
+
const routing = device.routing;
|
|
186
|
+
const policies = device.policies ?? {};
|
|
187
|
+
|
|
188
|
+
for (const route of routing?.static ?? []) {
|
|
189
|
+
cidr("static route", route.prefix);
|
|
190
|
+
if (!route.via && !route.interface && !route.blackhole) report(`static route ${route.prefix} has no next hop`);
|
|
191
|
+
if (route.via) ip(`static route ${route.prefix}`, route.via);
|
|
192
|
+
names.expectInterface(`static route ${route.prefix} interface`, route.interface);
|
|
193
|
+
names.expect(`static route ${route.prefix} VRF`, route.vrf, route.vrf !== undefined && route.vrf in (device.vrfs ?? {}));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const bgp = routing?.bgp;
|
|
197
|
+
const isPolicy = (policy: string | undefined) => policy !== undefined && policy in policies;
|
|
198
|
+
|
|
199
|
+
for (const prefix of bgp?.networks ?? []) cidr("BGP network", prefix);
|
|
200
|
+
|
|
201
|
+
for (const [name, group] of Object.entries(bgp?.groups ?? {})) {
|
|
202
|
+
for (const policy of [group.import, group.export]) {
|
|
203
|
+
names.expect(`BGP group ${name} policy`, policy, isPolicy(policy));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
for (const [name, neighbor] of Object.entries(bgp?.neighbors ?? {})) {
|
|
208
|
+
ip(`neighbor ${name}`, neighbor.address);
|
|
209
|
+
|
|
210
|
+
const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
|
|
211
|
+
names.expect(`neighbor ${name} group`, neighbor.group, group !== undefined);
|
|
212
|
+
|
|
213
|
+
if (!(neighbor.remote_as ?? group?.remote_as)) report(`neighbor ${name} has no remote_as`);
|
|
214
|
+
|
|
215
|
+
for (const policy of [neighbor.import, neighbor.export]) {
|
|
216
|
+
names.expect(`neighbor ${name} policy`, policy, isPolicy(policy));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const local = neighbor.local_address ?? group?.local_address;
|
|
220
|
+
if (local && familyOf(local) !== familyOf(neighbor.address)) report(`neighbor ${name} local_address and address differ in family`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
for (const [areaId, area] of Object.entries(routing?.ospf?.areas ?? {})) {
|
|
224
|
+
if (!/^\d+\.\d+\.\d+\.\d+$/.test(areaId)) report(`OSPF area '${areaId}' is not written as a dotted area ID`);
|
|
225
|
+
for (const iface of Object.keys(area.interfaces)) names.expectInterface(`OSPF area ${areaId} interface`, iface);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function checkPolicies(device: Device, names: Names, report: Report, cidr: Checker): void {
|
|
230
|
+
const policies = device.policies ?? {};
|
|
231
|
+
const sets = device.prefix_sets ?? {};
|
|
232
|
+
|
|
233
|
+
for (const [name, rules] of Object.entries(policies)) {
|
|
234
|
+
for (const rule of rules) {
|
|
235
|
+
names.expect(`policy ${name} call`, rule.call, rule.call !== undefined && rule.call in policies);
|
|
236
|
+
const set = rule.match?.prefix_set;
|
|
237
|
+
names.expect(`policy ${name} prefix set`, set, set !== undefined && set in sets);
|
|
238
|
+
if (rule.match?.prefix) cidr(`policy ${name}`, rule.match.prefix);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// A policy that calls itself, directly or not, never finishes.
|
|
243
|
+
const visit = (policy: string, path: string[]): void => {
|
|
244
|
+
if (path.includes(policy)) return report(`policy ${[...path, policy].join(" -> ")} calls itself`);
|
|
245
|
+
|
|
246
|
+
for (const rule of policies[policy] ?? []) if (rule.call) visit(rule.call, [...path, policy]);
|
|
247
|
+
};
|
|
248
|
+
for (const policy of Object.keys(policies)) visit(policy, []);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function checkFirewall(device: Device, names: Names, cidr: Checker): void {
|
|
252
|
+
const firewall = device.firewall;
|
|
253
|
+
const sets = firewall?.address_sets ?? {};
|
|
254
|
+
|
|
255
|
+
for (const [name, members] of Object.entries(sets)) {
|
|
256
|
+
for (const member of members) {
|
|
257
|
+
if (!isAddress(member)) cidr(`address set ${name}`, member);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const filterChains = [firewall?.filter?.input, firewall?.filter?.forward, firewall?.filter?.output];
|
|
262
|
+
const filterRules = filterChains.flatMap((chain) => chain?.rules ?? []);
|
|
263
|
+
const natRules = [...(firewall?.nat?.source ?? []), ...(firewall?.nat?.destination ?? [])];
|
|
264
|
+
|
|
265
|
+
for (const rule of [...filterRules, ...natRules]) {
|
|
266
|
+
const match = rule.match;
|
|
267
|
+
if (!match) continue;
|
|
268
|
+
|
|
269
|
+
for (const set of [match.src_set, match.dst_set]) {
|
|
270
|
+
names.expect("address set", set, set !== undefined && set in sets);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
for (const iface of [match.in_interface, match.out_interface]) names.expectInterface("interface", iface);
|
|
274
|
+
|
|
275
|
+
for (const address of [match.src, match.dst]) {
|
|
276
|
+
if (address) cidr("firewall rule", address);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function checkServices(device: Device, names: Names, report: Report, cidr: Checker): void {
|
|
282
|
+
for (const [name, server] of Object.entries(device.dhcp ?? {})) {
|
|
283
|
+
names.expectInterface(`DHCP ${name} interface`, server.interface);
|
|
284
|
+
cidr(`DHCP ${name}`, server.network);
|
|
285
|
+
if (!isCidr(server.network)) continue;
|
|
286
|
+
|
|
287
|
+
const reserved = (server.reservations ?? []).map((reservation) => reservation.address);
|
|
288
|
+
const addresses = [server.gateway, ...server.pool, ...reserved];
|
|
289
|
+
|
|
290
|
+
for (const address of addresses) {
|
|
291
|
+
if (!contains(server.network, address)) report(`DHCP ${name}: ${address} is outside ${server.network}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
for (const [name, relay] of Object.entries(device.dhcp_relay ?? {})) {
|
|
296
|
+
names.expectInterface(`DHCP relay ${name} interface`, relay.interface);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
for (const iface of device.flow_export?.interfaces ?? []) names.expectInterface("flow export interface", iface);
|
|
300
|
+
|
|
301
|
+
const lldp = device.lldp;
|
|
302
|
+
if (lldp && lldp !== true) for (const iface of lldp.interfaces) names.expectInterface("LLDP interface", iface);
|
|
303
|
+
|
|
304
|
+
const management = device.management;
|
|
305
|
+
for (const iface of management?.native?.interfaces ?? []) names.expectInterface("native management interface", iface);
|
|
306
|
+
for (const prefix of management?.allow ?? []) cidr("management allow", prefix);
|
|
307
|
+
if (management?.snmp && !management.snmp.community && !management.snmp.users) report("SNMP is on with neither a community nor a user");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function checkCertificates(device: Device, names: Names, report: Report): void {
|
|
311
|
+
const certificates = device.certificates ?? {};
|
|
312
|
+
|
|
313
|
+
for (const [name, certificate] of Object.entries(certificates)) {
|
|
314
|
+
try {
|
|
315
|
+
new X509Certificate(certificate.certificate);
|
|
316
|
+
} catch {
|
|
317
|
+
report(`certificate ${name} is not a PEM certificate`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
for (const service of ["https", "api_tls"] as const) {
|
|
322
|
+
const name = device.management?.[service]?.certificate;
|
|
323
|
+
names.expect(`${service} certificate`, name, name !== undefined && name in certificates);
|
|
324
|
+
|
|
325
|
+
if (name && certificates[name] && !certificates[name].private_key) {
|
|
326
|
+
report(`${service} certificate ${name} has no private key to serve with`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** The VLAN ids a port carries, by how it carries them, so two ends can be compared across devices. */
|
|
332
|
+
function carried(device: Device, port: NonNullable<NonNullable<Device["ports"]>[string]>) {
|
|
333
|
+
const id = (vlan: string | undefined) => (vlan ? device.vlans?.[vlan]?.id : undefined);
|
|
334
|
+
return {
|
|
335
|
+
access: id(port.access_vlan),
|
|
336
|
+
native: id(port.native_vlan),
|
|
337
|
+
trunk: (port.trunk_vlans ?? []).map(id).sort((a, b) => (a ?? 0) - (b ?? 0)),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** A link names a device and port that exist, the far end links back, and both ends carry the same VLANs the same way. */
|
|
342
|
+
function checkLinks(devices: readonly Device[]): Finding[] {
|
|
343
|
+
const findings: Finding[] = [];
|
|
344
|
+
const byName = new Map(devices.map((device) => [device.name, device]));
|
|
345
|
+
|
|
346
|
+
for (const device of devices) {
|
|
347
|
+
for (const [portName, port] of Object.entries(device.ports ?? {})) {
|
|
348
|
+
const link = port?.link;
|
|
349
|
+
if (!port || !link) continue;
|
|
350
|
+
|
|
351
|
+
const report = (message: string) =>
|
|
352
|
+
findings.push({
|
|
353
|
+
level: "error",
|
|
354
|
+
device: device.name,
|
|
355
|
+
message: `port ${portName} link: ${message}`,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const far = byName.get(link.device);
|
|
359
|
+
if (!far) {
|
|
360
|
+
report(`device ${link.device} is not in the network`);
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const farPort = far.ports?.[link.port];
|
|
365
|
+
if (!farPort) {
|
|
366
|
+
report(`${link.device} does not declare port ${link.port}`);
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const linksBack = farPort.link?.device === device.name && farPort.link.port === portName;
|
|
371
|
+
if (!linksBack) report(`${link.device} ${link.port} does not link back to it`);
|
|
372
|
+
|
|
373
|
+
const here = JSON.stringify(carried(device, port));
|
|
374
|
+
const there = JSON.stringify(carried(far, farPort));
|
|
375
|
+
if (here !== there) report(`carries ${here}, and ${link.device} ${link.port} carries ${there}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return findings;
|
|
380
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { defineDevice, defineNetwork, secret, secretFile } from "./core/define";
|
|
2
|
+
export type { Check, Community, Finding, Network } from "./core/define";
|
|
3
|
+
export type * from "./schema";
|
|
4
|
+
|
|
5
|
+
/** Address helpers, for checks of your own. */
|
|
6
|
+
export { contains, familyOf, host, isAddress, isCidr, isGlobal, network as prefixOf } from "./core/addr";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A value kept out of the repository and resolved only when a device is applied.
|
|
3
|
+
*
|
|
4
|
+
* `secret("NAME")` reads a variable from the environment or `.env.local`. `secretFile("path")` reads a file, relative to the project root, for a value that spans lines such as a private key in PEM. Keep that file out of git.
|
|
5
|
+
*/
|
|
6
|
+
export type Secret =
|
|
7
|
+
| {
|
|
8
|
+
/** Name of the value in the environment or in `.env.local`. */
|
|
9
|
+
readonly secret: string;
|
|
10
|
+
}
|
|
11
|
+
| {
|
|
12
|
+
/** Path of a file holding the value, relative to the project root. */
|
|
13
|
+
readonly secret_file: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** An address without a length, IPv4 or IPv6, for example `10.0.0.1`. */
|
|
17
|
+
export type IP = string;
|
|
18
|
+
|
|
19
|
+
/** A network with its length, for example `10.0.0.0/24`. */
|
|
20
|
+
export type Prefix = string;
|
|
21
|
+
|
|
22
|
+
/** An interface address with its length, for example `10.0.0.1/24`. */
|
|
23
|
+
export type Address = string;
|
|
24
|
+
|
|
25
|
+
/** An address family. */
|
|
26
|
+
export type Family = "ipv4" | "ipv6";
|
|
27
|
+
|
|
28
|
+
/** Bits per second, for example `2G`, `500M` or `64k`. */
|
|
29
|
+
export type Rate = `${number}${"k" | "M" | "G"}`;
|
|
30
|
+
|
|
31
|
+
/** A log severity, most severe first. A threshold includes everything above it. */
|
|
32
|
+
export type Severity = "emergency" | "alert" | "critical" | "error" | "warning" | "notice" | "info" | "debug";
|