@takodotid/circuit 0.43.1 → 1.0.0-rc.2
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 +115 -37
- package/package.json +21 -58
- package/src/checks/across.ts +402 -0
- package/src/checks/built-in.ts +256 -0
- package/src/checks/device.ts +436 -0
- package/src/checks/index.ts +53 -0
- package/src/cli/advisories.ts +108 -0
- package/src/cli/app.ts +71 -0
- package/src/cli/editor-types.generated.ts +30 -0
- package/src/cli/editor-types.ts +69 -0
- package/src/cli/format.ts +124 -0
- package/src/cli/git-hooks.ts +79 -0
- package/src/cli/index.ts +350 -1170
- package/src/cli/migrate.ts +554 -0
- package/src/cli/project-commands.ts +656 -0
- package/src/cli/releases.ts +374 -0
- package/src/cli/remember.ts +339 -0
- package/src/cli/rename.ts +27 -33
- package/src/cli/secrets-password.ts +60 -0
- package/src/cli/secrets.ts +258 -0
- package/src/cli/setup.ts +300 -0
- package/src/cli/signing.ts +23 -0
- package/src/cli/text-editor.ts +268 -0
- package/src/cli/ui.ts +298 -0
- package/src/cli/version.ts +2 -0
- package/src/cli/versions.ts +158 -0
- package/src/config/address.ts +102 -0
- package/src/config/circuit.d.ts +30 -0
- package/src/config/common.ts +65 -0
- package/src/config/communities.ts +479 -0
- package/src/config/define.ts +684 -0
- package/src/config/device.ts +164 -0
- package/src/config/firewall.ts +150 -0
- package/src/config/index.ts +22 -0
- package/src/{schema → config}/interfaces.ts +121 -85
- package/src/config/keys.ts +434 -0
- package/src/config/management.ts +61 -0
- package/src/config/merge.ts +335 -0
- package/src/config/models.ts +70 -0
- package/src/config/monitoring.ts +64 -0
- package/src/config/network.ts +180 -0
- package/src/config/presets.ts +149 -0
- package/src/config/routing.ts +401 -0
- package/src/config/services.ts +51 -0
- package/src/config/switching.ts +84 -0
- package/src/config/system.ts +103 -0
- package/src/{schema → config}/time-zones.ts +1 -1
- package/src/core/address.ts +1 -1
- package/src/core/cabling.ts +8 -43
- package/src/core/graph.ts +40 -0
- package/src/{integrations → core}/registries.ts +5 -3
- package/src/core/secrets-file.ts +539 -0
- package/src/core/secrets.ts +83 -64
- package/src/core/topology.ts +103 -146
- package/src/core/utils.ts +12 -1
- package/src/model.ts +83 -0
- package/src/platforms/derive.ts +64 -37
- package/src/platforms/finding.ts +9 -0
- package/src/platforms/fortios/firewall.ts +76 -64
- package/src/platforms/fortios/index.ts +189 -125
- package/src/platforms/fortios/names.ts +51 -18
- package/src/platforms/fortios/render.ts +69 -62
- package/src/platforms/fortios/routing.ts +89 -58
- package/src/platforms/fortios/vdoms.ts +47 -16
- package/src/platforms/junos/TRAPS.md +2 -2
- package/src/platforms/junos/bgp.ts +63 -42
- package/src/platforms/junos/index.ts +122 -89
- package/src/platforms/junos/render.ts +139 -104
- package/src/platforms/platform.ts +10 -8
- package/src/platforms/raisecom-ros/TRAPS.md +1 -1
- package/src/platforms/raisecom-ros/index.ts +89 -70
- package/src/platforms/raisecom-ros/render.ts +49 -45
- package/src/platforms/routeros/TRAPS.md +3 -3
- package/src/platforms/routeros/index.ts +121 -60
- package/src/platforms/routeros/render.ts +214 -195
- package/src/platforms/vrp/TRAPS.md +1 -1
- package/src/platforms/vrp/index.ts +74 -50
- package/src/platforms/vrp/render.ts +67 -56
- package/src/project/project.ts +725 -0
- package/src/studio/app/env.d.ts +9 -0
- package/src/studio/app/index.html +18 -0
- package/src/studio/app/src/App.vue +95 -0
- package/src/studio/app/src/components/AddressesView.vue +38 -0
- package/src/studio/app/src/components/CablesView.vue +29 -0
- package/src/studio/app/src/components/CompareView.vue +160 -0
- package/src/studio/app/src/components/DataTable.vue +49 -0
- package/src/studio/app/src/components/DeviceView.vue +237 -0
- package/src/studio/app/src/components/DevicesView.vue +32 -0
- package/src/studio/app/src/components/Faceplate.vue +75 -0
- package/src/studio/app/src/components/GraphView.vue +282 -0
- package/src/studio/app/src/components/LineDiff.vue +24 -0
- package/src/studio/app/src/components/ProblemsView.vue +42 -0
- package/src/studio/app/src/components/SearchView.vue +72 -0
- package/src/studio/app/src/components/SecretsView.vue +112 -0
- package/src/studio/app/src/components/SessionsView.vue +35 -0
- package/src/studio/app/src/components/Settings.vue +95 -0
- package/src/studio/app/src/components/ToolsView.vue +118 -0
- package/src/studio/app/src/components/VlansView.vue +50 -0
- package/src/studio/app/src/lib/csv.ts +19 -0
- package/src/studio/{public/diff.js → app/src/lib/diff.ts} +40 -35
- package/src/studio/app/src/lib/format.ts +38 -0
- package/src/studio/app/src/lib/net.ts +68 -0
- package/src/studio/app/src/lib/secrets-crypto.ts +35 -0
- package/src/studio/app/src/lib/store.ts +71 -0
- package/src/studio/app/src/lib/tools.ts +217 -0
- package/src/studio/app/src/main.ts +9 -0
- package/src/studio/{public → app/src}/style.css +14 -4
- package/src/studio/app/tsconfig.json +17 -0
- package/src/studio/app/vite.config.ts +16 -0
- package/src/studio/assets.generated.ts +3 -0
- package/src/studio/data.ts +96 -188
- package/src/studio/git.ts +27 -43
- package/src/studio/server.ts +196 -107
- package/src/studio/types.ts +141 -0
- package/src/transport/console.ts +3 -3
- package/src/transport/ssh.ts +1 -1
- package/dist/chunks/index-3b343xva.js +0 -177
- package/dist/chunks/index-6wb41q67.js +0 -62
- package/dist/chunks/index-n7w3tg5y.js +0 -149
- package/dist/chunks/index-yzw86rc8.js +0 -921
- package/dist/chunks/new-w3zys5y4.js +0 -172
- package/dist/chunks/server-cxr9ynxc.js +0 -259
- package/dist/cli/index.js +0 -12922
- package/dist/helpers/index.js +0 -881
- package/dist/index.js +0 -28
- package/dist/types/core/address-space.d.ts +0 -62
- package/dist/types/core/address.d.ts +0 -44
- package/dist/types/core/cabling.d.ts +0 -41
- package/dist/types/core/cross-checks.d.ts +0 -28
- package/dist/types/core/define.d.ts +0 -79
- package/dist/types/core/project-files.d.ts +0 -12
- package/dist/types/core/schema-docs.d.ts +0 -25
- package/dist/types/core/secrets.d.ts +0 -62
- package/dist/types/core/topology.d.ts +0 -96
- package/dist/types/core/utils.d.ts +0 -10
- package/dist/types/core/validator.d.ts +0 -23
- package/dist/types/helpers/bgp.d.ts +0 -195
- package/dist/types/helpers/cables.d.ts +0 -69
- package/dist/types/helpers/checks.d.ts +0 -36
- package/dist/types/helpers/exchange.d.ts +0 -55
- package/dist/types/helpers/filters.d.ts +0 -51
- package/dist/types/helpers/index.d.ts +0 -14
- package/dist/types/helpers/route-server.d.ts +0 -52
- package/dist/types/helpers/upstreams.d.ts +0 -192
- package/dist/types/index.d.ts +0 -10
- package/dist/types/integrations/librenms.d.ts +0 -60
- package/dist/types/integrations/netbox.d.ts +0 -89
- package/dist/types/integrations/peeringdb.d.ts +0 -24
- package/dist/types/integrations/prometheus.d.ts +0 -24
- package/dist/types/integrations/registries.d.ts +0 -8
- package/dist/types/platforms/bgp-wait.d.ts +0 -24
- package/dist/types/platforms/catalog.d.ts +0 -109
- package/dist/types/platforms/derive.d.ts +0 -17
- package/dist/types/platforms/fortios/firewall.d.ts +0 -47
- package/dist/types/platforms/fortios/index.d.ts +0 -140
- package/dist/types/platforms/fortios/models.d.ts +0 -14
- package/dist/types/platforms/fortios/names.d.ts +0 -52
- package/dist/types/platforms/fortios/plan.d.ts +0 -114
- package/dist/types/platforms/fortios/render.d.ts +0 -77
- package/dist/types/platforms/fortios/routing.d.ts +0 -56
- package/dist/types/platforms/fortios/tree.d.ts +0 -37
- package/dist/types/platforms/fortios/vdoms.d.ts +0 -50
- package/dist/types/platforms/index.d.ts +0 -13
- package/dist/types/platforms/junos/bgp.d.ts +0 -44
- package/dist/types/platforms/junos/index.d.ts +0 -114
- package/dist/types/platforms/junos/models.d.ts +0 -14
- package/dist/types/platforms/junos/render.d.ts +0 -154
- package/dist/types/platforms/line-diff.d.ts +0 -67
- package/dist/types/platforms/platform.d.ts +0 -135
- package/dist/types/platforms/raisecom-ros/index.d.ts +0 -71
- package/dist/types/platforms/raisecom-ros/models.d.ts +0 -14
- package/dist/types/platforms/raisecom-ros/render.d.ts +0 -41
- package/dist/types/platforms/routeros/export.d.ts +0 -59
- package/dist/types/platforms/routeros/index.d.ts +0 -116
- package/dist/types/platforms/routeros/models.d.ts +0 -18
- package/dist/types/platforms/routeros/plan.d.ts +0 -58
- package/dist/types/platforms/routeros/render.d.ts +0 -250
- package/dist/types/platforms/vrp/index.d.ts +0 -83
- package/dist/types/platforms/vrp/models.d.ts +0 -12
- package/dist/types/platforms/vrp/render.d.ts +0 -96
- package/dist/types/schema/common.d.ts +0 -30
- package/dist/types/schema/device.d.ts +0 -126
- package/dist/types/schema/firewall.d.ts +0 -131
- package/dist/types/schema/index.d.ts +0 -8
- package/dist/types/schema/interfaces.d.ts +0 -205
- package/dist/types/schema/policy.d.ts +0 -107
- package/dist/types/schema/routing.d.ts +0 -161
- package/dist/types/schema/system.d.ts +0 -198
- package/dist/types/schema/time-zones.d.ts +0 -2
- package/dist/types/transport/console.d.ts +0 -115
- package/dist/types/transport/path-check.d.ts +0 -32
- package/dist/types/transport/ssh.d.ts +0 -27
- package/src/cli/graph-format.ts +0 -91
- package/src/cli/new.ts +0 -234
- package/src/core/address-space.ts +0 -320
- package/src/core/cross-checks.ts +0 -369
- package/src/core/define.ts +0 -124
- package/src/core/project-files.ts +0 -35
- package/src/core/schema-docs.ts +0 -174
- package/src/core/validator.ts +0 -508
- package/src/helpers/bgp.ts +0 -473
- package/src/helpers/cables.ts +0 -74
- package/src/helpers/checks.ts +0 -201
- package/src/helpers/exchange.ts +0 -123
- package/src/helpers/filters.ts +0 -107
- package/src/helpers/index.ts +0 -25
- package/src/helpers/route-server.ts +0 -212
- package/src/helpers/upstreams.ts +0 -369
- package/src/index.ts +0 -13
- package/src/integrations/librenms.ts +0 -291
- package/src/integrations/netbox.ts +0 -620
- package/src/integrations/peeringdb.ts +0 -173
- package/src/integrations/prometheus.ts +0 -90
- package/src/schema/common.ts +0 -41
- package/src/schema/device.ts +0 -145
- package/src/schema/firewall.ts +0 -140
- package/src/schema/index.ts +0 -8
- package/src/schema/policy.ts +0 -115
- package/src/schema/routing.ts +0 -173
- package/src/schema/system.ts +0 -203
- package/src/studio/public/app.js +0 -186
- package/src/studio/public/compare.js +0 -250
- package/src/studio/public/dom.js +0 -138
- package/src/studio/public/evaluate.js +0 -687
- package/src/studio/public/export.js +0 -130
- package/src/studio/public/graph.js +0 -1541
- package/src/studio/public/index.html +0 -83
- package/src/studio/public/net.js +0 -134
- package/src/studio/public/tables.js +0 -1289
- package/src/studio/public/tools.js +0 -610
- package/templates/colocation/checks.ts +0 -53
- package/templates/colocation/circuit.config.ts +0 -9
- package/templates/colocation/edge-01.ts +0 -133
- package/templates/colocation/routing.ts +0 -13
- package/templates/colocation/tenancy.ts +0 -76
- package/templates/colocation/tenants/acme.ts +0 -19
- package/templates/colocation/tenants/index.ts +0 -6
- package/templates/colocation/tor-01.ts +0 -33
- package/templates/edge-router/checks.ts +0 -20
- package/templates/edge-router/circuit.config.ts +0 -8
- package/templates/edge-router/edge-01.ts +0 -110
- package/templates/edge-router/routing.ts +0 -13
- package/templates/project/AGENTS.md +0 -51
- package/templates/project/CLAUDE.md +0 -1
- package/templates/project/README.md +0 -16
- package/templates/project/gitignore +0 -5
- package/templates/project/tsconfig.json +0 -13
- package/templates/single-site/checks.ts +0 -26
- package/templates/single-site/circuit.config.ts +0 -8
- package/templates/single-site/router.ts +0 -86
- package/templates/single-site/site.ts +0 -18
- package/templates/single-site/switch.ts +0 -34
- /package/src/studio/{public → app/public}/favicon.svg +0 -0
- /package/src/studio/{public → app/public}/special-gothic-latin-ext.woff2 +0 -0
- /package/src/studio/{public → app/public}/special-gothic-latin.woff2 +0 -0
- /package/src/studio/{public → app/public}/special-gothic-license.txt +0 -0
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
// What Circuit does with a project: import its `circuit.ts`, then every device file under `sites/`, check what the types cannot, run the project's own
|
|
2
|
+
// checks on the network resolved, and turn what the language adds (lists in a match, interface sets, `bgpNeighbors`, `{ communities }` rules, an
|
|
3
|
+
// address with its length where the model keeps it alone, `management.allow` as an address set's name) into the model's plain values, which the
|
|
4
|
+
// platforms read (`src/model.ts`). It fills in nothing but the network's AS on BGP, which the model needs.
|
|
5
|
+
|
|
6
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
7
|
+
import { basename, isAbsolute, resolve, sep } from "node:path";
|
|
8
|
+
import { pathToFileURL } from "node:url";
|
|
9
|
+
import { catalogue, schemeProblems, writeCommunities } from "../config/communities";
|
|
10
|
+
import { BGP_NEIGHBORS, type Registered, registry } from "../config/define";
|
|
11
|
+
import type { AnyDevice } from "../config/device";
|
|
12
|
+
import type { Community, Finding, ResolvedNetwork } from "../config/network";
|
|
13
|
+
import * as globals from "../config/index";
|
|
14
|
+
import { ACTIONS, CHECKS, DEVICE, NETWORK, POLICY_RULE, closest, unknownKeyHelp, unknownKeys } from "../config/keys";
|
|
15
|
+
import { isOrdered, lineOf, source, userFrames, writtenAt } from "../config/merge";
|
|
16
|
+
import { NUMBERED_VLANS, portsOf, spell } from "../config/models";
|
|
17
|
+
import { policyRuleProblem } from "../config/routing";
|
|
18
|
+
import { Secrets } from "../core/secrets";
|
|
19
|
+
import { SecretsFile } from "../core/secrets-file";
|
|
20
|
+
import { Utils } from "../core/utils";
|
|
21
|
+
import type { Device } from "../model";
|
|
22
|
+
|
|
23
|
+
// The model's JSON, walked without its types.
|
|
24
|
+
type Json = any;
|
|
25
|
+
|
|
26
|
+
/** One network of a project, as its devices run it. */
|
|
27
|
+
export type ProjectNetwork = {
|
|
28
|
+
/** Its name in `circuit.ts`, such as `as64500`. */
|
|
29
|
+
name: string;
|
|
30
|
+
asn?: number;
|
|
31
|
+
/** What each community of the network's scheme means, for a looking glass or bgp.tools. */
|
|
32
|
+
communities: Community[];
|
|
33
|
+
/** Each device, in the model the platforms read, by hostname. */
|
|
34
|
+
devices: Record<string, Device>;
|
|
35
|
+
/** The file that writes each device, from the project's folder. */
|
|
36
|
+
files: Record<string, string>;
|
|
37
|
+
/** The rest of `circuit()`: sites, checks, integrations. */
|
|
38
|
+
settings: Json;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** The secrets file of a network, relative to the project: the one `secrets.file` names, or `.circuit/secrets`. */
|
|
42
|
+
export function secretsFileOf(network: Pick<ProjectNetwork, "settings">): string {
|
|
43
|
+
return ((network.settings.secrets as Json | undefined)?.file as string | undefined) ?? SecretsFile.DEFAULT;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** What reading a project found: each network, and what Circuit tells but doesn't stop on. What stops it is thrown as `ProjectError`. */
|
|
47
|
+
export type ProjectRead = { networks: ProjectNetwork[]; warnings: string[] };
|
|
48
|
+
|
|
49
|
+
/** A project that can't be read, with each reason. */
|
|
50
|
+
export class ProjectError extends Error {
|
|
51
|
+
constructor(readonly lines: string[]) {
|
|
52
|
+
super(lines.join("\n"));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let project = "";
|
|
57
|
+
|
|
58
|
+
/** Paths from the project root, with forward slashes. */
|
|
59
|
+
const short = (text: string) => text.replaceAll(project + sep, "").replaceAll("\\", "/");
|
|
60
|
+
|
|
61
|
+
const stop = (lines: string[]): never => {
|
|
62
|
+
throw new ProjectError(lines.map(short));
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const many = (v: unknown): unknown[] => (v === undefined ? [] : Array.isArray(v) ? v : [v]);
|
|
66
|
+
const keys = (map: unknown) => Object.keys((map as object | undefined) ?? {});
|
|
67
|
+
/** Text as a regular expression that matches it as written. */
|
|
68
|
+
const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
69
|
+
let findings: string[] = [];
|
|
70
|
+
/** What Circuit tells but doesn't stop on. */
|
|
71
|
+
let warnings: string[] = [];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Imports one file of the project. Its `merge` calls and devices run: a value written twice, or an ordered list from two places, stops it
|
|
75
|
+
* there, naming both files. Anything else that stops it, such as a misspelt global, is said with the user's line it stopped at.
|
|
76
|
+
*/
|
|
77
|
+
async function load(file: string): Promise<Record<string, unknown>> {
|
|
78
|
+
registry.reading = file;
|
|
79
|
+
try {
|
|
80
|
+
return await import(pathToFileURL(file).href);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
83
|
+
const at = error instanceof Error && !/^\S+\.ts:\d+: /.test(message) ? (userFrames(error)[0] ?? file) : undefined;
|
|
84
|
+
return stop([at ? `${at}: ${message}` : message]);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Reads the project in `folder`: imports `circuit.ts` and each device file, checks the whole network, runs the project's own checks,
|
|
90
|
+
* and gives each network's devices in the model. Throws `ProjectError` with every reason when the project can't be read.
|
|
91
|
+
*/
|
|
92
|
+
export async function readProject(folder: string): Promise<ProjectRead> {
|
|
93
|
+
project = resolve(folder);
|
|
94
|
+
findings = [];
|
|
95
|
+
warnings = [];
|
|
96
|
+
registry.networks.length = 0;
|
|
97
|
+
registry.sources.length = 0;
|
|
98
|
+
registry.rules.length = 0;
|
|
99
|
+
Utils.root = project;
|
|
100
|
+
Secrets.useRoot(project);
|
|
101
|
+
// Circuit's functions and `presets` are globals (circuit.d.ts): the project's files use them with no import.
|
|
102
|
+
Object.assign(globalThis, globals);
|
|
103
|
+
// Where a key is written is found by reading the file that writes it.
|
|
104
|
+
source.read = (file) => readFileSync(file, "utf8");
|
|
105
|
+
|
|
106
|
+
if (!existsSync(resolve(project, "circuit.ts")))
|
|
107
|
+
stop([`${project} has no circuit.ts. Start one by running circuit, or give the project's folder.`]);
|
|
108
|
+
|
|
109
|
+
// circuit.ts first: the networks it exports.
|
|
110
|
+
const root = await load(resolve(project, "circuit.ts"));
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Then every device file: each `.ts` under `sites/`, at any depth, but `site.ts`, which holds what a site's devices share. `lib/` and
|
|
114
|
+
* `tenants/` are imported only by the files that use them. A device file's `<network>.device(...)` registers it with that network.
|
|
115
|
+
*/
|
|
116
|
+
const sites = resolve(project, "sites");
|
|
117
|
+
const files = existsSync(sites)
|
|
118
|
+
? (readdirSync(sites, { recursive: true }) as string[])
|
|
119
|
+
.filter((f) => f.endsWith(".ts") && !f.endsWith(".d.ts") && basename(f) !== "site.ts")
|
|
120
|
+
.map((f) => resolve(sites, f))
|
|
121
|
+
.sort()
|
|
122
|
+
: [];
|
|
123
|
+
for (const file of files) await load(file);
|
|
124
|
+
|
|
125
|
+
/** Each network, by its name in circuit.ts, or by its AS when it is the default export. */
|
|
126
|
+
const networks: { name: string; registered: Registered; network: Json; devices: Record<string, AnyDevice> }[] = [];
|
|
127
|
+
for (const registered of registry.networks) {
|
|
128
|
+
const name = Object.keys(root).find((k) => root[k] === registered.network);
|
|
129
|
+
const network = registered.network as Json;
|
|
130
|
+
if (name === undefined)
|
|
131
|
+
findings.push(
|
|
132
|
+
`${registered.at}: this network is not exported from circuit.ts. Export each network under its own name, after its AS: export const as64500 = circuit({ ... }).`
|
|
133
|
+
);
|
|
134
|
+
else networks.push({ name: name === "default" ? `as${network.asn}` : name, registered, network, devices: {} });
|
|
135
|
+
}
|
|
136
|
+
for (const { registered, network } of networks) {
|
|
137
|
+
const file = (network.secrets as Json | undefined)?.file;
|
|
138
|
+
if (
|
|
139
|
+
network.secrets !== undefined &&
|
|
140
|
+
(typeof file !== "string" || !file.trim() || isAbsolute(file) || file.split(/[\\/]/).includes(".."))
|
|
141
|
+
)
|
|
142
|
+
findings.push(
|
|
143
|
+
`${registered.at}: secrets.file is a path inside the project, such as ".circuit/lab-secrets". Leave secrets out for .circuit/secrets.`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (!registry.networks.length)
|
|
147
|
+
stop([
|
|
148
|
+
"circuit.ts makes no network. Write it as export const as64500 = circuit({ asn, sites, checks, integrations }), named after its AS.",
|
|
149
|
+
]);
|
|
150
|
+
|
|
151
|
+
/** Each device file registers one device. */
|
|
152
|
+
const registeredBy = new Map<string, string[]>();
|
|
153
|
+
for (const { devices } of registry.networks)
|
|
154
|
+
for (const { device, file } of devices) registeredBy.set(file, [...(registeredBy.get(file) ?? []), device.name]);
|
|
155
|
+
for (const file of files) {
|
|
156
|
+
const names = registeredBy.get(file) ?? [];
|
|
157
|
+
if (!names.length)
|
|
158
|
+
findings.push(
|
|
159
|
+
`${file}: registers no device. Each .ts under sites/, but site.ts, is one device: export default as64500.device({ ... }), with your network's name. What a site's devices share goes in its site.ts.`
|
|
160
|
+
);
|
|
161
|
+
else if (names.length > 1)
|
|
162
|
+
findings.push(`${file}: registers ${names.length} devices, ${names.join(", ")}. Write one file per device.`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Each network's devices by name. A name twice is an error naming both files. */
|
|
166
|
+
for (const n of networks)
|
|
167
|
+
for (const { device, at } of n.registered.devices) {
|
|
168
|
+
const first = n.registered.devices.find((d) => d.device.name === device.name)!;
|
|
169
|
+
if (first.device !== device)
|
|
170
|
+
findings.push(
|
|
171
|
+
`${at}: ${device.name} is registered twice with network ${n.name}, here and in ${first.at}. A hostname is one device.`
|
|
172
|
+
);
|
|
173
|
+
else n.devices[device.name] = device;
|
|
174
|
+
}
|
|
175
|
+
const everyDevice = networks.flatMap((n) => Object.values(n.devices));
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Where a finding of the project's own check is, as `file:line: `: the line that writes the deepest key of `path` its device has, such
|
|
179
|
+
* as a port a tenant's file gives; with no path, or none of it found, the line that made the device.
|
|
180
|
+
*/
|
|
181
|
+
function foundAt(device: AnyDevice, path: string | undefined, registeredAt: string): string {
|
|
182
|
+
const names: string[] = [];
|
|
183
|
+
let value: Json = device;
|
|
184
|
+
let last: [Json, string] | undefined;
|
|
185
|
+
for (const step of path?.match(/[^.[\]]+/g) ?? []) {
|
|
186
|
+
if (!value || typeof value !== "object" || !Object.hasOwn(value, step)) break;
|
|
187
|
+
if (!Array.isArray(value)) {
|
|
188
|
+
names.push(step);
|
|
189
|
+
last = [value, step];
|
|
190
|
+
}
|
|
191
|
+
value = value[step];
|
|
192
|
+
}
|
|
193
|
+
return (last && writtenAt(last[0], last[1], names)) || `${registeredAt}: `;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Each address set written `bgpNeighbors()` gets every BGP neighbor address of its device. */
|
|
197
|
+
for (const d of everyDevice as Json[]) {
|
|
198
|
+
const sets = d.firewall?.address_sets ?? {};
|
|
199
|
+
for (const [name, set] of Object.entries<Json>(sets))
|
|
200
|
+
if (Object.hasOwn(set, BGP_NEIGHBORS)) sets[name] = Object.values<Json>(d.routing?.bgp?.neighbors ?? {}).map((n) => n.address);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------- what only Circuit can check: the whole network ----------
|
|
204
|
+
|
|
205
|
+
/** Each hostname a source gives a part to is a device of the network of the device it is given to: a misspelt one would give its part to no one. */
|
|
206
|
+
const reported = new Set<string>();
|
|
207
|
+
for (const { source, device, file, network } of registry.sources) {
|
|
208
|
+
const devices = networks.find((n) => n.registered.network === network)?.devices ?? {};
|
|
209
|
+
for (const host of keys(source)) {
|
|
210
|
+
const written = writtenAt(source, host);
|
|
211
|
+
const where = written ? `${written}the source made here` : `${file}: a source given to ${device}`;
|
|
212
|
+
if (devices[host] || reported.has(`${where} ${host}`)) continue;
|
|
213
|
+
reported.add(`${where} ${host}`);
|
|
214
|
+
findings.push(`${where} has a part for ${host}, which is no device of the network. The devices: ${keys(devices).join(", ")}.`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Every ordered list of a device: a chain's rules, NAT, an ACL, a route policy, policy routing. */
|
|
219
|
+
function* orderedLists(value: unknown, path = ""): Generator<readonly object[]> {
|
|
220
|
+
if (Array.isArray(value)) {
|
|
221
|
+
if (isOrdered(path)) yield value;
|
|
222
|
+
} else if (value && typeof value === "object")
|
|
223
|
+
for (const [k, v] of Object.entries(value)) yield* orderedLists(v, path ? `${path}.${k}` : k);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Every rule a rule-list `part` made is in some device's ordered list: the file that writes the list has to place it, and may forget. */
|
|
227
|
+
const placed = new Set<object>(everyDevice.flatMap((d) => [...orderedLists(d)].flat()));
|
|
228
|
+
for (const { rules, file } of registry.rules) {
|
|
229
|
+
const lost = rules.filter((rule) => !placed.has(rule)) as Json[];
|
|
230
|
+
if (lost.length)
|
|
231
|
+
findings.push(
|
|
232
|
+
`${file}: ${lost.length} of the rules made here with part ${lost.length === 1 ? "is" : "are"} in no device: ${lost.map((r) => JSON.stringify(r.description ?? r.if ?? r.match ?? r)).join(", ")}. Place them in the ordered list where they go, with merge.`
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---------- each network ----------
|
|
237
|
+
|
|
238
|
+
/** Each device's `site` is one of the network's sites, each site has a device, and each device a check names is one. */
|
|
239
|
+
function places(network: Json, devices: Record<string, AnyDevice>, at: (name: string) => string) {
|
|
240
|
+
const sites = keys(network.sites);
|
|
241
|
+
for (const [name, d] of Object.entries(devices))
|
|
242
|
+
if (!sites.includes(d.site)) {
|
|
243
|
+
const hint = closest(d.site, sites);
|
|
244
|
+
findings.push(
|
|
245
|
+
`${at(name)}${name}: site ${d.site} is no site of the network${hint ? `. Did you mean ${hint}?` : `. The sites: ${sites.join(", ")}.`}`
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
for (const site of sites)
|
|
249
|
+
if (!Object.values(devices).some((d) => d.site === site))
|
|
250
|
+
findings.push(`sites.${site}: no device is at this site. Remove it, or add a device.`);
|
|
251
|
+
const trust = network.checks?.untrusted_vlans;
|
|
252
|
+
for (const [field, named] of [
|
|
253
|
+
["filtering_devices", trust?.filtering_devices],
|
|
254
|
+
["mixed_links", trust?.mixed_links?.flat()],
|
|
255
|
+
] as const)
|
|
256
|
+
for (const name of many(named) as string[])
|
|
257
|
+
if (!devices[name])
|
|
258
|
+
findings.push(`checks.untrusted_vlans.${field}: no device ${name}. The devices: ${keys(devices).join(", ")}.`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* `untrusted: true` on a VLAN is only for the check `untrusted_vlans`, so it needs the check on. A VLAN written so on one device is
|
|
263
|
+
* written so on every device that declares it.
|
|
264
|
+
*/
|
|
265
|
+
function untrusted(network: Json, devices: Record<string, AnyDevice>) {
|
|
266
|
+
const flags = new Map<string, { on: string[]; off: string[] }>();
|
|
267
|
+
// Without the check, each place that writes the flag, once, with the devices that take the VLAN from there.
|
|
268
|
+
const unread = new Map<string, string[]>();
|
|
269
|
+
for (const d of Object.values(devices)) {
|
|
270
|
+
for (const [name, vlan] of Object.entries<Json>(d.switching?.vlans ?? {})) {
|
|
271
|
+
const flag = flags.get(name) ?? { on: [], off: [] };
|
|
272
|
+
if (vlan.untrusted) flag.on.push(d.name);
|
|
273
|
+
else flag.off.push(`${d.name} (${writtenAt(vlan, "id", ["vlans", name]).slice(0, -2) || "?"})`);
|
|
274
|
+
flags.set(name, flag);
|
|
275
|
+
if (!vlan.untrusted || network.checks?.untrusted_vlans) continue;
|
|
276
|
+
const where = `${writtenAt(vlan, "untrusted", ["vlans", name])}VLAN ${name}`;
|
|
277
|
+
unread.set(where, [...(unread.get(where) ?? []), d.name]);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
for (const [where, names] of [...unread].sort(([a], [b]) => a.localeCompare(b, "en", { numeric: true })))
|
|
281
|
+
findings.push(
|
|
282
|
+
`${where} has untrusted: true (${names.join(", ")}), but circuit.ts doesn't turn on the check untrusted_vlans, the only thing that reads it. Turn it on, or remove untrusted.`
|
|
283
|
+
);
|
|
284
|
+
for (const [name, { on, off }] of flags)
|
|
285
|
+
if (on.length && off.length)
|
|
286
|
+
findings.push(
|
|
287
|
+
`VLAN ${name} is untrusted on ${on.join(", ")} but not on ${off.join(", ")}: write untrusted: true on every device that declares it.`
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** One VLAN name has one ID on every device: a VLAN with two or more is named once, with each ID, its devices, and where each is written. */
|
|
292
|
+
function sameVlanIds(network: Json, devices: Record<string, AnyDevice>) {
|
|
293
|
+
if (!network.checks?.same_vlan_ids) return;
|
|
294
|
+
const ids = new Map<string, Map<number, string[]>>();
|
|
295
|
+
for (const d of Object.values(devices))
|
|
296
|
+
for (const [name, vlan] of Object.entries<Json>(d.switching?.vlans ?? {})) {
|
|
297
|
+
const byId = ids.get(name) ?? new Map<number, string[]>();
|
|
298
|
+
byId.set(vlan.id, [
|
|
299
|
+
...(byId.get(vlan.id) ?? []),
|
|
300
|
+
`${d.name} (${writtenAt(vlan, "id", ["vlans", name]).slice(0, -2) || "?"})`,
|
|
301
|
+
]);
|
|
302
|
+
ids.set(name, byId);
|
|
303
|
+
}
|
|
304
|
+
for (const [name, byId] of ids)
|
|
305
|
+
if (byId.size > 1)
|
|
306
|
+
findings.push(
|
|
307
|
+
`VLAN ${name} has ${byId.size} IDs: ${[...byId].map(([id, on]) => `${id} on ${on.join(", ")}`).join("; ")}. One VLAN name has one ID in the network (check same_vlan_ids).`
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---------- each device ----------
|
|
312
|
+
|
|
313
|
+
/** The steps of a path that are keys, not list positions: `firewall.nat.source[0].if` is firewall, nat, source, if. */
|
|
314
|
+
const steps = (path: string) => path.match(/[^.[\]]+/g)?.filter((s) => !/^\d+$/.test(s)) ?? [];
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Every key a device writes, its parts' included, is one Circuit takes there (`DEVICE`, from the types), and each filter, NAT and ACL
|
|
318
|
+
* rule's `then` is one its kind takes. A route policy rule is checked whole by `names`, as its kind decides what it may hold.
|
|
319
|
+
*/
|
|
320
|
+
function known(name: string, device: AnyDevice, registeredAt: string) {
|
|
321
|
+
for (const u of unknownKeys(device, DEVICE)) {
|
|
322
|
+
const path = u.path ? `${u.path}.${u.key}` : u.key;
|
|
323
|
+
findings.push(`${foundAt(device, path, registeredAt)}${name}: ${path}: no such field. ${unknownKeyHelp(u)}`);
|
|
324
|
+
}
|
|
325
|
+
const d = device as Json;
|
|
326
|
+
const lists: [keyof typeof ACTIONS, string, unknown][] = [
|
|
327
|
+
...Object.entries<Json>(d.firewall?.filter ?? {}).map(([chain, c]): [keyof typeof ACTIONS, string, unknown] => [
|
|
328
|
+
"filter",
|
|
329
|
+
`firewall.filter.${chain}.rules`,
|
|
330
|
+
c?.rules,
|
|
331
|
+
]),
|
|
332
|
+
["nat", "firewall.nat.source", d.firewall?.nat?.source],
|
|
333
|
+
["nat", "firewall.nat.destination", d.firewall?.nat?.destination],
|
|
334
|
+
...Object.entries<Json>(d.switching?.acls ?? {}).map(([acl, rules]): [keyof typeof ACTIONS, string, unknown] => [
|
|
335
|
+
"acl",
|
|
336
|
+
`switching.acls.${acl}`,
|
|
337
|
+
rules,
|
|
338
|
+
]),
|
|
339
|
+
];
|
|
340
|
+
for (const [kind, at, rules] of lists)
|
|
341
|
+
many(rules).forEach((rule: Json, i) => {
|
|
342
|
+
const takes: readonly string[] = ACTIONS[kind];
|
|
343
|
+
if (!rule || typeof rule !== "object" || takes.includes(rule.then)) return;
|
|
344
|
+
const path = `${at}[${i}].then`;
|
|
345
|
+
const hint = typeof rule.then === "string" ? closest(rule.then, takes) : undefined;
|
|
346
|
+
const what =
|
|
347
|
+
rule.then === undefined ? `a ${kind} rule needs then` : `${JSON.stringify(rule.then)} is no then of a ${kind} rule`;
|
|
348
|
+
findings.push(
|
|
349
|
+
`${foundAt(device, rule.then === undefined ? `${at}[${i}]` : path, registeredAt)}${name}: ${path}: ${what}. ${hint ? `Did you mean ${hint}?` : `It takes: ${takes.join(", ")}.`}`
|
|
350
|
+
);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Every key `circuit()` is given is one it takes (`NETWORK`), and so is every setting of a built-in check. A check of the project's own
|
|
356
|
+
* has a name of its own. Returns whether `communities` has a key it doesn't take: the scheme is then not read further.
|
|
357
|
+
*/
|
|
358
|
+
function knownSettings(network: Json, at: string): boolean {
|
|
359
|
+
const { device: _, checks, ...settings } = network;
|
|
360
|
+
const found = [
|
|
361
|
+
...unknownKeys(settings, NETWORK),
|
|
362
|
+
...Object.entries<Json>(checks ?? {}).flatMap(([check, value]) =>
|
|
363
|
+
Object.hasOwn(CHECKS, check) ? unknownKeys(value, CHECKS[check as keyof typeof CHECKS], `checks.${check}`) : []
|
|
364
|
+
),
|
|
365
|
+
];
|
|
366
|
+
for (const u of found) {
|
|
367
|
+
const path = u.path ? `${u.path}.${u.key}` : u.key;
|
|
368
|
+
findings.push(`${lineOf(at, steps(path))}${path}: no such field. ${unknownKeyHelp(u)}`);
|
|
369
|
+
}
|
|
370
|
+
return found.some((u) => u.path.startsWith("communities"));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Every name a device refers to is one it declares. The editor checks the same; this catches what reaches the device as a plain `string`. */
|
|
374
|
+
function names(name: string, d: Json) {
|
|
375
|
+
const kinds = ["ethernet", "lag", "vlan_interface", "bridge", "loopback", "gre", "vxlan", "wireguard"];
|
|
376
|
+
|
|
377
|
+
// An interface's name is one interface: a rule or a route that names it would otherwise not say which.
|
|
378
|
+
const groupsOf = new Map<string, string[]>();
|
|
379
|
+
for (const kind of kinds) for (const i of keys(d.interfaces?.[kind])) groupsOf.set(i, [...(groupsOf.get(i) ?? []), kind]);
|
|
380
|
+
for (const [i, groups] of groupsOf)
|
|
381
|
+
if (groups.length > 1)
|
|
382
|
+
findings.push(
|
|
383
|
+
`${name}: interface ${i} is in ${groups.map((g) => `interfaces.${g}`).join(" and ")}. Give each interface its own name.`
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
const has: Record<string, Set<string>> = {
|
|
387
|
+
VLAN: new Set(keys(d.switching?.vlans)),
|
|
388
|
+
interface: new Set(kinds.flatMap((k) => keys(d.interfaces?.[k]))),
|
|
389
|
+
LAG: new Set(keys(d.interfaces?.lag)),
|
|
390
|
+
"VLAN interface": new Set(keys(d.interfaces?.vlan_interface)),
|
|
391
|
+
ACL: new Set(keys(d.switching?.acls)),
|
|
392
|
+
policy: new Set(keys(d.routing?.policies)),
|
|
393
|
+
"prefix set": new Set(keys(d.routing?.prefix_sets)),
|
|
394
|
+
"neighbor group": new Set(keys(d.routing?.bgp?.neighbor_groups)),
|
|
395
|
+
table: new Set(["main", ...keys(d.routing?.tables)]),
|
|
396
|
+
VRF: new Set(keys(d.routing?.vrfs)),
|
|
397
|
+
"address set": new Set(keys(d.firewall?.address_sets)),
|
|
398
|
+
"interface set": new Set(keys(d.firewall?.interface_sets)),
|
|
399
|
+
user: new Set(keys(d.system?.users)),
|
|
400
|
+
certificate: new Set(keys(d.system?.certificates)),
|
|
401
|
+
};
|
|
402
|
+
const need = (kind: string, value: unknown, where: string, written = "") => {
|
|
403
|
+
for (const v of many(value)) {
|
|
404
|
+
if (typeof v !== "string" || has[kind]!.has(v)) continue;
|
|
405
|
+
const hint = closest(v, has[kind]!);
|
|
406
|
+
findings.push(`${written}${name}: ${where}: no ${kind} ${v}${hint ? `. Did you mean ${hint}?` : ""}`);
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
need("user", d.connection?.user, "connection.user");
|
|
411
|
+
if (typeof d.management?.allow === "string") need("address set", d.management.allow, "management.allow");
|
|
412
|
+
for (const [k, s] of Object.entries<Json>(d.management ?? {})) {
|
|
413
|
+
if (k === "interfaces") need("interface", s, "management.interfaces");
|
|
414
|
+
else if (s && typeof s === "object") {
|
|
415
|
+
need("interface", s.interfaces, `management.${k}.interfaces`, writtenAt(d.management, k, ["management", k]));
|
|
416
|
+
need("certificate", s.certificate, `management.${k}.certificate`, writtenAt(d.management, k, ["management", k]));
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
need("interface", d.monitoring?.flow_export?.interfaces, "monitoring.flow_export.interfaces");
|
|
420
|
+
for (const [v, vdom] of Object.entries<Json>(d.system?.vdoms ?? {}))
|
|
421
|
+
need("interface", vdom.interfaces, `system.vdoms.${v}.interfaces`);
|
|
422
|
+
|
|
423
|
+
for (const kind of kinds)
|
|
424
|
+
for (const [i, x] of Object.entries<Json>(d.interfaces?.[kind] ?? {})) {
|
|
425
|
+
const at = `interfaces.${kind}.${i}`;
|
|
426
|
+
const written = writtenAt(d.interfaces[kind], i, [kind, i]);
|
|
427
|
+
for (const f of ["access_vlan", "trunk_vlans", "native_vlan", "vlan"]) need("VLAN", x[f], `${at}.${f}`, written);
|
|
428
|
+
need("ACL", x.ingress_acl, `${at}.ingress_acl`, written);
|
|
429
|
+
need("LAG", x.in_lag, `${at}.in_lag`, written);
|
|
430
|
+
need("VRF", x.vrf, `${at}.vrf`, written);
|
|
431
|
+
need("VLAN interface", x.member_interfaces, `${at}.member_interfaces`, written);
|
|
432
|
+
}
|
|
433
|
+
if (typeof d.switching?.lldp === "object") need("interface", d.switching.lldp.interfaces, "switching.lldp.interfaces");
|
|
434
|
+
for (const [a, rules] of Object.entries<Json>(d.switching?.acls ?? {}))
|
|
435
|
+
rules.forEach((r: Json, i: number) =>
|
|
436
|
+
need("VLAN", r.if?.vlan, `switching.acls.${a}[${i}].if.vlan`, writtenAt(d.switching.acls, a, ["acls", a]))
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
for (const group of ["dhcp_server", "dhcp_relay"])
|
|
440
|
+
for (const [s, x] of Object.entries<Json>(d.services?.[group] ?? {}))
|
|
441
|
+
need("interface", x.interface, `services.${group}.${s}.interface`, writtenAt(d.services[group], s, [group, s]));
|
|
442
|
+
|
|
443
|
+
(d.routing?.static_routes ?? []).forEach((r: Json, i: number) => {
|
|
444
|
+
need("interface", r.interface, `routing.static_routes[${i}].interface`);
|
|
445
|
+
need("table", r.routing_table, `routing.static_routes[${i}].routing_table`);
|
|
446
|
+
need("VRF", r.vrf, `routing.static_routes[${i}].vrf`);
|
|
447
|
+
});
|
|
448
|
+
(d.routing?.policy_routing ?? []).forEach((r: Json, i: number) => {
|
|
449
|
+
need("interface", r.match?.in_interface, `routing.policy_routing[${i}].match.in_interface`);
|
|
450
|
+
need("table", r.routing_table, `routing.policy_routing[${i}].routing_table`);
|
|
451
|
+
});
|
|
452
|
+
for (const [kind, map] of [
|
|
453
|
+
["neighbor_groups", d.routing?.bgp?.neighbor_groups],
|
|
454
|
+
["neighbors", d.routing?.bgp?.neighbors],
|
|
455
|
+
] as const)
|
|
456
|
+
for (const [n, x] of Object.entries<Json>(map ?? {})) {
|
|
457
|
+
const written = writtenAt(map, n, [kind, n]);
|
|
458
|
+
need("neighbor group", x.neighbor_group, `routing.bgp.${kind}.${n}.neighbor_group`, written);
|
|
459
|
+
need("policy", x.import_policy, `routing.bgp.${kind}.${n}.import_policy`, written);
|
|
460
|
+
need("policy", x.export_policy, `routing.bgp.${kind}.${n}.export_policy`, written);
|
|
461
|
+
need("table", x.routing_table, `routing.bgp.${kind}.${n}.routing_table`, written);
|
|
462
|
+
}
|
|
463
|
+
for (const [p, rules] of Object.entries<Json>(d.routing?.policies ?? {}))
|
|
464
|
+
rules.forEach((rule: Json, i: number) => {
|
|
465
|
+
const written = writtenAt(d.routing.policies, p, ["policies", p]);
|
|
466
|
+
// A key no policy rule has is said once, by `known`.
|
|
467
|
+
const problem = unknownKeys(rule, POLICY_RULE).length ? undefined : policyRuleProblem(rule);
|
|
468
|
+
if (problem) return findings.push(`${written}${name}: routing.policies.${p}[${i}]: ${problem}`);
|
|
469
|
+
need("policy", rule.call, `routing.policies.${p}[${i}].call`, written);
|
|
470
|
+
need("prefix set", rule.if?.prefix_set, `routing.policies.${p}[${i}].if.prefix_set`, written);
|
|
471
|
+
});
|
|
472
|
+
for (const [a, area] of Object.entries<Json>(d.routing?.ospf?.areas ?? {}))
|
|
473
|
+
need("interface", keys(area.interfaces), `routing.ospf.areas.${a}.interfaces`);
|
|
474
|
+
|
|
475
|
+
for (const [s, members] of Object.entries<Json>(d.firewall?.interface_sets ?? {}))
|
|
476
|
+
need("interface", members, `firewall.interface_sets.${s}`, writtenAt(d.firewall.interface_sets, s, ["interface_sets", s]));
|
|
477
|
+
const lists = [
|
|
478
|
+
...Object.entries<Json>(d.firewall?.filter ?? {}).map(([chain, c]) => [`firewall.filter.${chain}.rules`, c.rules] as const),
|
|
479
|
+
["firewall.nat.source", d.firewall?.nat?.source] as const,
|
|
480
|
+
["firewall.nat.destination", d.firewall?.nat?.destination] as const,
|
|
481
|
+
];
|
|
482
|
+
for (const [at, rules] of lists)
|
|
483
|
+
(rules ?? []).forEach((r: Json, i: number) => {
|
|
484
|
+
const m = r.if ?? {};
|
|
485
|
+
for (const way of ["in", "out"]) {
|
|
486
|
+
need("interface", m[`${way}_interface`], `${at}[${i}].if.${way}_interface`);
|
|
487
|
+
need("interface set", m[`${way}_interface_set`], `${at}[${i}].if.${way}_interface_set`);
|
|
488
|
+
if (m[`${way}_interface`] !== undefined && m[`${way}_interface_set`] !== undefined)
|
|
489
|
+
findings.push(`${name}: ${at}[${i}].if: ${way}_interface and ${way}_interface_set together. Use one.`);
|
|
490
|
+
}
|
|
491
|
+
need("address set", m.src_address_set, `${at}[${i}].if.src_address_set`);
|
|
492
|
+
need("address set", m.dst_address_set, `${at}[${i}].if.dst_address_set`);
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Each port is one the model has, and each link names a device and port that link back. A linked port's description names the far end,
|
|
498
|
+
* `<far device> <far port as printed there>`: Circuit warns when it doesn't, and writes nothing. `devices` is the device's network.
|
|
499
|
+
*/
|
|
500
|
+
function links(name: string, device: AnyDevice, devices: Record<string, AnyDevice>) {
|
|
501
|
+
const ports = portsOf(device.platform, device.model);
|
|
502
|
+
for (const [port, settings] of Object.entries(device.interfaces?.ethernet ?? {})) {
|
|
503
|
+
const at = `${writtenAt(device.interfaces?.ethernet, port, ["ethernet", port])}${name}: interfaces.ethernet.${port}`;
|
|
504
|
+
if (!ports.includes(port)) findings.push(`${at}: ${device.model} has no port ${port}`);
|
|
505
|
+
const link = settings.link;
|
|
506
|
+
if (!link) continue;
|
|
507
|
+
const far = devices[link.device]?.interfaces?.ethernet?.[link.port];
|
|
508
|
+
if (!devices[link.device]) findings.push(`${at}.link: no device ${link.device}`);
|
|
509
|
+
else if (!far) findings.push(`${at}.link: ${link.device} has no port ${link.port}`);
|
|
510
|
+
else if (far.link?.device !== name || far.link.port !== port)
|
|
511
|
+
findings.push(`${at}.link: ${link.device} ${link.port} does not link back to ${name} ${port}`);
|
|
512
|
+
else if (far.link.through !== link.through) findings.push(`${at}.link: the two ends name another \`through\``);
|
|
513
|
+
else {
|
|
514
|
+
const farPort = spell(devices[link.device]!.platform, devices[link.device]!.model, link.port);
|
|
515
|
+
// A name counts as a word of its own: `port6` is not named by `port60`.
|
|
516
|
+
const names = (text: string) => new RegExp(`(^|\\s)${escape(text)}($|[\\s,.;:)])`).test(settings.description ?? "");
|
|
517
|
+
if (!names(link.device) || !names(farPort))
|
|
518
|
+
warnings.push(
|
|
519
|
+
`${at}.description: ${settings.description === undefined ? "none" : JSON.stringify(settings.description)}, which doesn't name the far end of its link as printed there: ${link.device} ${farPort}.`
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* The network as the project's own checks read it: as its devices will run it, still in the language's shape (`ResolvedDevice`). A
|
|
527
|
+
* copy of each device: each neighbor with what it takes from its group; each policy with each `call` replaced by the called policy's
|
|
528
|
+
* rules (its `{ communities }` rules are written out already); `management.allow` written as an address set's name as that set's
|
|
529
|
+
* addresses; a rule's interface set as its interfaces.
|
|
530
|
+
*/
|
|
531
|
+
function resolvedForChecks(network: Json, devices: Record<string, AnyDevice>): ResolvedNetwork {
|
|
532
|
+
const resolved = Object.values(devices).map((device) => {
|
|
533
|
+
const d = JSON.parse(JSON.stringify(device));
|
|
534
|
+
const bgp = d.routing?.bgp;
|
|
535
|
+
for (const [name, n] of Object.entries<Json>(bgp?.neighbors ?? {}))
|
|
536
|
+
bgp.neighbors[name] = { ...(n.neighbor_group ? bgp.neighbor_groups?.[n.neighbor_group] : {}), ...n };
|
|
537
|
+
|
|
538
|
+
const policies = d.routing?.policies ?? {};
|
|
539
|
+
// A policy that calls itself, at any depth, is not followed again.
|
|
540
|
+
const inline = (rules: Json[], seen: string[]): Json[] =>
|
|
541
|
+
rules.flatMap((r) =>
|
|
542
|
+
r.call === undefined ? [r] : seen.includes(r.call) ? [] : inline(policies[r.call] ?? [], [...seen, r.call])
|
|
543
|
+
);
|
|
544
|
+
if (d.routing?.policies)
|
|
545
|
+
d.routing.policies = Object.fromEntries(Object.entries<Json>(policies).map(([p, rules]) => [p, inline(rules, [p])]));
|
|
546
|
+
|
|
547
|
+
if (typeof d.management?.allow === "string") d.management.allow = d.firewall?.address_sets?.[d.management.allow] ?? [];
|
|
548
|
+
const sets = d.firewall?.interface_sets ?? {};
|
|
549
|
+
const rules = [
|
|
550
|
+
...Object.values<Json>(d.firewall?.filter ?? {}).flatMap((c) => c.rules ?? []),
|
|
551
|
+
...(d.firewall?.nat?.source ?? []),
|
|
552
|
+
...(d.firewall?.nat?.destination ?? []),
|
|
553
|
+
];
|
|
554
|
+
for (const rule of rules)
|
|
555
|
+
for (const way of ["in", "out"])
|
|
556
|
+
if (rule.if?.[`${way}_interface_set`] !== undefined) {
|
|
557
|
+
rule.if[`${way}_interface`] = sets[rule.if[`${way}_interface_set`]] ?? [];
|
|
558
|
+
delete rule.if[`${way}_interface_set`];
|
|
559
|
+
}
|
|
560
|
+
if (d.firewall) delete d.firewall.interface_sets;
|
|
561
|
+
return d;
|
|
562
|
+
});
|
|
563
|
+
return { asn: network.asn, prefixes: network.prefixes ?? {}, devices: resolved };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// The project's own checks: every name in `checks` that holds a function. Any other name is one of Circuit's.
|
|
567
|
+
const BUILT_IN = Object.keys(CHECKS);
|
|
568
|
+
|
|
569
|
+
// Each network on its own. With several, each finding starts with the network's name.
|
|
570
|
+
for (const { name: networkName, registered, network, devices } of networks) {
|
|
571
|
+
const start = findings.length;
|
|
572
|
+
places(network, devices, (name) => registered.devices.find((d) => d.device.name === name)!.at + ": ");
|
|
573
|
+
const schemeKnown = !knownSettings(network, registered.at);
|
|
574
|
+
untrusted(network, devices);
|
|
575
|
+
sameVlanIds(network, devices);
|
|
576
|
+
if (schemeKnown) for (const finding of schemeProblems(network)) findings.push(`${registered.at}: ${finding}`);
|
|
577
|
+
for (const [name, device] of Object.entries(devices)) {
|
|
578
|
+
known(name, device, registered.devices.find((d) => d.device === device)!.at);
|
|
579
|
+
names(name, device);
|
|
580
|
+
links(name, device, devices);
|
|
581
|
+
if (device.routing?.bgp && device.routing.bgp.asn === undefined && network.asn === undefined)
|
|
582
|
+
findings.push(
|
|
583
|
+
`${registered.devices.find((d) => d.device === device)!.at}: ${name}: routing.bgp has no asn, and circuit.ts writes none. Write asn in circuit.ts, or in routing.bgp.`
|
|
584
|
+
);
|
|
585
|
+
// Each `{ communities }` rule written out for the neighbors that use its policy, before our own checks read the policies.
|
|
586
|
+
findings.push(
|
|
587
|
+
...writeCommunities(network, device, (map, kind, key) => `${map ? writtenAt(map, key, [kind, key]) : ""}${name}: `)
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
for (const [check, value] of Object.entries<Json>(network.checks ?? {}))
|
|
591
|
+
if (typeof value !== "function" && !BUILT_IN.includes(check)) {
|
|
592
|
+
const hint = closest(check, BUILT_IN);
|
|
593
|
+
findings.push(
|
|
594
|
+
`checks.${check}: Circuit has no check of this name${hint ? `. Did you mean ${hint}?` : ""} A check of your own takes a function.`
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
const own = Object.entries<Json>(network.checks ?? {}).filter(([, check]) => typeof check === "function");
|
|
598
|
+
const resolved = own.length ? resolvedForChecks(network, devices) : undefined;
|
|
599
|
+
for (const [check, run] of own) {
|
|
600
|
+
let found: Finding[];
|
|
601
|
+
try {
|
|
602
|
+
found = run(resolved);
|
|
603
|
+
} catch (error) {
|
|
604
|
+
findings.push(`checks.${check}: the check stopped: ${error instanceof Error ? error.message : String(error)}`);
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
for (const { message, device, path } of found) {
|
|
608
|
+
const registeredAt = registered.devices.find((d) => d.device.name === device)?.at;
|
|
609
|
+
const where = device && devices[device] && registeredAt ? foundAt(devices[device], path, registeredAt) : "";
|
|
610
|
+
findings.push(`${where}${check}: ${device ? `${device}: ` : ""}${path ? `${path}: ` : ""}${message}`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
if (networks.length > 1) for (let i = start; i < findings.length; i++) findings[i] = `${networkName}: ${findings[i]}`;
|
|
614
|
+
}
|
|
615
|
+
if (findings.length) stop(findings);
|
|
616
|
+
|
|
617
|
+
return {
|
|
618
|
+
warnings: warnings.map(short),
|
|
619
|
+
networks: networks.map(({ name, registered, network, devices }) => ({
|
|
620
|
+
name,
|
|
621
|
+
...(network.asn !== undefined ? { asn: network.asn } : {}),
|
|
622
|
+
communities: network.communities ? catalogue(network) : [],
|
|
623
|
+
devices: Object.fromEntries(Object.values(devices).map((d) => [d.name, primitive(d, network)])),
|
|
624
|
+
files: Object.fromEntries(registered.devices.map(({ device, file }) => [device.name, short(file)])),
|
|
625
|
+
settings: network,
|
|
626
|
+
})),
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ---------- into the model's primitives ----------
|
|
631
|
+
|
|
632
|
+
/** One rule per combination of the lists in its `if`, in the order of `fields`: the first outermost. */
|
|
633
|
+
function combinations(rule: Json, fields: [string, unknown[]][], drop: string[]): Json[] {
|
|
634
|
+
const rest = Object.fromEntries(Object.entries<Json>(rule.if).filter(([k]) => !drop.includes(k)));
|
|
635
|
+
let matches: Json[] = [rest];
|
|
636
|
+
for (const [key, values] of fields) if (values.length) matches = matches.flatMap((m) => values.map((v) => ({ ...m, [key]: v })));
|
|
637
|
+
return matches.map((match) => ({ ...rule, if: match }));
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** An ACL rule: the VLAN outermost, then `src`, then `dst`. */
|
|
641
|
+
const acl = (rule: Json): Json[] =>
|
|
642
|
+
rule.if
|
|
643
|
+
? combinations(
|
|
644
|
+
rule,
|
|
645
|
+
[
|
|
646
|
+
["vlan", many(rule.if.vlan)],
|
|
647
|
+
["src", many(rule.if.src)],
|
|
648
|
+
["dst", many(rule.if.dst)],
|
|
649
|
+
],
|
|
650
|
+
["vlan", "src", "dst"]
|
|
651
|
+
)
|
|
652
|
+
: [rule];
|
|
653
|
+
|
|
654
|
+
/** A filter or NAT rule: the interfaces outermost (an interface set is its interfaces), then `src`, then `dst`. */
|
|
655
|
+
const filter = (rule: Json, sets: Record<string, string[]>): Json[] => {
|
|
656
|
+
const m = rule.if;
|
|
657
|
+
if (!m) return [rule];
|
|
658
|
+
const way = (w: string) => (m[`${w}_interface_set`] !== undefined ? sets[m[`${w}_interface_set`]]! : many(m[`${w}_interface`]));
|
|
659
|
+
if ([m.in_interface_set, m.out_interface_set].some((s) => s !== undefined && !sets[s]!.length)) return [];
|
|
660
|
+
const fields: [string, unknown[]][] = [
|
|
661
|
+
["in_interface", way("in")],
|
|
662
|
+
["out_interface", way("out")],
|
|
663
|
+
["src", many(m.src)],
|
|
664
|
+
["dst", many(m.dst)],
|
|
665
|
+
];
|
|
666
|
+
return combinations(rule, fields, ["in_interface", "out_interface", "in_interface_set", "out_interface_set", "src", "dst"]);
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
/** An address written with its length, as the address alone: `192.0.2.1/32` is `192.0.2.1`. Anything else as it is. */
|
|
670
|
+
const alone = (v: unknown) => (typeof v === "string" ? v.replace(/\/\d+$/, "") : v);
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* What the model keeps otherwise than a device writes it: an address set named in `management.allow` as its addresses; no VLAN
|
|
674
|
+
* description where a VLAN is only its number (`NUMBERED_VLANS`), so a VLAN written whole once serves every platform; the address alone in each field that names one host (`HostAddress`), which a device may write with its length; and the network's AS on BGP,
|
|
675
|
+
* which the model needs there. No default is written: what a device leaves out stays out.
|
|
676
|
+
*/
|
|
677
|
+
function written(d: Json, network: Json) {
|
|
678
|
+
// `management.allow` written as an address set's name is that set's addresses.
|
|
679
|
+
if (typeof d.management?.allow === "string") d.management.allow = d.firewall.address_sets[d.management.allow];
|
|
680
|
+
if (NUMBERED_VLANS.includes(d.platform)) for (const vlan of Object.values<Json>(d.switching?.vlans ?? {})) delete vlan.description;
|
|
681
|
+
|
|
682
|
+
if (d.system?.ntp?.servers) d.system.ntp.servers = d.system.ntp.servers.map(alone);
|
|
683
|
+
for (const kind of ["gre", "vxlan"])
|
|
684
|
+
for (const tunnel of Object.values<Json>(d.interfaces?.[kind] ?? {})) tunnel.local_address = alone(tunnel.local_address);
|
|
685
|
+
for (const wg of Object.values<Json>(d.interfaces?.wireguard ?? {})) if (wg.endpoint) wg.endpoint = alone(wg.endpoint);
|
|
686
|
+
for (const route of [
|
|
687
|
+
...(d.routing?.static_routes ?? []),
|
|
688
|
+
...Object.values<Json>(d.system?.vdoms ?? {}).flatMap((v) => v.static_routes ?? []),
|
|
689
|
+
])
|
|
690
|
+
if (route.next_hop) route.next_hop = alone(route.next_hop);
|
|
691
|
+
for (const rules of Object.values<Json>(d.routing?.policies ?? {}))
|
|
692
|
+
for (const rule of rules) if (rule.set?.preferred_source) rule.set.preferred_source = alone(rule.set.preferred_source);
|
|
693
|
+
for (const way of ["source", "destination"])
|
|
694
|
+
for (const rule of d.firewall?.nat?.[way] ?? []) if (rule.to_address) rule.to_address = alone(rule.to_address);
|
|
695
|
+
|
|
696
|
+
const bgp = d.routing?.bgp;
|
|
697
|
+
if (!bgp) return;
|
|
698
|
+
for (const x of [...Object.values<Json>(bgp.neighbor_groups ?? {}), ...Object.values<Json>(bgp.neighbors ?? {})])
|
|
699
|
+
if (x.local_address) x.local_address = alone(x.local_address);
|
|
700
|
+
// The model needs an AS on BGP: the network's own, not a guess.
|
|
701
|
+
if (bgp.asn === undefined) d.routing.bgp = { asn: network.asn, ...bgp };
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* A device in the model the platforms read: a copy, with `written`'s changes, a list in a match as one rule per value, an interface set
|
|
706
|
+
* as its interfaces, and a prefix set written as a map of named prefixes as its prefixes.
|
|
707
|
+
*/
|
|
708
|
+
function primitive(device: AnyDevice, network: Json): Device {
|
|
709
|
+
const d = JSON.parse(JSON.stringify(device));
|
|
710
|
+
written(d, network);
|
|
711
|
+
for (const [name, rules] of Object.entries<Json>(d.switching?.acls ?? {})) d.switching.acls[name] = rules.flatMap(acl);
|
|
712
|
+
for (const set of Object.values<Json>(d.routing?.prefix_sets ?? {}))
|
|
713
|
+
if (set.prefixes && !Array.isArray(set.prefixes)) set.prefixes = Object.values(set.prefixes);
|
|
714
|
+
|
|
715
|
+
if (d.firewall) {
|
|
716
|
+
const sets = d.firewall.interface_sets ?? {};
|
|
717
|
+
delete d.firewall.interface_sets;
|
|
718
|
+
for (const chain of Object.values<Json>(d.firewall.filter ?? {}))
|
|
719
|
+
if (chain.rules) chain.rules = chain.rules.flatMap((r: Json) => filter(r, sets));
|
|
720
|
+
for (const way of ["source", "destination"])
|
|
721
|
+
if (d.firewall.nat?.[way]) d.firewall.nat[way] = d.firewall.nat[way].flatMap((r: Json) => filter(r, sets));
|
|
722
|
+
if (!Object.keys(d.firewall).length) delete d.firewall;
|
|
723
|
+
}
|
|
724
|
+
return d;
|
|
725
|
+
}
|