@takodotid/circuit 0.34.3 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -14
- package/dist/chunks/index-20kcjxjg.js +174 -0
- package/dist/chunks/index-2r4bbhac.js +977 -0
- package/dist/chunks/index-n7w3tg5y.js +149 -0
- package/dist/chunks/new-w3zys5y4.js +172 -0
- package/dist/chunks/server-gzsf5me5.js +259 -0
- package/dist/cli/index.js +11345 -9024
- package/dist/helpers/index.js +594 -0
- package/dist/index.js +9 -28
- package/dist/types/core/{addresses.d.ts → address-space.d.ts} +23 -7
- package/dist/types/core/address.d.ts +44 -0
- package/dist/types/core/cabling.d.ts +41 -0
- package/dist/types/core/cross-checks.d.ts +28 -0
- package/dist/types/core/define.d.ts +25 -3
- package/dist/types/core/project-files.d.ts +12 -0
- package/dist/types/core/schema-docs.d.ts +13 -4
- package/dist/types/core/secrets.d.ts +62 -21
- package/dist/types/core/{graph.d.ts → topology.d.ts} +32 -12
- package/dist/types/core/utils.d.ts +8 -0
- package/dist/types/core/validator.d.ts +19 -0
- package/dist/types/helpers/bgp.d.ts +97 -0
- package/dist/types/helpers/checks.d.ts +32 -0
- package/dist/types/helpers/exchange.d.ts +49 -0
- package/dist/types/helpers/filters.d.ts +51 -0
- package/dist/types/helpers/index.d.ts +10 -0
- package/dist/types/helpers/route-server.d.ts +52 -0
- package/dist/types/index.d.ts +5 -3
- package/dist/types/integrations/librenms.d.ts +60 -0
- package/dist/types/integrations/netbox.d.ts +89 -0
- package/dist/types/integrations/peeringdb.d.ts +24 -0
- package/dist/types/integrations/prometheus.d.ts +24 -0
- package/dist/types/integrations/registries.d.ts +8 -0
- package/dist/types/platforms/bgp-wait.d.ts +18 -0
- package/dist/types/platforms/catalog.d.ts +109 -0
- package/dist/types/platforms/derive.d.ts +17 -0
- package/dist/types/platforms/fortios/firewall.d.ts +45 -0
- package/dist/types/platforms/fortios/index.d.ts +134 -0
- package/dist/types/platforms/fortios/names.d.ts +52 -0
- package/dist/types/platforms/fortios/plan.d.ts +104 -0
- package/dist/types/platforms/fortios/render.d.ts +75 -0
- package/dist/types/platforms/fortios/routing.d.ts +56 -0
- package/dist/types/platforms/fortios/tree.d.ts +37 -0
- package/dist/types/platforms/index.d.ts +13 -0
- package/dist/types/platforms/junos/bgp.d.ts +44 -0
- package/dist/types/platforms/junos/index.d.ts +114 -0
- package/dist/types/platforms/junos/render.d.ts +154 -0
- package/dist/types/platforms/line-diff.d.ts +67 -0
- package/dist/types/platforms/platform.d.ts +120 -0
- package/dist/types/platforms/raisecom-ros/index.d.ts +71 -0
- package/dist/types/platforms/raisecom-ros/render.d.ts +41 -0
- package/dist/types/platforms/routeros/export.d.ts +59 -0
- package/dist/types/platforms/routeros/index.d.ts +96 -0
- package/dist/types/{adapters/devices → platforms}/routeros/models.d.ts +4 -0
- package/dist/types/platforms/routeros/plan.d.ts +54 -0
- package/dist/types/platforms/routeros/render.d.ts +240 -0
- package/dist/types/platforms/vrp/index.d.ts +83 -0
- package/dist/types/platforms/vrp/render.d.ts +96 -0
- package/dist/types/schema/common.d.ts +12 -6
- package/dist/types/schema/index.d.ts +1 -0
- package/dist/types/schema/interfaces.d.ts +14 -6
- package/dist/types/schema/policy.d.ts +18 -10
- package/dist/types/schema/routing.d.ts +14 -1
- package/dist/types/schema/system.d.ts +16 -6
- package/dist/types/schema/time-zones.d.ts +2 -0
- package/dist/types/transport/console.d.ts +79 -23
- package/dist/types/transport/path-check.d.ts +32 -0
- package/dist/types/transport/ssh.d.ts +17 -15
- package/package.json +17 -11
- package/src/cli/graph-format.ts +91 -0
- package/src/cli/index.ts +1074 -484
- package/src/cli/new.ts +208 -175
- package/src/cli/rename.ts +97 -91
- package/src/core/address-space.ts +320 -0
- package/src/core/address.ts +202 -0
- package/src/core/cabling.ts +154 -0
- package/src/core/cross-checks.ts +364 -0
- package/src/core/define.ts +26 -3
- package/src/core/project-files.ts +35 -0
- package/src/core/schema-docs.ts +138 -130
- package/src/core/secrets.ts +170 -76
- package/src/core/topology.ts +656 -0
- package/src/core/utils.ts +37 -0
- package/src/core/validator.ts +407 -0
- package/src/helpers/bgp.ts +301 -0
- package/src/helpers/checks.ts +178 -0
- package/src/helpers/exchange.ts +117 -0
- package/src/helpers/filters.ts +107 -0
- package/src/helpers/index.ts +12 -0
- package/src/helpers/route-server.ts +212 -0
- package/src/index.ts +6 -3
- package/src/integrations/librenms.ts +291 -0
- package/src/integrations/netbox.ts +620 -0
- package/src/integrations/peeringdb.ts +173 -0
- package/src/integrations/prometheus.ts +90 -0
- package/src/integrations/registries.ts +25 -0
- package/src/platforms/bgp-wait.ts +41 -0
- package/src/platforms/catalog.ts +69 -0
- package/src/platforms/derive.ts +117 -0
- package/src/{adapters/devices → platforms}/fortios/TRAPS.md +6 -0
- package/src/platforms/fortios/firewall.ts +377 -0
- package/src/platforms/fortios/index.ts +1060 -0
- package/src/platforms/fortios/names.ts +130 -0
- package/src/platforms/fortios/plan.ts +929 -0
- package/src/platforms/fortios/render.ts +779 -0
- package/src/platforms/fortios/routing.ts +476 -0
- package/src/platforms/fortios/tree.ts +129 -0
- package/src/platforms/index.ts +36 -0
- package/src/{adapters/devices → platforms}/junos/TRAPS.md +8 -0
- package/src/platforms/junos/bgp.ts +293 -0
- package/src/platforms/junos/index.ts +1006 -0
- package/src/platforms/junos/render.ts +967 -0
- package/src/platforms/line-diff.ts +244 -0
- package/src/platforms/platform.ts +122 -0
- package/src/platforms/raisecom-ros/TRAPS.md +40 -0
- package/src/platforms/raisecom-ros/index.ts +731 -0
- package/src/platforms/raisecom-ros/render.ts +328 -0
- package/src/{adapters/devices → platforms}/routeros/TRAPS.md +16 -2
- package/src/platforms/routeros/export.ts +342 -0
- package/src/platforms/routeros/index.ts +636 -0
- package/src/{adapters/devices → platforms}/routeros/models.ts +2 -0
- package/src/platforms/routeros/plan.ts +444 -0
- package/src/platforms/routeros/render.ts +1483 -0
- package/src/platforms/vrp/TRAPS.md +45 -0
- package/src/platforms/vrp/index.ts +652 -0
- package/src/platforms/vrp/render.ts +615 -0
- package/src/schema/common.ts +15 -6
- package/src/schema/index.ts +1 -0
- package/src/schema/interfaces.ts +15 -5
- package/src/schema/policy.ts +22 -10
- package/src/schema/routing.ts +16 -1
- package/src/schema/system.ts +16 -6
- package/src/schema/time-zones.ts +482 -0
- package/src/studio/data.ts +172 -168
- package/src/studio/git.ts +95 -90
- package/src/studio/public/app.js +161 -150
- package/src/studio/public/compare.js +219 -212
- package/src/studio/public/diff.js +77 -75
- package/src/studio/public/dom.js +118 -108
- package/src/studio/public/evaluate.js +584 -545
- package/src/studio/public/export.js +120 -118
- package/src/studio/public/graph.js +1343 -1266
- package/src/studio/public/net.js +115 -109
- package/src/studio/public/tables.js +1135 -1083
- package/src/studio/public/tools.js +561 -529
- package/src/studio/server.ts +202 -196
- package/src/transport/console.ts +363 -271
- package/src/transport/path-check.ts +91 -0
- package/src/transport/ssh.ts +98 -94
- package/templates/colocation/checks.ts +11 -6
- package/templates/colocation/edge-01.ts +9 -9
- package/templates/colocation/routing.ts +2 -2
- package/templates/colocation/tenancy.ts +3 -3
- package/templates/colocation/tor-01.ts +3 -3
- package/templates/edge-router/checks.ts +4 -4
- package/templates/edge-router/edge-01.ts +5 -5
- package/templates/edge-router/routing.ts +2 -2
- package/templates/project/AGENTS.md +5 -1
- package/templates/single-site/checks.ts +2 -2
- package/dist/chunks/index-47debt16.js +0 -956
- package/dist/chunks/index-v8zgr7wm.js +0 -128
- package/dist/chunks/new-ny89s7y7.js +0 -166
- package/dist/chunks/server-tjbb3rqc.js +0 -251
- package/dist/presets/index.js +0 -394
- package/dist/types/adapters/devices/catalog.d.ts +0 -86
- package/dist/types/adapters/devices/derive.d.ts +0 -7
- package/dist/types/adapters/devices/fortios/firewall.d.ts +0 -23
- package/dist/types/adapters/devices/fortios/index.d.ts +0 -58
- package/dist/types/adapters/devices/fortios/names.d.ts +0 -48
- package/dist/types/adapters/devices/fortios/plan.d.ts +0 -28
- package/dist/types/adapters/devices/fortios/render.d.ts +0 -38
- package/dist/types/adapters/devices/fortios/routing.d.ts +0 -32
- package/dist/types/adapters/devices/fortios/tree.d.ts +0 -33
- package/dist/types/adapters/devices/index.d.ts +0 -3
- package/dist/types/adapters/devices/junos/bgp.d.ts +0 -11
- package/dist/types/adapters/devices/junos/index.d.ts +0 -42
- package/dist/types/adapters/devices/junos/render.d.ts +0 -52
- package/dist/types/adapters/devices/lines.d.ts +0 -56
- package/dist/types/adapters/devices/raisecom-ros/index.d.ts +0 -28
- package/dist/types/adapters/devices/routeros/export.d.ts +0 -39
- package/dist/types/adapters/devices/routeros/index.d.ts +0 -30
- package/dist/types/adapters/devices/routeros/plan.d.ts +0 -4
- package/dist/types/adapters/devices/routeros/render.d.ts +0 -100
- package/dist/types/adapters/devices/types.d.ts +0 -40
- package/dist/types/adapters/devices/vrp/index.d.ts +0 -27
- package/dist/types/adapters/registries/index.d.ts +0 -3
- package/dist/types/adapters/registries/librenms.d.ts +0 -15
- package/dist/types/adapters/registries/peeringdb.d.ts +0 -16
- package/dist/types/adapters/registries/prometheus.d.ts +0 -16
- package/dist/types/core/across.d.ts +0 -4
- package/dist/types/core/addr.d.ts +0 -34
- package/dist/types/core/files.d.ts +0 -6
- package/dist/types/core/merge.d.ts +0 -4
- package/dist/types/core/time.d.ts +0 -2
- package/dist/types/core/validate.d.ts +0 -2
- package/dist/types/presets/index.d.ts +0 -183
- package/src/adapters/devices/catalog.ts +0 -41
- package/src/adapters/devices/derive.ts +0 -59
- package/src/adapters/devices/fortios/firewall.ts +0 -362
- package/src/adapters/devices/fortios/index.ts +0 -923
- package/src/adapters/devices/fortios/names.ts +0 -115
- package/src/adapters/devices/fortios/plan.ts +0 -888
- package/src/adapters/devices/fortios/render.ts +0 -768
- package/src/adapters/devices/fortios/routing.ts +0 -444
- package/src/adapters/devices/fortios/tree.ts +0 -121
- package/src/adapters/devices/index.ts +0 -9
- package/src/adapters/devices/junos/bgp.ts +0 -269
- package/src/adapters/devices/junos/index.ts +0 -862
- package/src/adapters/devices/junos/render.ts +0 -872
- package/src/adapters/devices/lines.ts +0 -212
- package/src/adapters/devices/raisecom-ros/TRAPS.md +0 -26
- package/src/adapters/devices/raisecom-ros/index.ts +0 -700
- package/src/adapters/devices/routeros/export.ts +0 -321
- package/src/adapters/devices/routeros/index.ts +0 -346
- package/src/adapters/devices/routeros/plan.ts +0 -398
- package/src/adapters/devices/routeros/render.ts +0 -1490
- package/src/adapters/devices/types.ts +0 -38
- package/src/adapters/devices/vrp/TRAPS.md +0 -26
- package/src/adapters/devices/vrp/index.ts +0 -865
- package/src/adapters/registries/index.ts +0 -19
- package/src/adapters/registries/librenms.ts +0 -92
- package/src/adapters/registries/peeringdb.ts +0 -169
- package/src/adapters/registries/prometheus.ts +0 -71
- package/src/cli/graph.ts +0 -89
- package/src/core/across.ts +0 -350
- package/src/core/addr.ts +0 -174
- package/src/core/addresses.ts +0 -316
- package/src/core/files.ts +0 -32
- package/src/core/graph.ts +0 -640
- package/src/core/merge.ts +0 -19
- package/src/core/time.ts +0 -15
- package/src/core/validate.ts +0 -348
- package/src/presets/index.ts +0 -579
- /package/dist/types/{adapters/devices → platforms}/fortios/models.d.ts +0 -0
- /package/dist/types/{adapters/devices → platforms}/junos/models.d.ts +0 -0
- /package/dist/types/{adapters/devices → platforms}/raisecom-ros/models.d.ts +0 -0
- /package/dist/types/{adapters/devices → platforms}/vrp/models.d.ts +0 -0
- /package/src/{adapters/devices → platforms}/fortios/models.ts +0 -0
- /package/src/{adapters/devices → platforms}/junos/models.ts +0 -0
- /package/src/{adapters/devices → platforms}/raisecom-ros/models.ts +0 -0
- /package/src/{adapters/devices → platforms}/vrp/models.ts +0 -0
package/src/cli/index.ts
CHANGED
|
@@ -1,636 +1,1226 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// The command line. `new` starts a project; every other command loads the network from `circuit.config.ts` in the working directory, or the file `--config` names.
|
|
3
3
|
|
|
4
|
+
import { Command, InvalidArgumentError, Option } from "@commander-js/extra-typings";
|
|
4
5
|
import { createPrivateKey, createPublicKey } from "node:crypto";
|
|
5
6
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
7
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
7
|
-
import {
|
|
8
|
-
import type { ApplyOptions,
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
8
|
+
import { Platforms } from "../platforms";
|
|
9
|
+
import type { ApplyOptions, BgpLogLine, BgpSession, Platform, Plan, ReadSession } from "../platforms/platform";
|
|
10
|
+
import { BgpWait } from "../platforms/bgp-wait";
|
|
11
|
+
import { Registries } from "../integrations/registries";
|
|
12
|
+
import { LibreNms } from "../integrations/librenms";
|
|
13
|
+
import { NetBox, type NetBoxPlan } from "../integrations/netbox";
|
|
14
|
+
import { PeeringDb } from "../integrations/peeringdb";
|
|
15
|
+
import { Prometheus } from "../integrations/prometheus";
|
|
16
|
+
import { Address } from "../core/address";
|
|
17
|
+
import { Cabling, type Neighbor } from "../core/cabling";
|
|
18
|
+
import { AddressSpace, type Segment } from "../core/address-space";
|
|
19
|
+
import type { Finding, Network } from "../core/define";
|
|
20
|
+
import { Topology, type Annotated, type Drift, type Layer } from "../core/topology";
|
|
21
|
+
import { Secrets } from "../core/secrets";
|
|
22
|
+
import { Validator } from "../core/validator";
|
|
19
23
|
import type { Device } from "../schema";
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
new [directory] start a project from a pattern, asking what it needs
|
|
28
|
-
validate check the config, then every rule in checks
|
|
29
|
-
build <device> print the configuration the device should run, without contacting it
|
|
30
|
-
diff [device...] what apply would send, planned against the last snapshot, offline
|
|
31
|
-
snapshot [device...] read what each device runs into the state directory
|
|
32
|
-
apply <device> read the device, plan against what it runs, and send with --confirm
|
|
33
|
-
--secrets also send every secret, to rotate one
|
|
34
|
-
--rollback=N minutes before a device that can restore itself does, default 10
|
|
35
|
-
--console through the device's serial console on its jump host, instead of SSH; also for snapshot
|
|
36
|
-
refresh <device> fetch prefix sets that come from a registry onto the device, with --confirm
|
|
37
|
-
wireguard <device> <interface> <peer>
|
|
38
|
-
a client config for one peer, its private key left for the peer to fill
|
|
39
|
-
communities the network's BGP communities, one per line, for a looking glass or bgp.tools
|
|
40
|
-
peeringdb bring PeeringDB's exchange records in line with the config, with --confirm
|
|
41
|
-
librenms add every device LibreNMS does not monitor yet, with --confirm
|
|
42
|
-
prometheus every device with SNMP, as Prometheus targets for snmp_exporter
|
|
43
|
-
--auths snmp_exporter's auths for them instead, with secrets as \${NAME}
|
|
44
|
-
secrets every secret the config uses, and whether each can be read
|
|
45
|
-
rename <device> <new-name>
|
|
46
|
-
give a device a new name in every file that names it, with --confirm
|
|
47
|
-
--file also rename the file that declares it, to <new-name>.ts
|
|
48
|
-
addresses [prefix] every address the config uses, by prefix, and what is still free
|
|
49
|
-
--length=N free prefixes of this length, instead of the fewest that cover the free space
|
|
50
|
-
--format=json as JSON
|
|
51
|
-
graph [device...] the network as a graph, from the config alone, without contacting a device
|
|
52
|
-
--layer=l1 l1, l2, l3, bgp, ospf or tunnels, several with commas, or all
|
|
53
|
-
--format=mermaid mermaid, dot or json
|
|
54
|
-
studio Circuit Studio: the network in the browser, drawn again whenever the config changes
|
|
55
|
-
--port=4600 the port it listens on
|
|
56
|
-
--no-open do not open the browser
|
|
57
|
-
--host=0.0.0.0 listen on a server's address instead, with --allow-host
|
|
58
|
-
--allow-host=studio.example.com
|
|
59
|
-
the names it is reached by, several with commas
|
|
60
|
-
--json print what Studio shows, as JSON, instead
|
|
61
|
-
|
|
62
|
-
--config <path> the network's config file, circuit.config.ts in the working directory by default`;
|
|
63
|
-
|
|
64
|
-
const DEFAULT_CONFIG = "circuit.config.ts";
|
|
65
|
-
|
|
66
|
-
const [command, ...args] = process.argv.slice(2);
|
|
67
|
-
|
|
68
|
-
if (command === "new") {
|
|
69
|
-
const { create } = await import("./new");
|
|
70
|
-
await create(args);
|
|
71
|
-
process.exit(0);
|
|
72
|
-
}
|
|
24
|
+
import { GraphFormat } from "./graph-format";
|
|
25
|
+
import { Rename, type RenamePlan } from "./rename";
|
|
26
|
+
import { ProjectFiles } from "../core/project-files";
|
|
27
|
+
import { ConsoleScript, type ConsoleSession, SerialConsole } from "../transport/console";
|
|
28
|
+
import { PathCheck } from "../transport/path-check";
|
|
29
|
+
import { StudioModel } from "../studio/data";
|
|
30
|
+
import type { ScaffoldOptions } from "./new";
|
|
73
31
|
|
|
74
|
-
|
|
75
|
-
console.log(HELP);
|
|
76
|
-
process.exit(0);
|
|
77
|
-
}
|
|
32
|
+
const GENERATED_README = `# Written by Circuit
|
|
78
33
|
|
|
79
|
-
|
|
80
|
-
const configAt = args.indexOf("--config");
|
|
81
|
-
const configArgument = configAt >= 0 ? args[configAt + 1] : args.find((arg) => arg.startsWith("--config="))?.slice("--config=".length);
|
|
82
|
-
const rest = args.filter((arg, index) => index !== configAt && (configAt < 0 || index !== configAt + 1) && !arg.startsWith("--config="));
|
|
34
|
+
Do not edit anything here: an edit changes nothing on a device, and Circuit overwrites it. Change the config instead.
|
|
83
35
|
|
|
84
|
-
|
|
36
|
+
\`state/\` holds what each device ran when Circuit last read it, with secrets removed. \`circuit snapshot\` and \`circuit apply\` write it, and \`circuit diff\` plans against it. Commit it, so the history of this directory is the history of the network.
|
|
85
37
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
configArgument ? `no config at ${configPath}` : `no ${DEFAULT_CONFIG} in ${process.cwd()}; name another with --config <path>`
|
|
89
|
-
);
|
|
90
|
-
process.exit(1);
|
|
91
|
-
}
|
|
38
|
+
A \`.lldp.json\` file beside a device's config is what the device saw over LLDP at the last \`circuit snapshot\`: which device and port is at the far end of each cable. \`circuit validate\` compares it with each port's \`link\`; \`circuit diff\` ignores it.
|
|
39
|
+
`;
|
|
92
40
|
|
|
93
|
-
/**
|
|
94
|
-
|
|
95
|
-
|
|
41
|
+
/** One run of the command line: the network it loaded, and how it reaches devices. */
|
|
42
|
+
class Cli {
|
|
43
|
+
static readonly DEFAULT_CONFIG = "circuit.config.ts";
|
|
44
|
+
|
|
45
|
+
/** Ctrl-], the key that leaves `circuit console`, as telnet uses it. */
|
|
46
|
+
private static readonly LEAVE_KEY = 0x1d;
|
|
47
|
+
|
|
48
|
+
/** `--config`, which every command but `new` takes. */
|
|
49
|
+
private static readonly CONFIG_FLAGS = "--config <path>";
|
|
50
|
+
private static readonly CONFIG_HELP = `the network's config file, ${Cli.DEFAULT_CONFIG} in the working directory by default`;
|
|
51
|
+
|
|
52
|
+
/** The directory of the config file. `.circuit/`, `.env` and secret files are found from here. */
|
|
53
|
+
private readonly root: string;
|
|
54
|
+
/** What Circuit writes, beside the config. */
|
|
55
|
+
private readonly generated: string;
|
|
56
|
+
private readonly stateDirectory: string;
|
|
57
|
+
|
|
58
|
+
private constructor(
|
|
59
|
+
private readonly configPath: string,
|
|
60
|
+
private readonly network: Network,
|
|
61
|
+
/** How a device is reached and what a plan sends: `apply`'s flags, the defaults for every other command. */
|
|
62
|
+
private readonly options: ApplyOptions
|
|
63
|
+
) {
|
|
64
|
+
this.root = dirname(configPath);
|
|
65
|
+
this.generated = join(this.root, ".circuit");
|
|
66
|
+
this.stateDirectory = join(this.generated, "state");
|
|
67
|
+
}
|
|
96
68
|
|
|
97
|
-
/**
|
|
98
|
-
async
|
|
99
|
-
|
|
69
|
+
/** Read the arguments and run the command they name. */
|
|
70
|
+
static async main(argv: string[]): Promise<void> {
|
|
71
|
+
const program = Cli.program();
|
|
100
72
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
73
|
+
// With no command, the help, as `circuit help` prints it.
|
|
74
|
+
if (!argv.length) {
|
|
75
|
+
program.outputHelp();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
104
78
|
|
|
105
|
-
|
|
79
|
+
await program.parseAsync(argv, { from: "user" });
|
|
80
|
+
}
|
|
106
81
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
const options: ApplyOptions = {
|
|
111
|
-
secrets: flags.includes("--secrets"),
|
|
112
|
-
rollback: Number(flags.find((flag) => flag.startsWith("--rollback="))?.split("=")[1] ?? 10),
|
|
113
|
-
...(flags.includes("--console") ? { console: true } : {}),
|
|
114
|
-
};
|
|
82
|
+
/** Load the network from the config file `--config` names, or the one in the working directory. */
|
|
83
|
+
private static async open(configArgument: string | undefined, flags: Partial<ApplyOptions> = {}): Promise<Cli> {
|
|
84
|
+
const configPath = resolve(process.cwd(), configArgument ?? Cli.DEFAULT_CONFIG);
|
|
115
85
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
86
|
+
if (!existsSync(configPath)) {
|
|
87
|
+
Cli.fail(
|
|
88
|
+
configArgument
|
|
89
|
+
? `no config at ${configPath}`
|
|
90
|
+
: `no ${Cli.DEFAULT_CONFIG} in ${process.cwd()}; name another with --config <path>`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
120
93
|
|
|
121
|
-
|
|
122
|
-
function selected(allByDefault: boolean): Device[] {
|
|
123
|
-
if (!names.length && allByDefault) return [...network.devices];
|
|
94
|
+
Secrets.useRoot(dirname(configPath));
|
|
124
95
|
|
|
125
|
-
|
|
126
|
-
|
|
96
|
+
const network = await Cli.load(configPath);
|
|
97
|
+
const options: ApplyOptions = {
|
|
98
|
+
secrets: flags.secrets ?? false,
|
|
99
|
+
rollback: flags.rollback ?? 10,
|
|
100
|
+
...(flags.console ? { console: true } : {}),
|
|
101
|
+
};
|
|
127
102
|
|
|
128
|
-
|
|
129
|
-
}
|
|
103
|
+
return new Cli(configPath, network, options);
|
|
104
|
+
}
|
|
130
105
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
}
|
|
106
|
+
/** The config, a TypeScript file. Bun imports it as it is; Node needs jiti, which also resolves imports written without an extension. */
|
|
107
|
+
private static async load(path: string): Promise<Network> {
|
|
108
|
+
if (process.versions.bun) return (await import(path)).default;
|
|
135
109
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
110
|
+
const { createJiti } = await import("jiti");
|
|
111
|
+
return createJiti(import.meta.url).import(path, { default: true });
|
|
112
|
+
}
|
|
140
113
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
114
|
+
private static fail(message: string): never {
|
|
115
|
+
console.error(message);
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
145
118
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
/** What Circuit writes, beside the config. */
|
|
151
|
-
const generated = join(root, ".circuit");
|
|
152
|
-
const stateDirectory = join(generated, "state");
|
|
153
|
-
const statePath = (device: Device) => join(stateDirectory, device.name + adapterOf(device).extension);
|
|
119
|
+
/** `--rollback`'s value: a whole number of minutes. */
|
|
120
|
+
private static minutes(value: string): number {
|
|
121
|
+
const minutes = Number(value);
|
|
122
|
+
if (!Number.isInteger(minutes) || minutes < 1) throw new InvalidArgumentError("Give a whole number of minutes, such as 10.");
|
|
154
123
|
|
|
155
|
-
|
|
124
|
+
return minutes;
|
|
125
|
+
}
|
|
156
126
|
|
|
157
|
-
|
|
127
|
+
/** `--timeout`'s value: a whole number of seconds. */
|
|
128
|
+
private static seconds(value: string): number {
|
|
129
|
+
const seconds = Number(value);
|
|
130
|
+
if (!Number.isInteger(seconds) || seconds < 1) throw new InvalidArgumentError("Give a whole number of seconds, such as 90.");
|
|
158
131
|
|
|
159
|
-
|
|
160
|
-
|
|
132
|
+
return seconds;
|
|
133
|
+
}
|
|
161
134
|
|
|
162
|
-
/**
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
writeFileSync(statePath(device), text);
|
|
167
|
-
}
|
|
135
|
+
/** `--wait`'s value: a whole number of sessions. */
|
|
136
|
+
private static count(value: string): number {
|
|
137
|
+
const count = Number(value);
|
|
138
|
+
if (!Number.isInteger(count) || count < 1) throw new InvalidArgumentError("Give a whole number of sessions, such as 4.");
|
|
168
139
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const relevant = new Set(devices.map((device) => device.name));
|
|
172
|
-
const findings = validate(network).filter((finding) => !finding.device || relevant.has(finding.device));
|
|
140
|
+
return count;
|
|
141
|
+
}
|
|
173
142
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
|
|
143
|
+
/** Every command, with its arguments and options. `circuit --help` and `circuit <command> --help` are written from these. */
|
|
144
|
+
private static program() {
|
|
145
|
+
const program = new Command("circuit")
|
|
146
|
+
.description("Describe each network device in TypeScript, see what would change, then make the device match.")
|
|
147
|
+
.usage("<command> [device...] [options]")
|
|
148
|
+
.showHelpAfterError("(add --help to see what it takes)");
|
|
149
|
+
|
|
150
|
+
program
|
|
151
|
+
.command("new")
|
|
152
|
+
.description("start a project from a pattern, asking what it needs")
|
|
153
|
+
.argument("[directory]", "where to create it")
|
|
154
|
+
.option("--pattern <name>", "single-site, edge-router or colocation")
|
|
155
|
+
.option("--asn <number>", "your AS number, for edge-router and colocation")
|
|
156
|
+
.option("--1password", "write .env with 1Password references instead of empty values")
|
|
157
|
+
.option("--use <manager>", "npm, pnpm or bun; the one that ran this by default")
|
|
158
|
+
.option("--no-install", "do not install the dependencies")
|
|
159
|
+
.action(async (directory, flags) => {
|
|
160
|
+
const { Scaffold } = await import("./new");
|
|
161
|
+
const options: ScaffoldOptions = {
|
|
162
|
+
pattern: flags.pattern,
|
|
163
|
+
asn: flags.asn,
|
|
164
|
+
onePassword: flags["1password"],
|
|
165
|
+
use: flags.use,
|
|
166
|
+
install: flags.install,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
await Scaffold.create(directory, options);
|
|
170
|
+
process.exit(0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
program
|
|
174
|
+
.command("validate")
|
|
175
|
+
.description("check the config, then every rule in checks")
|
|
176
|
+
.option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
|
|
177
|
+
.action(async (flags) => (await Cli.open(flags.config)).validate());
|
|
178
|
+
|
|
179
|
+
program
|
|
180
|
+
.command("build")
|
|
181
|
+
.description("print the configuration the device should run, without contacting it")
|
|
182
|
+
.argument("<device>")
|
|
183
|
+
.option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
|
|
184
|
+
.action(async (device, flags) => (await Cli.open(flags.config)).build(device));
|
|
185
|
+
|
|
186
|
+
program
|
|
187
|
+
.command("diff")
|
|
188
|
+
.description("what apply would send, planned against the last snapshot, offline")
|
|
189
|
+
.argument("[device...]", "every device when none is named")
|
|
190
|
+
.option("--secrets", "plan as apply --secrets would, sending every secret again")
|
|
191
|
+
.option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
|
|
192
|
+
.action(async (devices, flags) => (await Cli.open(flags.config, flags)).diff(devices));
|
|
193
|
+
|
|
194
|
+
program
|
|
195
|
+
.command("snapshot")
|
|
196
|
+
.description("read what each device runs into the state directory")
|
|
197
|
+
.argument("[device...]", "every device when none is named")
|
|
198
|
+
.option("--console", "through the device's serial console on its jump host, instead of SSH")
|
|
199
|
+
.option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
|
|
200
|
+
.action(async (devices, flags) => (await Cli.open(flags.config, flags)).snapshot(devices));
|
|
201
|
+
|
|
202
|
+
program
|
|
203
|
+
.command("apply")
|
|
204
|
+
.description("read the device, plan against what it runs, and send with --confirm")
|
|
205
|
+
.argument("<device>")
|
|
206
|
+
.option("--confirm", "send the plan; without it, nothing is sent")
|
|
207
|
+
.option("--secrets", "also send every secret, to rotate one")
|
|
208
|
+
.option("--rollback <minutes>", "minutes before a device that can restore itself does", Cli.minutes, 10)
|
|
209
|
+
.option("--console", "through the device's serial console on its jump host, instead of SSH")
|
|
210
|
+
.option("--force", "send even when the pings before --confirm find a path that loses too much or is too slow")
|
|
211
|
+
.option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
|
|
212
|
+
.action(async (device, flags) =>
|
|
213
|
+
(await Cli.open(flags.config, flags)).apply(device, flags.confirm ?? false, flags.force ?? false)
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
program
|
|
217
|
+
.command("show")
|
|
218
|
+
.description("run commands that only read on a device and print what it answers, secrets hidden")
|
|
219
|
+
.argument("<device>")
|
|
220
|
+
.argument("[command...]", "each command in quotes, such as '/ip route print'")
|
|
221
|
+
.option("--file <path>", "also each line of this file; blank lines and lines starting with # are skipped")
|
|
222
|
+
.option("--timeout <seconds>", "how long each command may take", Cli.seconds, 90)
|
|
223
|
+
.option("--console", "through the device's serial console, instead of SSH")
|
|
224
|
+
.option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
|
|
225
|
+
.action(async (device, commands, flags) =>
|
|
226
|
+
(await Cli.open(flags.config, flags)).show(device, commands, flags.file, flags.timeout)
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
program
|
|
230
|
+
.command("bgp")
|
|
231
|
+
.description("each BGP neighbor in the config next to its live session: up or down, prefixes, uptime")
|
|
232
|
+
.argument("[device]", "may be left out when only one device has BGP neighbors")
|
|
233
|
+
.argument("[name-regex]", "only the neighbors whose name matches")
|
|
234
|
+
.option(
|
|
235
|
+
"--wait [count]",
|
|
236
|
+
`read again every ${BgpWait.COMMAND.every} seconds until every neighbor shown, or this many, is up`,
|
|
237
|
+
Cli.count
|
|
238
|
+
)
|
|
239
|
+
.option("--max <seconds>", "how long --wait waits at most", Cli.seconds, BgpWait.COMMAND.seconds)
|
|
240
|
+
.option("--log [minutes]", "also print the device's BGP log lines from the last minutes, 5 by default", Cli.minutes)
|
|
241
|
+
.option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
|
|
242
|
+
.action(async (first, second, flags) => (await Cli.open(flags.config)).bgp(first, second, flags));
|
|
243
|
+
|
|
244
|
+
program
|
|
245
|
+
.command("console")
|
|
246
|
+
.description("open a device's serial console here, logged in; or send it lines and print what it answers")
|
|
247
|
+
.argument("<device>")
|
|
248
|
+
.argument("[line...]", "lines to send, each in quotes; none joins the console to this terminal, Ctrl-] leaves")
|
|
249
|
+
.option("--raw", "do not log in or out: only join the console, or send the lines")
|
|
250
|
+
.option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
|
|
251
|
+
.action(async (device, lines, flags) => (await Cli.open(flags.config)).console(device, lines, flags.raw ?? false));
|
|
252
|
+
|
|
253
|
+
program
|
|
254
|
+
.command("refresh")
|
|
255
|
+
.description("fetch prefix sets that come from a registry onto the device, with --confirm")
|
|
256
|
+
.argument("<device>")
|
|
257
|
+
.option("--confirm", "put them on the device; without it, nothing is sent")
|
|
258
|
+
.option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
|
|
259
|
+
.action(async (device, flags) => (await Cli.open(flags.config)).refresh(device, flags.confirm ?? false));
|
|
260
|
+
|
|
261
|
+
program
|
|
262
|
+
.command("wireguard")
|
|
263
|
+
.description("a client config for one peer, its private key left for the peer to fill")
|
|
264
|
+
.argument("<device>")
|
|
265
|
+
.argument("<interface>")
|
|
266
|
+
.argument("<peer>")
|
|
267
|
+
.option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
|
|
268
|
+
.action(async (device, iface, peer, flags) => (await Cli.open(flags.config)).wireguard(device, iface, peer));
|
|
269
|
+
|
|
270
|
+
program
|
|
271
|
+
.command("communities")
|
|
272
|
+
.description("the network's BGP communities, one per line, for a looking glass or bgp.tools")
|
|
273
|
+
.option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
|
|
274
|
+
.action(async (flags) => (await Cli.open(flags.config)).communities());
|
|
275
|
+
|
|
276
|
+
program
|
|
277
|
+
.command("peeringdb")
|
|
278
|
+
.description("bring PeeringDB's exchange records in line with the config, with --confirm")
|
|
279
|
+
.option("--confirm", "send the changes; without it, nothing is sent")
|
|
280
|
+
.option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
|
|
281
|
+
.action(async (flags) => (await Cli.open(flags.config)).peeringdb(flags.confirm ?? false));
|
|
282
|
+
|
|
283
|
+
program
|
|
284
|
+
.command("librenms")
|
|
285
|
+
.description("bring LibreNMS in line with the config, with --confirm")
|
|
286
|
+
.option("--confirm", "send the changes; without it, nothing is sent")
|
|
287
|
+
.option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
|
|
288
|
+
.action(async (flags) => (await Cli.open(flags.config)).librenms(flags.confirm ?? false));
|
|
289
|
+
|
|
290
|
+
program
|
|
291
|
+
.command("netbox")
|
|
292
|
+
.description("bring NetBox in line with the config, with --confirm")
|
|
293
|
+
.option("--confirm", "send the changes; without it, nothing is sent")
|
|
294
|
+
.option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
|
|
295
|
+
.action(async (flags) => (await Cli.open(flags.config)).netbox(flags.confirm ?? false));
|
|
296
|
+
|
|
297
|
+
program
|
|
298
|
+
.command("prometheus")
|
|
299
|
+
.description("every device with SNMP, as Prometheus targets for snmp_exporter")
|
|
300
|
+
.option("--auths", "snmp_exporter's auths for them instead, with secrets as ${NAME}")
|
|
301
|
+
.option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
|
|
302
|
+
.action(async (flags) => (await Cli.open(flags.config)).prometheus(flags.auths ?? false));
|
|
303
|
+
|
|
304
|
+
program
|
|
305
|
+
.command("secrets")
|
|
306
|
+
.description("every secret the config uses, and whether each can be read")
|
|
307
|
+
.option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
|
|
308
|
+
.action(async (flags) => (await Cli.open(flags.config)).secrets());
|
|
309
|
+
|
|
310
|
+
program
|
|
311
|
+
.command("rename")
|
|
312
|
+
.description("give a device a new name in every file that names it, with --confirm")
|
|
313
|
+
.argument("<device>")
|
|
314
|
+
.argument("<new-name>")
|
|
315
|
+
.option("--file", "also rename the file that declares it, to <new-name>.ts")
|
|
316
|
+
.option("--confirm", "write the files; without it, nothing is written")
|
|
317
|
+
.option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
|
|
318
|
+
.action(async (from, to, flags) =>
|
|
319
|
+
(await Cli.open(flags.config)).rename(from, to, flags.file ?? false, flags.confirm ?? false)
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
program
|
|
323
|
+
.command("addresses")
|
|
324
|
+
.description("every address the config uses, by prefix, and what is still free")
|
|
325
|
+
.argument("[prefix]", "only the addresses in this prefix, such as 10.0.0.0/16")
|
|
326
|
+
.option("--length <n>", "free prefixes of this length, instead of the fewest that cover the free space")
|
|
327
|
+
.option("--format <format>", "json, to print JSON")
|
|
328
|
+
.option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
|
|
329
|
+
.action(async (within, flags) => (await Cli.open(flags.config)).addresses(within, flags.length, flags.format));
|
|
330
|
+
|
|
331
|
+
program
|
|
332
|
+
.command("graph")
|
|
333
|
+
.description("the network as a graph, from the config alone, without contacting a device")
|
|
334
|
+
.argument("[device...]", "only these devices and what they connect to")
|
|
335
|
+
.option("--layer <layers>", `${Topology.LAYERS.join(", ")}, several with commas, or all`, "l1")
|
|
336
|
+
.addOption(new Option("--format <format>", "how to print it").choices(["mermaid", "dot", "json"] as const).default("mermaid"))
|
|
337
|
+
.option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
|
|
338
|
+
.action(async (devices, flags) => (await Cli.open(flags.config)).graph(devices, flags.layer, flags.format));
|
|
339
|
+
|
|
340
|
+
program
|
|
341
|
+
.command("studio")
|
|
342
|
+
.description("Circuit Studio: the network in the browser, drawn again whenever the config changes")
|
|
343
|
+
.option("--port <number>", "the port it listens on", "4600")
|
|
344
|
+
.option("--no-open", "do not open the browser")
|
|
345
|
+
.option("--host <address>", "listen on a server's address, such as 0.0.0.0, instead; needs --allow-host", "127.0.0.1")
|
|
346
|
+
.option("--allow-host <names>", "the names it is reached by, such as studio.example.com, several with commas")
|
|
347
|
+
.option("--json", "print what Studio shows, as JSON, instead")
|
|
348
|
+
.option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
|
|
349
|
+
.action(async (flags) => (await Cli.open(flags.config)).studio(flags));
|
|
350
|
+
|
|
351
|
+
return program;
|
|
178
352
|
}
|
|
179
353
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
354
|
+
/** The devices with these names, or every device when none is named and `allByDefault`. */
|
|
355
|
+
private selected(names: readonly string[], allByDefault: boolean): Device[] {
|
|
356
|
+
if (!names.length && allByDefault) return [...this.network.devices];
|
|
183
357
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
const commands = plan.steps.reduce((count, step) => count + step.send.length, 0);
|
|
187
|
-
const summary = commands ? `${commands} command(s) in ${plan.steps.length} step(s)` : "matches";
|
|
188
|
-
console.log(`${device.name}: ${summary}`);
|
|
358
|
+
const known = this.network.devices.map((device) => device.name).join(", ");
|
|
359
|
+
const byName = (name: string) => this.network.devices.find((device) => device.name === name);
|
|
189
360
|
|
|
190
|
-
|
|
191
|
-
console.log(` ${step.title}`);
|
|
192
|
-
for (const line of step.show) console.log(` ${line.slice(0, 220)}`);
|
|
361
|
+
return names.map((name) => byName(name) ?? Cli.fail(`no device ${name}; known: ${known}`));
|
|
193
362
|
}
|
|
194
363
|
|
|
195
|
-
|
|
364
|
+
/** The device with this name. */
|
|
365
|
+
private one(name: string): Device {
|
|
366
|
+
return this.selected([name], false)[0]!;
|
|
367
|
+
}
|
|
196
368
|
|
|
197
|
-
|
|
198
|
-
|
|
369
|
+
/** How a device is reached: through its console when `--console` asks, or when it has no management address. */
|
|
370
|
+
private optionsFor(device: Device): ApplyOptions {
|
|
371
|
+
return this.options.console || !device.connection.host ? { ...this.options, console: true } : this.options;
|
|
372
|
+
}
|
|
199
373
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
374
|
+
/** The adapter of a device, which must be able to use its console when the device is reached through it. */
|
|
375
|
+
private adapterOf(device: Device): Platform {
|
|
376
|
+
const adapter = Platforms.find(device.platform) ?? Cli.fail(`no adapter for ${device.platform}`);
|
|
377
|
+
if (!this.optionsFor(device).console) return adapter;
|
|
204
378
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
}
|
|
379
|
+
if (!adapter.console) Cli.fail(`${device.name}: ${device.platform} cannot be reached through a console yet`);
|
|
380
|
+
if (!device.connection.console) Cli.fail(`${device.name}: --console needs connection.console`);
|
|
381
|
+
return adapter;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private statePath(device: Device): string {
|
|
385
|
+
return join(this.stateDirectory, device.name + this.adapterOf(device).extension);
|
|
386
|
+
}
|
|
209
387
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
388
|
+
/** Record what a device runs, beside a note that the directory is generated. */
|
|
389
|
+
private writeState(device: Device, text: string): void {
|
|
390
|
+
mkdirSync(this.stateDirectory, { recursive: true });
|
|
391
|
+
writeFileSync(join(this.generated, "README.md"), GENERATED_README);
|
|
392
|
+
writeFileSync(this.statePath(device), text);
|
|
393
|
+
}
|
|
213
394
|
|
|
214
|
-
|
|
395
|
+
/** Each device's LLDP neighbors at its last snapshot, for the devices whose neighbors were read. */
|
|
396
|
+
private neighbors(): Map<string, Neighbor[]> {
|
|
397
|
+
const found = new Map<string, Neighbor[]>();
|
|
215
398
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
console.log(`${device.name}: no snapshot, run snapshot first`);
|
|
220
|
-
continue;
|
|
399
|
+
for (const device of this.network.devices) {
|
|
400
|
+
const neighbors = Cabling.load(this.stateDirectory, device.name);
|
|
401
|
+
if (neighbors) found.set(device.name, neighbors);
|
|
221
402
|
}
|
|
222
403
|
|
|
223
|
-
|
|
224
|
-
const plan = adapter.plan(adapter.render(device), readFileSync(path, "utf8"), options);
|
|
225
|
-
differences += show(device, plan);
|
|
404
|
+
return found;
|
|
226
405
|
}
|
|
227
406
|
|
|
228
|
-
|
|
229
|
-
|
|
407
|
+
/** What `validate` finds: the config's own findings, then where LLDP at the last snapshot disagrees with `link`. */
|
|
408
|
+
private findings(): Finding[] {
|
|
409
|
+
return [...Validator.run(this.network), ...Cabling.compare(this.network.devices, this.neighbors())];
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Validate, print what was found, and stop on an error about one of these devices. `diff` and `apply` leave LLDP out: it is what was seen, not config. */
|
|
413
|
+
private guard(devices: Device[], all: readonly Finding[] = Validator.run(this.network)): void {
|
|
414
|
+
const relevant = new Set(devices.map((device) => device.name));
|
|
415
|
+
const findings = all.filter((finding) => !finding.device || relevant.has(finding.device));
|
|
416
|
+
|
|
417
|
+
for (const finding of findings) {
|
|
418
|
+
const where = finding.device ? `${finding.device}: ` : "";
|
|
419
|
+
const level = finding.level === "error" ? "error" : "warn ";
|
|
420
|
+
console.log(` ${level} ${where}${finding.message}`);
|
|
421
|
+
}
|
|
230
422
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const text = await adapterOf(device).read(device, network.devices, optionsFor(device));
|
|
234
|
-
writeState(device, text);
|
|
235
|
-
console.log(`${device.name}: ${text.split("\n").length} lines`);
|
|
423
|
+
const errors = findings.filter((finding) => finding.level === "error").length;
|
|
424
|
+
if (errors) Cli.fail(`\n${errors} error(s).`);
|
|
236
425
|
}
|
|
237
426
|
|
|
238
|
-
|
|
239
|
-
|
|
427
|
+
/** Print a plan. Returns how many commands and problems it holds. */
|
|
428
|
+
private static printPlan(device: Device, plan: Plan): number {
|
|
429
|
+
const commands = plan.steps.reduce((count, step) => count + step.send.length, 0);
|
|
430
|
+
const summary = commands ? `${commands} command(s) in ${plan.steps.length} step(s)` : "matches";
|
|
431
|
+
console.log(`${device.name}: ${summary}`);
|
|
240
432
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
433
|
+
for (const step of plan.steps) {
|
|
434
|
+
console.log(` ${step.title}`);
|
|
435
|
+
for (const line of step.show) console.log(` ${line.slice(0, 220)}`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
for (const problem of plan.problems) console.log(` PROBLEM ${problem}`);
|
|
244
439
|
|
|
245
|
-
|
|
246
|
-
console.log(`${file}: removed, no such device in the config`);
|
|
440
|
+
return commands + plan.problems.length;
|
|
247
441
|
}
|
|
248
|
-
}
|
|
249
442
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
443
|
+
private validate(): void {
|
|
444
|
+
this.guard([...this.network.devices], this.findings());
|
|
445
|
+
console.log(`${this.network.devices.length} device(s), no errors.`);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private build(name: string): void {
|
|
449
|
+
const device = this.one(name);
|
|
450
|
+
process.stdout.write(this.adapterOf(device).render(device));
|
|
451
|
+
}
|
|
254
452
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
453
|
+
private diff(names: string[]): void {
|
|
454
|
+
const devices = this.selected(names, true);
|
|
455
|
+
this.guard(devices);
|
|
258
456
|
|
|
259
|
-
|
|
260
|
-
if (!show(device, plan)) return;
|
|
457
|
+
let differences = 0;
|
|
261
458
|
|
|
262
|
-
|
|
459
|
+
for (const device of devices) {
|
|
460
|
+
const path = this.statePath(device);
|
|
461
|
+
if (!existsSync(path)) {
|
|
462
|
+
console.log(`${device.name}: no snapshot, run snapshot first`);
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
263
465
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
466
|
+
const adapter = this.adapterOf(device);
|
|
467
|
+
const plan = adapter.plan(adapter.render(device), readFileSync(path, "utf8"), this.options);
|
|
468
|
+
differences += Cli.printPlan(device, plan);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
process.exit(differences ? 1 : 0);
|
|
267
472
|
}
|
|
268
473
|
|
|
269
|
-
|
|
474
|
+
private async snapshot(names: string[]): Promise<void> {
|
|
475
|
+
for (const device of this.selected(names, true)) {
|
|
476
|
+
const adapter = this.adapterOf(device);
|
|
477
|
+
const text = await adapter.read(device, this.network.devices, this.optionsFor(device));
|
|
478
|
+
this.writeState(device, text);
|
|
479
|
+
console.log(`${device.name}: ${text.split("\n").length} lines`);
|
|
270
480
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
481
|
+
await this.snapshotNeighbors(device, adapter);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// With every device read, a snapshot of a device the config no longer has, such as one renamed, is removed.
|
|
485
|
+
if (names.length || !existsSync(this.stateDirectory)) return;
|
|
274
486
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
487
|
+
const current = new Set(
|
|
488
|
+
this.network.devices.flatMap((device) => [
|
|
489
|
+
basename(this.statePath(device)),
|
|
490
|
+
basename(Cabling.path(this.stateDirectory, device.name)),
|
|
491
|
+
])
|
|
492
|
+
);
|
|
493
|
+
for (const file of readdirSync(this.stateDirectory)) {
|
|
494
|
+
if (current.has(file)) continue;
|
|
278
495
|
|
|
279
|
-
|
|
280
|
-
|
|
496
|
+
rmSync(join(this.stateDirectory, file));
|
|
497
|
+
console.log(`${file}: removed, no such device in the config`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
281
500
|
|
|
282
|
-
|
|
283
|
-
|
|
501
|
+
/** Save a device's LLDP neighbors beside its config, where its platform can read them. A device without LLDP is left alone. */
|
|
502
|
+
private async snapshotNeighbors(device: Device, adapter: Platform): Promise<void> {
|
|
503
|
+
if (!Cabling.runsAnywhere(device)) return;
|
|
284
504
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
505
|
+
if (!adapter.neighbors) {
|
|
506
|
+
Cabling.forget(this.stateDirectory, device.name);
|
|
507
|
+
console.log(`${device.name}: LLDP neighbors not read, reading them on ${device.platform} is not proved on a device yet`);
|
|
508
|
+
return;
|
|
288
509
|
}
|
|
289
510
|
|
|
290
|
-
|
|
291
|
-
|
|
511
|
+
const neighbors = await adapter.neighbors(device, this.network.devices, this.optionsFor(device));
|
|
512
|
+
Cabling.save(this.stateDirectory, device.name, neighbors);
|
|
513
|
+
console.log(`${device.name}: ${neighbors.length} LLDP neighbor(s)`);
|
|
292
514
|
}
|
|
293
515
|
|
|
294
|
-
|
|
295
|
-
|
|
516
|
+
private async apply(name: string, confirmed: boolean, force: boolean): Promise<void> {
|
|
517
|
+
const device = this.one(name);
|
|
518
|
+
this.guard([device]);
|
|
519
|
+
const adapter = this.adapterOf(device);
|
|
296
520
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
// The DER prefix of a raw X25519 private key in PKCS#8.
|
|
300
|
-
const pkcs8Prefix = Buffer.from("302e020100300506032b656e04220420", "hex");
|
|
301
|
-
const der = Buffer.concat([pkcs8Prefix, Buffer.from(privateKey, "base64")]);
|
|
521
|
+
// A lossy path also cuts the read short, so the path is checked before the read.
|
|
522
|
+
if (confirmed) await this.checkPath(device, force);
|
|
302
523
|
|
|
303
|
-
|
|
304
|
-
|
|
524
|
+
// The plan is made against what the device runs now, never against an old snapshot.
|
|
525
|
+
const live = await adapter.read(device, this.network.devices, this.optionsFor(device));
|
|
526
|
+
this.writeState(device, live);
|
|
305
527
|
|
|
306
|
-
|
|
307
|
-
|
|
528
|
+
const plan = adapter.plan(adapter.render(device), live, this.options);
|
|
529
|
+
if (!Cli.printPlan(device, plan)) return;
|
|
308
530
|
|
|
309
|
-
|
|
310
|
-
const [deviceName, interfaceName, peerName] = names;
|
|
311
|
-
const device = network.devices.find((candidate) => candidate.name === deviceName) ?? fail(HELP);
|
|
312
|
-
|
|
313
|
-
const iface = device.interfaces?.[interfaceName ?? ""];
|
|
314
|
-
if (iface?.type !== "wireguard") fail(`${deviceName} has no WireGuard interface ${interfaceName}`);
|
|
315
|
-
|
|
316
|
-
const peers = iface.peers ?? [];
|
|
317
|
-
const peerNames = peers.map((candidate) => candidate.name).join(", ");
|
|
318
|
-
const peer = peers.find((candidate) => candidate.name === peerName) ?? fail(`no peer ${peerName} on ${interfaceName}: ${peerNames}`);
|
|
319
|
-
|
|
320
|
-
const tunnelNetworks = (iface.addresses ?? []).map((entry) => prefixOf(typeof entry === "string" ? entry : entry.address));
|
|
321
|
-
|
|
322
|
-
const lines = [
|
|
323
|
-
"[Interface]",
|
|
324
|
-
`# ${peerName}'s own private key. It never leaves their machine.`,
|
|
325
|
-
"PrivateKey = <FILL IN>",
|
|
326
|
-
`Address = ${peer.allowed_addresses.join(", ")}`,
|
|
327
|
-
"",
|
|
328
|
-
"[Peer]",
|
|
329
|
-
`PublicKey = ${publicKeyOf(reveal(iface.private_key))}`,
|
|
330
|
-
`Endpoint = ${iface.endpoint ?? "<FILL IN>"}:${iface.listen_port}`,
|
|
331
|
-
`AllowedIPs = ${(peer.client_allowed_addresses ?? tunnelNetworks).join(", ")}`,
|
|
332
|
-
"PersistentKeepalive = 25",
|
|
333
|
-
"",
|
|
334
|
-
];
|
|
335
|
-
process.stdout.write(lines.join("\n"));
|
|
336
|
-
}
|
|
531
|
+
if (plan.problems.length) Cli.fail("\nNot sent: resolve the problems first.");
|
|
337
532
|
|
|
338
|
-
|
|
339
|
-
|
|
533
|
+
if (!confirmed) {
|
|
534
|
+
console.log("\nNothing sent. Re-run with --confirm.");
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
340
537
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
if (community.includes(",")) fail(`community ${community} holds a comma`);
|
|
344
|
-
console.log(`${community},${description}`);
|
|
345
|
-
}
|
|
346
|
-
}
|
|
538
|
+
// What the checks after the apply compare against, such as the BGP sessions up now, is read before anything is sent.
|
|
539
|
+
const health = await adapter.health?.(device, plan, this.optionsFor(device));
|
|
347
540
|
|
|
348
|
-
|
|
349
|
-
const plan = await planPeeringDb(network);
|
|
541
|
+
await adapter.apply(device, this.network.devices, plan, this.optionsFor(device));
|
|
350
542
|
|
|
351
|
-
|
|
352
|
-
console.log(
|
|
353
|
-
|
|
543
|
+
this.writeState(device, await adapter.read(device, this.network.devices, this.optionsFor(device)));
|
|
544
|
+
console.log("snapshot updated");
|
|
545
|
+
|
|
546
|
+
if (!health) return;
|
|
547
|
+
|
|
548
|
+
console.log("checking the device after the apply");
|
|
549
|
+
const problems = await health.run();
|
|
354
550
|
|
|
355
|
-
|
|
551
|
+
if (problems.length) {
|
|
552
|
+
Cli.fail(`\nApplied, but the device is not healthy:\n${problems.map((problem) => ` ${problem}`).join("\n")}`);
|
|
553
|
+
}
|
|
356
554
|
|
|
357
|
-
|
|
358
|
-
console.log("PeeringDB matches the config.");
|
|
359
|
-
return;
|
|
555
|
+
console.log("no problems found");
|
|
360
556
|
}
|
|
361
557
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
558
|
+
/** Run commands that only read, each under a header with how long it took. Every command is checked before anything connects. */
|
|
559
|
+
private async show(name: string, given: string[], file: string | undefined, timeoutSeconds: number): Promise<void> {
|
|
560
|
+
const device = this.one(name);
|
|
561
|
+
const adapter = this.adapterOf(device);
|
|
562
|
+
|
|
563
|
+
const fromFile = file ? readFileSync(resolve(process.cwd(), file), "utf8").split(/\r?\n/) : [];
|
|
564
|
+
const listed = fromFile.map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
565
|
+
const commands = [...given, ...listed];
|
|
566
|
+
if (!commands.length) Cli.fail("give a command to run, or --file");
|
|
567
|
+
|
|
568
|
+
const refused = commands.filter((command) => !adapter.readOnly(command));
|
|
569
|
+
if (refused.length) {
|
|
570
|
+
Cli.fail(
|
|
571
|
+
`not known to only read on ${device.platform}, so nothing was sent:\n${refused.map((command) => ` ${command}`).join("\n")}`
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const session = await adapter.session(device, this.optionsFor(device));
|
|
576
|
+
const timeout = timeoutSeconds * 1000;
|
|
577
|
+
let failed = false;
|
|
578
|
+
|
|
579
|
+
try {
|
|
580
|
+
for (const command of commands) {
|
|
581
|
+
const started = Date.now();
|
|
582
|
+
let output: string;
|
|
583
|
+
|
|
584
|
+
try {
|
|
585
|
+
output = await session.run(command, timeout);
|
|
586
|
+
} catch (error) {
|
|
587
|
+
output = `stopped: ${(error as Error).message}`;
|
|
588
|
+
failed = true;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const took = Date.now() - started;
|
|
592
|
+
const stoppedAtLimit = !failed && took >= timeout ? `\n(stopped at the ${timeoutSeconds} second limit)` : "";
|
|
593
|
+
const block = `>>> ${command} (${(took / 1000).toFixed(1)}s)\n${output.replace(/\r/g, "").trimEnd()}${stoppedAtLimit}\n\n`;
|
|
594
|
+
process.stdout.write(Secrets.scrub(block, { network: this.network }));
|
|
595
|
+
|
|
596
|
+
// A shell or a console still runs a command that did not finish, so nothing more can be sent.
|
|
597
|
+
if (failed) break;
|
|
598
|
+
}
|
|
599
|
+
} finally {
|
|
600
|
+
session.close();
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (failed) process.exitCode = 1;
|
|
365
604
|
}
|
|
366
605
|
|
|
367
|
-
|
|
368
|
-
|
|
606
|
+
/**
|
|
607
|
+
* Each BGP neighbor in the config next to its live session. `first` is the device, or, when it names none and only one device has BGP neighbors, the name pattern. With `wait`, reads again until every neighbor shown, or that many, is up, and fails when time runs out.
|
|
608
|
+
*/
|
|
609
|
+
private async bgp(
|
|
610
|
+
first: string | undefined,
|
|
611
|
+
second: string | undefined,
|
|
612
|
+
flags: { wait?: true | number; max: number; log?: true | number }
|
|
613
|
+
): Promise<void> {
|
|
614
|
+
const { device, pattern } = this.bgpTarget(first, second);
|
|
615
|
+
const adapter = this.adapterOf(device);
|
|
616
|
+
|
|
617
|
+
const supported = Platforms.all()
|
|
618
|
+
.filter((platform) => platform.bgpSessions)
|
|
619
|
+
.map((platform) => platform.name)
|
|
620
|
+
.join(", ");
|
|
621
|
+
if (!adapter.bgpSessions) Cli.fail(`circuit bgp does not support ${device.platform} yet, only ${supported}`);
|
|
622
|
+
if (flags.log && !adapter.bgpLog) Cli.fail(`circuit bgp --log does not support ${device.platform} yet`);
|
|
623
|
+
|
|
624
|
+
let matcher: RegExp;
|
|
625
|
+
try {
|
|
626
|
+
matcher = new RegExp(pattern ?? ".");
|
|
627
|
+
} catch (error) {
|
|
628
|
+
Cli.fail(`${pattern} is not a regular expression: ${(error as Error).message}`);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const names = Object.keys(device.routing?.bgp?.neighbors ?? {});
|
|
632
|
+
if (!names.length) Cli.fail(`${device.name} has no BGP neighbors`);
|
|
369
633
|
|
|
370
|
-
|
|
371
|
-
|
|
634
|
+
const shown = names.filter((name) => matcher.test(name));
|
|
635
|
+
if (!shown.length) Cli.fail(`no BGP neighbor of ${device.name} matches ${matcher}; known: ${names.join(", ")}`);
|
|
372
636
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
for (const device of plan.withoutSnmp)
|
|
376
|
-
console.log(` ${device.name} has no SNMP in its config, so LibreNMS cannot poll it. Add management.snmp first.`);
|
|
377
|
-
for (const device of plan.additions) console.log(` add ${device.name}, ${device.connection.host}`);
|
|
637
|
+
const want = flags.wait === undefined ? 0 : flags.wait === true ? shown.length : flags.wait;
|
|
638
|
+
const session = await adapter.session(device, this.optionsFor(device));
|
|
378
639
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
640
|
+
try {
|
|
641
|
+
const read = async () => (await adapter.bgpSessions!(device, session)).filter((neighbor) => shown.includes(neighbor.neighbor));
|
|
642
|
+
|
|
643
|
+
const limits = { seconds: flags.max, every: BgpWait.COMMAND.every };
|
|
644
|
+
const sessions = await BgpWait.until(
|
|
645
|
+
read,
|
|
646
|
+
(sessions) => BgpWait.upCount(sessions) >= want,
|
|
647
|
+
limits,
|
|
648
|
+
(sessions) =>
|
|
649
|
+
console.log(
|
|
650
|
+
`${new Date().toLocaleTimeString()}: ${BgpWait.upCount(sessions)}/${want} up, checking again in ${limits.every}s`
|
|
651
|
+
)
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
Cli.printBgp(sessions);
|
|
655
|
+
if (BgpWait.upCount(sessions) < want) process.exitCode = 1;
|
|
656
|
+
|
|
657
|
+
if (flags.log) await this.printBgpLog(adapter, session, flags.log === true ? 5 : flags.log);
|
|
658
|
+
} finally {
|
|
659
|
+
session.close();
|
|
660
|
+
}
|
|
382
661
|
}
|
|
383
662
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
663
|
+
/** The device `circuit bgp` reads, and the name pattern, from its two optional arguments. */
|
|
664
|
+
private bgpTarget(first: string | undefined, second: string | undefined): { device: Device; pattern: string | undefined } {
|
|
665
|
+
if (second !== undefined || (first && this.network.devices.some((device) => device.name === first))) {
|
|
666
|
+
return { device: this.one(first!), pattern: second };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const withBgp = this.network.devices.filter((device) => Object.keys(device.routing?.bgp?.neighbors ?? {}).length);
|
|
670
|
+
const named = first ? `no device ${first}, and ` : "";
|
|
671
|
+
|
|
672
|
+
if (withBgp.length === 1) return { device: withBgp[0]!, pattern: first };
|
|
673
|
+
if (!withBgp.length) Cli.fail(`${named}no device has BGP neighbors`);
|
|
674
|
+
|
|
675
|
+
return Cli.fail(`${named}several devices have BGP neighbors, so name one: ${withBgp.map((device) => device.name).join(", ")}`);
|
|
387
676
|
}
|
|
388
677
|
|
|
389
|
-
|
|
390
|
-
|
|
678
|
+
/** One line per neighbor: name, up or down, prefixes received, uptime, remote address. Then how many are up. */
|
|
679
|
+
private static printBgp(sessions: readonly BgpSession[]): void {
|
|
680
|
+
const width = Math.max(...sessions.map((session) => session.neighbor.length));
|
|
391
681
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
682
|
+
for (const session of sessions) {
|
|
683
|
+
const state = session.established ? "up " : "DOWN";
|
|
684
|
+
console.log(
|
|
685
|
+
`${session.neighbor.padEnd(width)} ${state} ${session.prefixes.padStart(7)} ${session.uptime.padEnd(12)} ${session.remote}`
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
console.log(`${BgpWait.upCount(sessions)}/${sessions.length} up`);
|
|
398
690
|
}
|
|
399
691
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
}
|
|
692
|
+
/** The device's BGP log lines from the last `minutes`, a message repeated folded into one line, secrets hidden. */
|
|
693
|
+
private async printBgpLog(adapter: Platform, session: ReadSession, minutes: number): Promise<void> {
|
|
694
|
+
const lines = Cli.foldLog(await adapter.bgpLog!(session, minutes));
|
|
695
|
+
const text = `\n--- BGP log, last ${minutes} min\n${lines.join("\n") || "(nothing)"}\n`;
|
|
404
696
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
if (isSecret(value)) found.add(referenceOf(value));
|
|
408
|
-
else if (Array.isArray(value)) for (const item of value) secretsIn(item, found);
|
|
409
|
-
else if (typeof value === "object" && value !== null) for (const item of Object.values(value)) secretsIn(item, found);
|
|
697
|
+
process.stdout.write(Secrets.scrub(text, { network: this.network }));
|
|
698
|
+
}
|
|
410
699
|
|
|
411
|
-
|
|
412
|
-
|
|
700
|
+
/** A message repeated, such as one every 10 seconds, becomes one line with when it came first, when last, and how many times. */
|
|
701
|
+
private static foldLog(lines: readonly BgpLogLine[]): string[] {
|
|
702
|
+
const folded = new Map<string, { count: number; first: string; last: string }>();
|
|
703
|
+
|
|
704
|
+
for (const { at, message } of lines) {
|
|
705
|
+
const seen = folded.get(message);
|
|
706
|
+
if (seen) {
|
|
707
|
+
seen.count++;
|
|
708
|
+
seen.last = at;
|
|
709
|
+
} else {
|
|
710
|
+
folded.set(message, { count: 1, first: at, last: at });
|
|
711
|
+
}
|
|
712
|
+
}
|
|
413
713
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
714
|
+
return [...folded].map(([message, { count, first, last }]) => {
|
|
715
|
+
const lastTime = last.split(" ")[1] ?? last;
|
|
716
|
+
return count > 1 ? `${first}..${lastTime} x${count} ${message}` : `${first} ${message}`;
|
|
717
|
+
});
|
|
718
|
+
}
|
|
417
719
|
|
|
418
|
-
|
|
419
|
-
|
|
720
|
+
/**
|
|
721
|
+
* A device's console, for a person: logged in with the platform's own login, then joined to this terminal, or sent each line. Logs out when done, and lets the port go on Ctrl-C or when the terminal closes, so no other program finds it held.
|
|
722
|
+
*/
|
|
723
|
+
private async console(name: string, lines: string[], raw: boolean): Promise<void> {
|
|
724
|
+
const device = this.one(name);
|
|
725
|
+
const dialect = Platforms.find(device.platform)?.console ?? Cli.fail(`${device.platform} cannot be reached through a console yet`);
|
|
726
|
+
const serial = device.connection.console ?? Cli.fail(`${device.name} has no connection.console`);
|
|
727
|
+
const login = raw ? undefined : SerialConsole.of(device);
|
|
420
728
|
|
|
729
|
+
let opened: ConsoleSession;
|
|
421
730
|
try {
|
|
422
|
-
|
|
423
|
-
console.log(` ok ${name}, from ${sourceOf(reference)}`);
|
|
731
|
+
opened = await SerialConsole.open(serial, 1);
|
|
424
732
|
} catch (error) {
|
|
425
|
-
|
|
426
|
-
|
|
733
|
+
const message = (error as Error).message;
|
|
734
|
+
if (message.startsWith("busy:")) {
|
|
735
|
+
Cli.fail(
|
|
736
|
+
`${device.name}: ${serial.port} is in use. Only one program can hold a console port: close the other one, then try again.`
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
Cli.fail(`${device.name}: ${message}`);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
let loggedIn = false;
|
|
743
|
+
let leaving = false;
|
|
744
|
+
|
|
745
|
+
const leave = async (code: number) => {
|
|
746
|
+
if (leaving) return;
|
|
747
|
+
leaving = true;
|
|
748
|
+
|
|
749
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
750
|
+
if (loggedIn) opened.channel.write(`${dialect.logout}\n`);
|
|
751
|
+
|
|
752
|
+
await opened.close();
|
|
753
|
+
process.exit(code);
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) process.once(signal, () => void leave(130));
|
|
757
|
+
|
|
758
|
+
opened.channel.on("close", () => {
|
|
759
|
+
if (leaving) return;
|
|
760
|
+
|
|
761
|
+
console.error("\nthe console closed");
|
|
762
|
+
process.exit(1);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
if (login) {
|
|
766
|
+
try {
|
|
767
|
+
await SerialConsole.login(opened.channel, dialect, login.user, login.password, serial.passwordOnly);
|
|
768
|
+
} catch (error) {
|
|
769
|
+
console.error(`${device.name}: ${(error as Error).message}`);
|
|
770
|
+
await leave(1);
|
|
771
|
+
}
|
|
772
|
+
loggedIn = true;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
if (lines.length) {
|
|
776
|
+
await ConsoleScript.run(opened.channel, lines, (answer) =>
|
|
777
|
+
process.stdout.write(Secrets.scrub(answer, { network: this.network }))
|
|
778
|
+
);
|
|
779
|
+
await leave(0);
|
|
780
|
+
return;
|
|
427
781
|
}
|
|
782
|
+
|
|
783
|
+
this.join(opened, device, loggedIn ? login?.user : undefined, leave);
|
|
428
784
|
}
|
|
429
785
|
|
|
430
|
-
console.
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
}
|
|
786
|
+
/** Join the console to this terminal until Ctrl-] or the end of input. Every other key, Ctrl-C too, goes to the device. */
|
|
787
|
+
private join(opened: ConsoleSession, device: Device, user: string | undefined, leave: (code: number) => Promise<void>): void {
|
|
788
|
+
const stdin = process.stdin;
|
|
789
|
+
const as = user ? `, logged in as ${user}` : "";
|
|
790
|
+
console.error(`${device.name}'s console${as}. Ctrl-] leaves.`);
|
|
791
|
+
|
|
792
|
+
opened.channel.on("data", (chunk: Buffer) => process.stdout.write(chunk));
|
|
793
|
+
|
|
794
|
+
if (stdin.isTTY) stdin.setRawMode(true);
|
|
795
|
+
|
|
796
|
+
stdin.on("data", (data: Buffer) => {
|
|
797
|
+
const at = data.indexOf(Cli.LEAVE_KEY);
|
|
798
|
+
const keys = at >= 0 ? data.subarray(0, at) : data;
|
|
799
|
+
|
|
800
|
+
// A terminal sends a carriage return for Enter; text piped in ends its lines with a line feed, which the console takes as a keyboard's Enter.
|
|
801
|
+
if (keys.length) opened.channel.write(stdin.isTTY ? keys : keys.toString("utf8"));
|
|
802
|
+
if (at >= 0) void leave(0);
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
stdin.on("end", () => void leave(0));
|
|
806
|
+
stdin.resume();
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/** Ping the device and stop when the path loses too much or is too slow, unless `force`. A device reached through its console has no path to measure. */
|
|
810
|
+
private async checkPath(device: Device, force: boolean): Promise<void> {
|
|
811
|
+
const host = device.connection.host;
|
|
812
|
+
if (!host || this.optionsFor(device).console) return;
|
|
813
|
+
|
|
814
|
+
const measure = await PathCheck.measure(host);
|
|
815
|
+
if (!measure) {
|
|
816
|
+
console.log(`path: no ping program here, so the path to ${host} was not measured`);
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const { refuse, summary } = PathCheck.verdict(host, measure);
|
|
821
|
+
console.log(`path: ${summary}`);
|
|
822
|
+
if (!refuse) return;
|
|
823
|
+
|
|
824
|
+
const limits = `at most ${PathCheck.MAX_LOSS_PERCENT}% lost and ${PathCheck.MAX_AVERAGE_MS} ms on average`;
|
|
825
|
+
if (force) {
|
|
826
|
+
console.log(`path: worse than ${limits}; going ahead, as --force asks`);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
Cli.fail(
|
|
831
|
+
`\nNot sent: the path is worse than ${limits}. A change sent over a path that drops packets can cut the device off halfway. Fix the path, or add --force to go ahead anyway.`
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
private async refresh(name: string, confirmed: boolean): Promise<void> {
|
|
836
|
+
const device = this.one(name);
|
|
837
|
+
const adapter = this.adapterOf(device);
|
|
838
|
+
const fill = adapter.fill?.bind(adapter) ?? Cli.fail(`${device.platform} cannot hold a fetched prefix set`);
|
|
839
|
+
|
|
840
|
+
for (const [name, set] of Object.entries(device.prefix_sets ?? {})) {
|
|
841
|
+
if (!("source" in set)) continue;
|
|
842
|
+
|
|
843
|
+
const { registry, query, family } = set.source;
|
|
844
|
+
const prefixes = await Registries.prefixes(registry, query, family);
|
|
845
|
+
|
|
846
|
+
// An empty or absurd answer is a broken feed, not a policy.
|
|
847
|
+
if (prefixes.length < 1 || prefixes.length > 100_000) {
|
|
848
|
+
Cli.fail(`${name}: ${prefixes.length} prefixes from ${registry}, refusing`);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
console.log(`${name}: ${prefixes.length} prefixes from ${registry} ${query}`);
|
|
852
|
+
if (confirmed) await fill(device, this.network.devices, name, family, prefixes);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
if (!confirmed) console.log("\nNothing sent. Re-run with --confirm.");
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** A WireGuard public key from its private key, so the device's never has to be read off the box. */
|
|
859
|
+
private static publicKeyOf(privateKey: string): string {
|
|
860
|
+
// The DER prefix of a raw X25519 private key in PKCS#8.
|
|
861
|
+
const pkcs8Prefix = Buffer.from("302e020100300506032b656e04220420", "hex");
|
|
862
|
+
const der = Buffer.concat([pkcs8Prefix, Buffer.from(privateKey, "base64")]);
|
|
434
863
|
|
|
435
|
-
|
|
436
|
-
const
|
|
864
|
+
const key = createPrivateKey({ key: der, format: "der", type: "pkcs8" });
|
|
865
|
+
const publicDer = createPublicKey(key).export({ format: "der", type: "spki" });
|
|
437
866
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
867
|
+
return publicDer.subarray(-32).toString("base64");
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
private wireguard(deviceName: string, interfaceName: string, peerName: string): void {
|
|
871
|
+
const device = this.one(deviceName);
|
|
872
|
+
|
|
873
|
+
const iface = device.interfaces?.[interfaceName];
|
|
874
|
+
if (iface?.type !== "wireguard") Cli.fail(`${deviceName} has no WireGuard interface ${interfaceName}`);
|
|
875
|
+
|
|
876
|
+
const peers = iface.peers ?? [];
|
|
877
|
+
const peerNames = peers.map((candidate) => candidate.name).join(", ");
|
|
878
|
+
const peer =
|
|
879
|
+
peers.find((candidate) => candidate.name === peerName) ?? Cli.fail(`no peer ${peerName} on ${interfaceName}: ${peerNames}`);
|
|
442
880
|
|
|
443
|
-
|
|
444
|
-
const adapter = adapterOf(device);
|
|
445
|
-
const plan = adapter.plan(adapter.render(device), readFileSync(path, "utf8"), options);
|
|
881
|
+
const tunnelNetworks = (iface.addresses ?? []).map((entry) => Address.prefixOf(typeof entry === "string" ? entry : entry.address));
|
|
446
882
|
|
|
447
883
|
const lines = [
|
|
448
|
-
|
|
449
|
-
|
|
884
|
+
"[Interface]",
|
|
885
|
+
`# ${peerName}'s own private key. It never leaves their machine.`,
|
|
886
|
+
"PrivateKey = <FILL IN>",
|
|
887
|
+
`Address = ${peer.allowed_addresses.join(", ")}`,
|
|
888
|
+
"",
|
|
889
|
+
"[Peer]",
|
|
890
|
+
`PublicKey = ${Cli.publicKeyOf(Secrets.reveal(iface.private_key))}`,
|
|
891
|
+
`Endpoint = ${iface.endpoint ?? "<FILL IN>"}:${iface.listen_port}`,
|
|
892
|
+
`AllowedIPs = ${(peer.client_allowed_addresses ?? tunnelNetworks).join(", ")}`,
|
|
893
|
+
"PersistentKeepalive = 25",
|
|
894
|
+
"",
|
|
450
895
|
];
|
|
896
|
+
process.stdout.write(lines.join("\n"));
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
private communities(): void {
|
|
900
|
+
const communities = this.network.communities ?? Cli.fail("the network declares no communities");
|
|
451
901
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
902
|
+
for (const { community, description } of communities) {
|
|
903
|
+
// The format splits each line at its first comma.
|
|
904
|
+
if (community.includes(",")) Cli.fail(`community ${community} holds a comma`);
|
|
905
|
+
console.log(`${community},${description}`);
|
|
906
|
+
}
|
|
455
907
|
}
|
|
456
|
-
}
|
|
457
908
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
const graph = only(graphOf(network.devices), layers, deviceNames);
|
|
461
|
-
const shown = new Set(graph.nodes.map((node) => node.label));
|
|
462
|
-
|
|
463
|
-
return {
|
|
464
|
-
...graph,
|
|
465
|
-
findings: validate(network).filter((finding) => !finding.device || shown.has(finding.device)),
|
|
466
|
-
drift: Object.fromEntries(
|
|
467
|
-
network.devices.filter((device) => shown.has(device.name)).map((device) => [device.name, driftOf(device)])
|
|
468
|
-
),
|
|
469
|
-
};
|
|
470
|
-
}
|
|
909
|
+
private async peeringdb(confirmed: boolean): Promise<void> {
|
|
910
|
+
const plan = await PeeringDb.plan(this.network);
|
|
471
911
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
912
|
+
for (const name of plan.unknown) {
|
|
913
|
+
console.log(` PeeringDB lists ${name}, which the config does not. Remove it by hand if the network left.`);
|
|
914
|
+
}
|
|
475
915
|
|
|
476
|
-
|
|
477
|
-
if (unknown.length) fail(`no layer ${unknown.join(", ")}; known: ${LAYERS.join(", ")}, all`);
|
|
916
|
+
for (const change of plan.changes) console.log(` ${change.summary}`);
|
|
478
917
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
918
|
+
if (!plan.changes.length) {
|
|
919
|
+
console.log("PeeringDB matches the config.");
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
482
922
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
}
|
|
923
|
+
if (!confirmed) {
|
|
924
|
+
console.log("\nNothing sent. Re-run with --confirm.");
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
488
927
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
const next = segment.next ? `, next ${segment.next}` : "";
|
|
492
|
-
console.log(`${indent}${segment.prefix}, ${segment.free} free${next}`);
|
|
928
|
+
await PeeringDb.send(this.network, plan.changes);
|
|
929
|
+
}
|
|
493
930
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
}
|
|
931
|
+
private async librenms(confirmed: boolean): Promise<void> {
|
|
932
|
+
const plan = await LibreNms.plan(this.network);
|
|
497
933
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
934
|
+
for (const hostname of plan.unknown)
|
|
935
|
+
console.log(` LibreNMS monitors ${hostname}, which the config does not. Remove it by hand if it is gone.`);
|
|
936
|
+
for (const device of plan.withoutSnmp)
|
|
937
|
+
console.log(` ${device.name} has no SNMP in its config, so LibreNMS cannot poll it. Add management.snmp first.`);
|
|
938
|
+
for (const name of plan.dynamicGroups)
|
|
939
|
+
console.log(` LibreNMS's group ${name} is dynamic, so Circuit cannot fill it. Make it static, or rename it.`);
|
|
501
940
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
if (length !== undefined && !(Number.isInteger(length) && length >= 0 && length <= 128))
|
|
505
|
-
fail("--length takes a prefix length, such as 24");
|
|
941
|
+
for (const device of plan.additions) console.log(` + ${device.name}, ${device.connection.host}`);
|
|
942
|
+
for (const change of plan.changes) console.log(` ~ ${change.device.name}: ${change.what.join(", ")}`);
|
|
506
943
|
|
|
507
|
-
|
|
944
|
+
for (const group of plan.groups) {
|
|
945
|
+
const names = (devices: readonly Device[]) => devices.map((device) => device.name).join(", ");
|
|
946
|
+
|
|
947
|
+
if (group.create) console.log(` + group ${group.name}: ${names(group.add)}`);
|
|
948
|
+
if (!group.create && group.add.length) console.log(` ~ group ${group.name}: add ${names(group.add)}`);
|
|
949
|
+
if (!group.create && group.remove.length) console.log(` ~ group ${group.name}: remove ${names(group.remove)}`);
|
|
950
|
+
}
|
|
508
951
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
952
|
+
if (!LibreNms.count(plan)) {
|
|
953
|
+
console.log("LibreNMS matches the config.");
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
if (!confirmed) {
|
|
958
|
+
console.log("\nNothing sent. Re-run with --confirm.");
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
await LibreNms.send(this.network, plan);
|
|
512
963
|
}
|
|
513
964
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
965
|
+
private async netbox(confirmed: boolean): Promise<void> {
|
|
966
|
+
const netbox = new NetBox(this.network);
|
|
967
|
+
const plan = await netbox.plan();
|
|
968
|
+
|
|
969
|
+
Cli.showNetBox(plan);
|
|
970
|
+
|
|
971
|
+
if (!NetBox.count(plan)) {
|
|
972
|
+
console.log("NetBox matches the config.");
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
518
975
|
|
|
519
|
-
if (
|
|
520
|
-
console.log(
|
|
521
|
-
|
|
522
|
-
const { prefixes, total } = freeOfLength(space.free, length, 16);
|
|
523
|
-
const more = total > BigInt(prefixes.length) ? `, and ${total - BigInt(prefixes.length)} more` : "";
|
|
524
|
-
console.log(` free /${length}: ${prefixes.join(", ") || "none"}${more}`);
|
|
976
|
+
if (!confirmed) {
|
|
977
|
+
console.log("\nNothing sent. Re-run with --confirm.");
|
|
978
|
+
return;
|
|
525
979
|
}
|
|
526
980
|
|
|
527
|
-
|
|
981
|
+
const left = await netbox.send(plan);
|
|
982
|
+
if (!NetBox.count(left)) return;
|
|
983
|
+
|
|
984
|
+
console.log("\nNetBox still differs:");
|
|
985
|
+
Cli.showNetBox(left);
|
|
986
|
+
process.exit(1);
|
|
528
987
|
}
|
|
529
988
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
console.log(
|
|
533
|
-
for (const
|
|
534
|
-
|
|
989
|
+
/** Each change NetBox would get, then how many of each kind. */
|
|
990
|
+
private static showNetBox(plan: NetBoxPlan): void {
|
|
991
|
+
for (const item of plan.leftAlone) console.log(` ${item} exists without the tag ${NetBox.TAG}: left alone`);
|
|
992
|
+
for (const change of plan.creates) console.log(` + ${change.path} ${change.key}`);
|
|
993
|
+
|
|
994
|
+
for (const change of plan.updates) {
|
|
995
|
+
console.log(` ~ ${change.path} ${change.key}`);
|
|
996
|
+
for (const line of change.diff) console.log(` ${line}`);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
for (const change of plan.deletes) console.log(` - ${change.path} ${change.key}`);
|
|
1000
|
+
|
|
1001
|
+
const paths = [...new Set([...plan.creates, ...plan.updates, ...plan.deletes].map((change) => change.path))];
|
|
1002
|
+
const count = (changes: readonly { path: string }[], path: string) => changes.filter((change) => change.path === path).length;
|
|
1003
|
+
|
|
1004
|
+
if (paths.length) console.log("");
|
|
1005
|
+
|
|
1006
|
+
for (const path of paths) {
|
|
1007
|
+
console.log(
|
|
1008
|
+
` ${path}: ${count(plan.creates, path)} to create, ${count(plan.updates, path)} to change, ${count(plan.deletes, path)} to delete`
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
535
1011
|
}
|
|
536
1012
|
|
|
537
|
-
|
|
538
|
-
|
|
1013
|
+
private prometheus(auths: boolean): void {
|
|
1014
|
+
if (auths) {
|
|
1015
|
+
const { yaml, notes } = Prometheus.snmpExporterAuths(this.network);
|
|
1016
|
+
for (const note of notes) console.error(note);
|
|
1017
|
+
process.stdout.write(yaml);
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
const { targets, withoutSnmp } = Prometheus.targets(this.network);
|
|
1022
|
+
for (const device of withoutSnmp) console.error(`${device.name} has no SNMP in its config; left out`);
|
|
1023
|
+
console.log(JSON.stringify(targets, null, 4));
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
private secrets(): void {
|
|
1027
|
+
const references = [
|
|
1028
|
+
...Secrets.referencesIn([this.network.devices, this.network.peeringdb, this.network.librenms, this.network.netbox]),
|
|
1029
|
+
].sort();
|
|
1030
|
+
let missing = 0;
|
|
1031
|
+
|
|
1032
|
+
for (const reference of references) {
|
|
1033
|
+
const name = reference.replace(/^file:/, "");
|
|
1034
|
+
|
|
1035
|
+
try {
|
|
1036
|
+
Secrets.reveal(reference);
|
|
1037
|
+
console.log(` ok ${name}, from ${Secrets.sourceOf(reference)}`);
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
missing++;
|
|
1040
|
+
console.log(` missing ${name}: ${(error as Error).message}`);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
console.log(`\n${references.length} secret(s), ${missing} missing.`);
|
|
1045
|
+
process.exit(missing ? 1 : 0);
|
|
1046
|
+
}
|
|
539
1047
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
1048
|
+
/** How a device compares with its last snapshot, offline, as `diff` plans it. */
|
|
1049
|
+
private driftOf(device: Device): Drift {
|
|
1050
|
+
const path = this.statePath(device);
|
|
1051
|
+
if (!existsSync(path)) return { status: "no snapshot", lines: [] };
|
|
543
1052
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(to)) fail(`${to} is not a hostname: letters, digits, dots, dashes and underscores`);
|
|
1053
|
+
try {
|
|
1054
|
+
const adapter = this.adapterOf(device);
|
|
1055
|
+
const plan = adapter.plan(adapter.render(device), readFileSync(path, "utf8"), this.options);
|
|
548
1056
|
|
|
549
|
-
|
|
550
|
-
|
|
1057
|
+
const lines = [
|
|
1058
|
+
...plan.steps.flatMap((step) => [step.title, ...step.show.map((line) => ` ${line}`)]),
|
|
1059
|
+
...plan.problems.map((problem) => `PROBLEM ${problem}`),
|
|
1060
|
+
];
|
|
551
1061
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
fail((error as Error).message);
|
|
1062
|
+
return { status: lines.length ? "differs" : "matches", lines };
|
|
1063
|
+
} catch (error) {
|
|
1064
|
+
return { status: "cannot plan", lines: [(error as Error).message] };
|
|
1065
|
+
}
|
|
557
1066
|
}
|
|
558
1067
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
1068
|
+
/** The graph on these layers, around these devices when any are named, with what validate found and how each device drawn compares with its snapshot. */
|
|
1069
|
+
private annotatedGraph(layers: readonly Layer[], deviceNames: readonly string[]): Annotated {
|
|
1070
|
+
const graph = Topology.only(Topology.of(this.network.devices), layers, deviceNames);
|
|
1071
|
+
const shown = new Set(graph.nodes.map((node) => node.label));
|
|
1072
|
+
|
|
1073
|
+
return {
|
|
1074
|
+
...graph,
|
|
1075
|
+
findings: this.findings().filter((finding) => !finding.device || shown.has(finding.device)),
|
|
1076
|
+
drift: Object.fromEntries(
|
|
1077
|
+
this.network.devices.filter((device) => shown.has(device.name)).map((device) => [device.name, this.driftOf(device)])
|
|
1078
|
+
),
|
|
1079
|
+
};
|
|
563
1080
|
}
|
|
564
1081
|
|
|
565
|
-
|
|
1082
|
+
private graph(names: string[], layer: string, format: string): void {
|
|
1083
|
+
const requested = layer.split(",");
|
|
1084
|
+
const layers = requested.includes("all") ? [...Topology.LAYERS] : (requested as Layer[]);
|
|
566
1085
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
1086
|
+
const unknown = layers.filter((layer) => !Topology.LAYERS.includes(layer));
|
|
1087
|
+
if (unknown.length) Cli.fail(`no layer ${unknown.join(", ")}; known: ${Topology.LAYERS.join(", ")}, all`);
|
|
1088
|
+
|
|
1089
|
+
const devices = this.selected(names, true);
|
|
1090
|
+
const annotated = this.annotatedGraph(layers, names.length ? devices.map((device) => device.name) : []);
|
|
1091
|
+
|
|
1092
|
+
if (format === "mermaid") process.stdout.write(GraphFormat.mermaid(annotated));
|
|
1093
|
+
else if (format === "dot") process.stdout.write(GraphFormat.dot(annotated));
|
|
1094
|
+
else if (format === "json") console.log(JSON.stringify(annotated, null, 4));
|
|
1095
|
+
else Cli.fail(`no format ${format}; known: mermaid, dot, json`);
|
|
570
1096
|
}
|
|
571
1097
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
1098
|
+
/** One segment and what uses each address in it. */
|
|
1099
|
+
private static printSegment(segment: Segment, indent: string): void {
|
|
1100
|
+
const next = segment.next ? `, next ${segment.next}` : "";
|
|
1101
|
+
console.log(`${indent}${segment.prefix}, ${segment.free} free${next}`);
|
|
1102
|
+
|
|
1103
|
+
const width = Math.max(...segment.uses.map((use) => use.address.length)) + 2;
|
|
1104
|
+
for (const use of segment.uses) console.log(`${indent} ${use.address.padEnd(width)}${use.what}`);
|
|
575
1105
|
}
|
|
576
1106
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
}
|
|
1107
|
+
private addresses(within: string | undefined, lengthText: string | undefined, format: string | undefined): void {
|
|
1108
|
+
if (within && !Address.isCidr(within)) Cli.fail(`${within} is not a prefix, such as 10.0.0.0/16`);
|
|
580
1109
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
1110
|
+
const length = lengthText === undefined ? undefined : Number(lengthText);
|
|
1111
|
+
if (length !== undefined && !(Number.isInteger(length) && length >= 0 && length <= 128))
|
|
1112
|
+
Cli.fail("--length takes a prefix length, such as 24");
|
|
1113
|
+
|
|
1114
|
+
const report = AddressSpace.report(this.network.devices, within);
|
|
1115
|
+
|
|
1116
|
+
if (format === "json") {
|
|
1117
|
+
console.log(JSON.stringify(report, null, 4));
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
for (const space of report.spaces) {
|
|
1122
|
+
console.log(`${space.prefix}, ${space.owner}`);
|
|
1123
|
+
for (const segment of space.segments) Cli.printSegment(segment, " ");
|
|
1124
|
+
for (const block of space.blocks) console.log(` ${block.prefix} ${block.what}`);
|
|
1125
|
+
|
|
1126
|
+
if (length === undefined) {
|
|
1127
|
+
console.log(` free: ${space.free.join(", ") || "none"}`);
|
|
1128
|
+
} else {
|
|
1129
|
+
const { prefixes, total } = AddressSpace.freeOfLength(space.free, length, 16);
|
|
1130
|
+
const more = total > BigInt(prefixes.length) ? `, and ${total - BigInt(prefixes.length)} more` : "";
|
|
1131
|
+
console.log(` free /${length}: ${prefixes.join(", ") || "none"}${more}`);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
console.log("");
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
const { segments, blocks } = report.elsewhere;
|
|
1138
|
+
if (segments.length || blocks.length) {
|
|
1139
|
+
console.log("Outside the space the network originates");
|
|
1140
|
+
for (const segment of segments) Cli.printSegment(segment, " ");
|
|
1141
|
+
for (const block of blocks) console.log(` ${block.prefix} ${block.what}`);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
if (!report.spaces.length && !segments.length && !blocks.length) console.log("The config holds no addresses.");
|
|
595
1145
|
}
|
|
596
1146
|
|
|
597
|
-
|
|
598
|
-
|
|
1147
|
+
private rename(from: string, to: string, file: boolean, confirmed: boolean): void {
|
|
1148
|
+
const device = this.network.devices.find((candidate) => candidate.name === from);
|
|
1149
|
+
if (!device) Cli.fail(`no device ${from}; known: ${this.network.devices.map((candidate) => candidate.name).join(", ")}`);
|
|
1150
|
+
if (this.network.devices.some((candidate) => candidate.name === to)) Cli.fail(`${to} is already a device`);
|
|
1151
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(to)) Cli.fail(`${to} is not a hostname: letters, digits, dots, dashes and underscores`);
|
|
1152
|
+
|
|
1153
|
+
const files = [this.statePath(device), Cabling.path(this.stateDirectory, device.name)];
|
|
1154
|
+
const snapshots = files.filter((file) => existsSync(file)).map((file) => relative(this.root, file));
|
|
1155
|
+
|
|
1156
|
+
let plan: RenamePlan;
|
|
1157
|
+
try {
|
|
1158
|
+
plan = Rename.plan(this.root, from, to, { file, snapshots });
|
|
1159
|
+
} catch (error) {
|
|
1160
|
+
Cli.fail((error as Error).message);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
for (const change of plan.changes) {
|
|
1164
|
+
console.log(`${change.file}:${change.line}`);
|
|
1165
|
+
console.log(` - ${change.before.trim()}`);
|
|
1166
|
+
console.log(` + ${change.after.trim()}`);
|
|
1167
|
+
}
|
|
599
1168
|
|
|
600
|
-
|
|
601
|
-
const host = flagValue("host") ?? "127.0.0.1";
|
|
602
|
-
const allowHosts = (flagValue("allow-host") ?? "").split(",").filter(Boolean);
|
|
1169
|
+
for (const move of plan.moves) console.log(`move ${move.from} to ${move.to}`);
|
|
603
1170
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
1171
|
+
if (plan.mentions.length) {
|
|
1172
|
+
console.log(`\nStill naming ${from}, left as they are: change them by hand if they mean the device.`);
|
|
1173
|
+
for (const mention of plan.mentions) console.log(` ${mention.file}:${mention.line} ${mention.text.slice(0, 160)}`);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
if (!confirmed) {
|
|
1177
|
+
console.log("\nNothing written. Re-run with --confirm.");
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
Rename.apply(this.root, plan);
|
|
1182
|
+
console.log(`\nRenamed. Run circuit validate, then circuit apply ${to}: the device keeps its old hostname until then.`);
|
|
607
1183
|
}
|
|
608
1184
|
|
|
609
|
-
|
|
1185
|
+
private async studio(flags: { port: string; open: boolean; host: string; allowHost?: string; json?: true }): Promise<void> {
|
|
1186
|
+
if (flags.json) {
|
|
1187
|
+
const sources = ProjectFiles.sources(this.root);
|
|
1188
|
+
const data = StudioModel.build(this.network.devices, this.annotatedGraph(Topology.LAYERS, []), {
|
|
1189
|
+
render: (device) => this.adapterOf(device).render(device),
|
|
1190
|
+
snapshot: (device) => (existsSync(this.statePath(device)) ? readFileSync(this.statePath(device), "utf8") : undefined),
|
|
1191
|
+
files: (device) => ({
|
|
1192
|
+
// Written with forward slashes on every system, as git and the page take them.
|
|
1193
|
+
source: ProjectFiles.deviceFiles(this.root, device.name, sources)[0]?.split(sep).join("/"),
|
|
1194
|
+
snapshot: existsSync(this.statePath(device))
|
|
1195
|
+
? relative(this.root, this.statePath(device)).split(sep).join("/")
|
|
1196
|
+
: undefined,
|
|
1197
|
+
}),
|
|
1198
|
+
});
|
|
1199
|
+
console.log(JSON.stringify(data));
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
const port = Number(flags.port);
|
|
1204
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) Cli.fail("--port takes a number from 1 to 65535");
|
|
1205
|
+
|
|
1206
|
+
const { StudioServer } = await import("../studio/server");
|
|
1207
|
+
const host = flags.host;
|
|
1208
|
+
const allowHosts = (flags.allowHost ?? "").split(",").filter(Boolean);
|
|
1209
|
+
|
|
1210
|
+
// Off this machine, Studio must be told the names it is reached by: every other name is refused.
|
|
1211
|
+
if (!StudioServer.isLoopback(host) && !allowHosts.length) {
|
|
1212
|
+
Cli.fail(`Studio on ${host} needs --allow-host=<name>, the name people reach it by, such as studio.example.com`);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
await StudioServer.start({
|
|
1216
|
+
configPath: this.configPath,
|
|
1217
|
+
root: this.root,
|
|
1218
|
+
port,
|
|
1219
|
+
open: flags.open,
|
|
1220
|
+
host,
|
|
1221
|
+
allowHosts,
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
610
1224
|
}
|
|
611
1225
|
|
|
612
|
-
|
|
613
|
-
validate: validateCommand,
|
|
614
|
-
build: buildCommand,
|
|
615
|
-
diff: diffCommand,
|
|
616
|
-
snapshot: snapshotCommand,
|
|
617
|
-
apply: applyCommand,
|
|
618
|
-
refresh: refreshCommand,
|
|
619
|
-
wireguard: wireguardCommand,
|
|
620
|
-
communities: communitiesCommand,
|
|
621
|
-
peeringdb: peeringdbCommand,
|
|
622
|
-
librenms: librenmsCommand,
|
|
623
|
-
prometheus: prometheusCommand,
|
|
624
|
-
secrets: secretsCommand,
|
|
625
|
-
rename: renameCommand,
|
|
626
|
-
addresses: addressesCommand,
|
|
627
|
-
graph: graphCommand,
|
|
628
|
-
studio: studioCommand,
|
|
629
|
-
};
|
|
630
|
-
|
|
631
|
-
const run = commands[command];
|
|
632
|
-
if (run) await run();
|
|
633
|
-
else
|
|
634
|
-
fail(`no command ${command}
|
|
635
|
-
|
|
636
|
-
${HELP}`);
|
|
1226
|
+
await Cli.main(process.argv.slice(2));
|