@takodotid/circuit 0.34.3 → 0.36.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.
Files changed (240) hide show
  1. package/README.md +14 -15
  2. package/dist/chunks/index-20kcjxjg.js +174 -0
  3. package/dist/chunks/index-2r4bbhac.js +977 -0
  4. package/dist/chunks/index-n7w3tg5y.js +149 -0
  5. package/dist/chunks/new-w3zys5y4.js +172 -0
  6. package/dist/chunks/server-gzsf5me5.js +259 -0
  7. package/dist/cli/index.js +11345 -9024
  8. package/dist/helpers/index.js +594 -0
  9. package/dist/index.js +9 -28
  10. package/dist/types/core/{addresses.d.ts → address-space.d.ts} +23 -7
  11. package/dist/types/core/address.d.ts +44 -0
  12. package/dist/types/core/cabling.d.ts +41 -0
  13. package/dist/types/core/cross-checks.d.ts +28 -0
  14. package/dist/types/core/define.d.ts +25 -3
  15. package/dist/types/core/project-files.d.ts +12 -0
  16. package/dist/types/core/schema-docs.d.ts +13 -4
  17. package/dist/types/core/secrets.d.ts +62 -21
  18. package/dist/types/core/{graph.d.ts → topology.d.ts} +32 -12
  19. package/dist/types/core/utils.d.ts +8 -0
  20. package/dist/types/core/validator.d.ts +19 -0
  21. package/dist/types/helpers/bgp.d.ts +97 -0
  22. package/dist/types/helpers/checks.d.ts +32 -0
  23. package/dist/types/helpers/exchange.d.ts +49 -0
  24. package/dist/types/helpers/filters.d.ts +51 -0
  25. package/dist/types/helpers/index.d.ts +10 -0
  26. package/dist/types/helpers/route-server.d.ts +52 -0
  27. package/dist/types/index.d.ts +5 -3
  28. package/dist/types/integrations/librenms.d.ts +60 -0
  29. package/dist/types/integrations/netbox.d.ts +89 -0
  30. package/dist/types/integrations/peeringdb.d.ts +24 -0
  31. package/dist/types/integrations/prometheus.d.ts +24 -0
  32. package/dist/types/integrations/registries.d.ts +8 -0
  33. package/dist/types/platforms/bgp-wait.d.ts +18 -0
  34. package/dist/types/platforms/catalog.d.ts +109 -0
  35. package/dist/types/platforms/derive.d.ts +17 -0
  36. package/dist/types/platforms/fortios/firewall.d.ts +45 -0
  37. package/dist/types/platforms/fortios/index.d.ts +134 -0
  38. package/dist/types/platforms/fortios/names.d.ts +52 -0
  39. package/dist/types/platforms/fortios/plan.d.ts +104 -0
  40. package/dist/types/platforms/fortios/render.d.ts +75 -0
  41. package/dist/types/platforms/fortios/routing.d.ts +56 -0
  42. package/dist/types/platforms/fortios/tree.d.ts +37 -0
  43. package/dist/types/platforms/index.d.ts +13 -0
  44. package/dist/types/platforms/junos/bgp.d.ts +44 -0
  45. package/dist/types/platforms/junos/index.d.ts +114 -0
  46. package/dist/types/platforms/junos/render.d.ts +154 -0
  47. package/dist/types/platforms/line-diff.d.ts +67 -0
  48. package/dist/types/platforms/platform.d.ts +120 -0
  49. package/dist/types/platforms/raisecom-ros/index.d.ts +71 -0
  50. package/dist/types/platforms/raisecom-ros/render.d.ts +41 -0
  51. package/dist/types/platforms/routeros/export.d.ts +59 -0
  52. package/dist/types/platforms/routeros/index.d.ts +96 -0
  53. package/dist/types/{adapters/devices → platforms}/routeros/models.d.ts +4 -0
  54. package/dist/types/platforms/routeros/plan.d.ts +54 -0
  55. package/dist/types/platforms/routeros/render.d.ts +240 -0
  56. package/dist/types/platforms/vrp/index.d.ts +83 -0
  57. package/dist/types/platforms/vrp/render.d.ts +96 -0
  58. package/dist/types/schema/common.d.ts +12 -6
  59. package/dist/types/schema/index.d.ts +1 -0
  60. package/dist/types/schema/interfaces.d.ts +14 -6
  61. package/dist/types/schema/policy.d.ts +18 -10
  62. package/dist/types/schema/routing.d.ts +14 -1
  63. package/dist/types/schema/system.d.ts +16 -6
  64. package/dist/types/schema/time-zones.d.ts +2 -0
  65. package/dist/types/transport/console.d.ts +79 -23
  66. package/dist/types/transport/path-check.d.ts +32 -0
  67. package/dist/types/transport/ssh.d.ts +17 -15
  68. package/package.json +18 -12
  69. package/src/cli/graph-format.ts +91 -0
  70. package/src/cli/index.ts +1074 -484
  71. package/src/cli/new.ts +208 -175
  72. package/src/cli/rename.ts +97 -91
  73. package/src/core/address-space.ts +320 -0
  74. package/src/core/address.ts +202 -0
  75. package/src/core/cabling.ts +154 -0
  76. package/src/core/cross-checks.ts +364 -0
  77. package/src/core/define.ts +26 -3
  78. package/src/core/project-files.ts +35 -0
  79. package/src/core/schema-docs.ts +138 -130
  80. package/src/core/secrets.ts +170 -76
  81. package/src/core/topology.ts +656 -0
  82. package/src/core/utils.ts +37 -0
  83. package/src/core/validator.ts +407 -0
  84. package/src/helpers/bgp.ts +301 -0
  85. package/src/helpers/checks.ts +178 -0
  86. package/src/helpers/exchange.ts +117 -0
  87. package/src/helpers/filters.ts +107 -0
  88. package/src/helpers/index.ts +12 -0
  89. package/src/helpers/route-server.ts +212 -0
  90. package/src/index.ts +6 -3
  91. package/src/integrations/librenms.ts +291 -0
  92. package/src/integrations/netbox.ts +620 -0
  93. package/src/integrations/peeringdb.ts +173 -0
  94. package/src/integrations/prometheus.ts +90 -0
  95. package/src/integrations/registries.ts +25 -0
  96. package/src/platforms/bgp-wait.ts +41 -0
  97. package/src/platforms/catalog.ts +69 -0
  98. package/src/platforms/derive.ts +117 -0
  99. package/src/{adapters/devices → platforms}/fortios/TRAPS.md +6 -0
  100. package/src/platforms/fortios/firewall.ts +377 -0
  101. package/src/platforms/fortios/index.ts +1060 -0
  102. package/src/platforms/fortios/names.ts +130 -0
  103. package/src/platforms/fortios/plan.ts +929 -0
  104. package/src/platforms/fortios/render.ts +779 -0
  105. package/src/platforms/fortios/routing.ts +476 -0
  106. package/src/platforms/fortios/tree.ts +129 -0
  107. package/src/platforms/index.ts +36 -0
  108. package/src/{adapters/devices → platforms}/junos/TRAPS.md +8 -0
  109. package/src/platforms/junos/bgp.ts +293 -0
  110. package/src/platforms/junos/index.ts +1006 -0
  111. package/src/platforms/junos/render.ts +967 -0
  112. package/src/platforms/line-diff.ts +244 -0
  113. package/src/platforms/platform.ts +122 -0
  114. package/src/platforms/raisecom-ros/TRAPS.md +40 -0
  115. package/src/platforms/raisecom-ros/index.ts +731 -0
  116. package/src/platforms/raisecom-ros/render.ts +328 -0
  117. package/src/{adapters/devices → platforms}/routeros/TRAPS.md +16 -2
  118. package/src/platforms/routeros/export.ts +342 -0
  119. package/src/platforms/routeros/index.ts +636 -0
  120. package/src/{adapters/devices → platforms}/routeros/models.ts +2 -0
  121. package/src/platforms/routeros/plan.ts +444 -0
  122. package/src/platforms/routeros/render.ts +1483 -0
  123. package/src/platforms/vrp/TRAPS.md +45 -0
  124. package/src/platforms/vrp/index.ts +652 -0
  125. package/src/platforms/vrp/render.ts +615 -0
  126. package/src/schema/common.ts +15 -6
  127. package/src/schema/index.ts +1 -0
  128. package/src/schema/interfaces.ts +15 -5
  129. package/src/schema/policy.ts +22 -10
  130. package/src/schema/routing.ts +16 -1
  131. package/src/schema/system.ts +16 -6
  132. package/src/schema/time-zones.ts +482 -0
  133. package/src/studio/data.ts +172 -168
  134. package/src/studio/git.ts +95 -90
  135. package/src/studio/public/app.js +161 -150
  136. package/src/studio/public/compare.js +219 -212
  137. package/src/studio/public/diff.js +77 -75
  138. package/src/studio/public/dom.js +118 -108
  139. package/src/studio/public/evaluate.js +584 -545
  140. package/src/studio/public/export.js +120 -118
  141. package/src/studio/public/graph.js +1343 -1266
  142. package/src/studio/public/net.js +115 -109
  143. package/src/studio/public/tables.js +1135 -1083
  144. package/src/studio/public/tools.js +561 -529
  145. package/src/studio/server.ts +202 -196
  146. package/src/transport/console.ts +363 -271
  147. package/src/transport/path-check.ts +91 -0
  148. package/src/transport/ssh.ts +98 -94
  149. package/templates/colocation/checks.ts +11 -6
  150. package/templates/colocation/edge-01.ts +9 -9
  151. package/templates/colocation/routing.ts +2 -2
  152. package/templates/colocation/tenancy.ts +3 -3
  153. package/templates/colocation/tor-01.ts +3 -3
  154. package/templates/edge-router/checks.ts +4 -4
  155. package/templates/edge-router/edge-01.ts +5 -5
  156. package/templates/edge-router/routing.ts +2 -2
  157. package/templates/project/AGENTS.md +5 -1
  158. package/templates/single-site/checks.ts +2 -2
  159. package/dist/chunks/index-47debt16.js +0 -956
  160. package/dist/chunks/index-v8zgr7wm.js +0 -128
  161. package/dist/chunks/new-ny89s7y7.js +0 -166
  162. package/dist/chunks/server-tjbb3rqc.js +0 -251
  163. package/dist/presets/index.js +0 -394
  164. package/dist/types/adapters/devices/catalog.d.ts +0 -86
  165. package/dist/types/adapters/devices/derive.d.ts +0 -7
  166. package/dist/types/adapters/devices/fortios/firewall.d.ts +0 -23
  167. package/dist/types/adapters/devices/fortios/index.d.ts +0 -58
  168. package/dist/types/adapters/devices/fortios/names.d.ts +0 -48
  169. package/dist/types/adapters/devices/fortios/plan.d.ts +0 -28
  170. package/dist/types/adapters/devices/fortios/render.d.ts +0 -38
  171. package/dist/types/adapters/devices/fortios/routing.d.ts +0 -32
  172. package/dist/types/adapters/devices/fortios/tree.d.ts +0 -33
  173. package/dist/types/adapters/devices/index.d.ts +0 -3
  174. package/dist/types/adapters/devices/junos/bgp.d.ts +0 -11
  175. package/dist/types/adapters/devices/junos/index.d.ts +0 -42
  176. package/dist/types/adapters/devices/junos/render.d.ts +0 -52
  177. package/dist/types/adapters/devices/lines.d.ts +0 -56
  178. package/dist/types/adapters/devices/raisecom-ros/index.d.ts +0 -28
  179. package/dist/types/adapters/devices/routeros/export.d.ts +0 -39
  180. package/dist/types/adapters/devices/routeros/index.d.ts +0 -30
  181. package/dist/types/adapters/devices/routeros/plan.d.ts +0 -4
  182. package/dist/types/adapters/devices/routeros/render.d.ts +0 -100
  183. package/dist/types/adapters/devices/types.d.ts +0 -40
  184. package/dist/types/adapters/devices/vrp/index.d.ts +0 -27
  185. package/dist/types/adapters/registries/index.d.ts +0 -3
  186. package/dist/types/adapters/registries/librenms.d.ts +0 -15
  187. package/dist/types/adapters/registries/peeringdb.d.ts +0 -16
  188. package/dist/types/adapters/registries/prometheus.d.ts +0 -16
  189. package/dist/types/core/across.d.ts +0 -4
  190. package/dist/types/core/addr.d.ts +0 -34
  191. package/dist/types/core/files.d.ts +0 -6
  192. package/dist/types/core/merge.d.ts +0 -4
  193. package/dist/types/core/time.d.ts +0 -2
  194. package/dist/types/core/validate.d.ts +0 -2
  195. package/dist/types/presets/index.d.ts +0 -183
  196. package/src/adapters/devices/catalog.ts +0 -41
  197. package/src/adapters/devices/derive.ts +0 -59
  198. package/src/adapters/devices/fortios/firewall.ts +0 -362
  199. package/src/adapters/devices/fortios/index.ts +0 -923
  200. package/src/adapters/devices/fortios/names.ts +0 -115
  201. package/src/adapters/devices/fortios/plan.ts +0 -888
  202. package/src/adapters/devices/fortios/render.ts +0 -768
  203. package/src/adapters/devices/fortios/routing.ts +0 -444
  204. package/src/adapters/devices/fortios/tree.ts +0 -121
  205. package/src/adapters/devices/index.ts +0 -9
  206. package/src/adapters/devices/junos/bgp.ts +0 -269
  207. package/src/adapters/devices/junos/index.ts +0 -862
  208. package/src/adapters/devices/junos/render.ts +0 -872
  209. package/src/adapters/devices/lines.ts +0 -212
  210. package/src/adapters/devices/raisecom-ros/TRAPS.md +0 -26
  211. package/src/adapters/devices/raisecom-ros/index.ts +0 -700
  212. package/src/adapters/devices/routeros/export.ts +0 -321
  213. package/src/adapters/devices/routeros/index.ts +0 -346
  214. package/src/adapters/devices/routeros/plan.ts +0 -398
  215. package/src/adapters/devices/routeros/render.ts +0 -1490
  216. package/src/adapters/devices/types.ts +0 -38
  217. package/src/adapters/devices/vrp/TRAPS.md +0 -26
  218. package/src/adapters/devices/vrp/index.ts +0 -865
  219. package/src/adapters/registries/index.ts +0 -19
  220. package/src/adapters/registries/librenms.ts +0 -92
  221. package/src/adapters/registries/peeringdb.ts +0 -169
  222. package/src/adapters/registries/prometheus.ts +0 -71
  223. package/src/cli/graph.ts +0 -89
  224. package/src/core/across.ts +0 -350
  225. package/src/core/addr.ts +0 -174
  226. package/src/core/addresses.ts +0 -316
  227. package/src/core/files.ts +0 -32
  228. package/src/core/graph.ts +0 -640
  229. package/src/core/merge.ts +0 -19
  230. package/src/core/time.ts +0 -15
  231. package/src/core/validate.ts +0 -348
  232. package/src/presets/index.ts +0 -579
  233. /package/dist/types/{adapters/devices → platforms}/fortios/models.d.ts +0 -0
  234. /package/dist/types/{adapters/devices → platforms}/junos/models.d.ts +0 -0
  235. /package/dist/types/{adapters/devices → platforms}/raisecom-ros/models.d.ts +0 -0
  236. /package/dist/types/{adapters/devices → platforms}/vrp/models.d.ts +0 -0
  237. /package/src/{adapters/devices → platforms}/fortios/models.ts +0 -0
  238. /package/src/{adapters/devices → platforms}/junos/models.ts +0 -0
  239. /package/src/{adapters/devices → platforms}/raisecom-ros/models.ts +0 -0
  240. /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 { adapters } from "../adapters/devices";
8
- import type { ApplyOptions, DeviceAdapter, Plan } from "../adapters/devices/types";
9
- import { registries } from "../adapters/registries";
10
- import { planLibreNms, sendLibreNms } from "../adapters/registries/librenms";
11
- import { planPeeringDb, sendPeeringDb } from "../adapters/registries/peeringdb";
12
- import { prometheusTargets, snmpExporterAuths } from "../adapters/registries/prometheus";
13
- import { isCidr, network as prefixOf } from "../core/addr";
14
- import { addressesOf, freeOfLength, type Segment } from "../core/addresses";
15
- import type { Network } from "../core/define";
16
- import { type Annotated, type Drift, graphOf, LAYERS, only, type Layer } from "../core/graph";
17
- import { isSecret, referenceOf, resolve as reveal, sourceOf, useRoot } from "../core/secrets";
18
- import { validate } from "../core/validate";
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 { dot, mermaid } from "./graph";
21
- import { applyRename, planRename, type RenamePlan } from "./rename";
22
- import { deviceFiles, projectSources } from "../core/files";
23
- import { studioData } from "../studio/data";
24
-
25
- const HELP = `usage: circuit <command> [device...] [options]
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
- if (!command || command === "help" || command === "--help" || command === "-h") {
75
- console.log(HELP);
76
- process.exit(0);
77
- }
32
+ const GENERATED_README = `# Written by Circuit
78
33
 
79
- // `--config <path>` and `--config=<path>` both name the config file; neither is a device name.
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
- const configPath = resolve(process.cwd(), configArgument ?? DEFAULT_CONFIG);
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
- if (!existsSync(configPath)) {
87
- console.error(
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
- /** The directory of the config file. `.circuit/`, `.env` and secret files are found from here. */
94
- const root = dirname(configPath);
95
- useRoot(root);
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
- /** The config, a TypeScript file. Bun imports it as it is; Node needs jiti, which also resolves imports written without an extension. */
98
- async function load(path: string): Promise<Network> {
99
- if (process.versions.bun) return (await import(path)).default;
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
- const { createJiti } = await import("jiti");
102
- return createJiti(import.meta.url).import(path, { default: true });
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
- const network = await load(configPath);
79
+ await program.parseAsync(argv, { from: "user" });
80
+ }
106
81
 
107
- const flags = rest.filter((arg) => arg.startsWith("--"));
108
- const names = rest.filter((arg) => !arg.startsWith("--"));
109
- const confirmed = flags.includes("--confirm");
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
- function fail(message: string): never {
117
- console.error(message);
118
- process.exit(1);
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
- /** The devices named on the command line, or every device when none is and `allByDefault`. */
122
- function selected(allByDefault: boolean): Device[] {
123
- if (!names.length && allByDefault) return [...network.devices];
94
+ Secrets.useRoot(dirname(configPath));
124
95
 
125
- const known = network.devices.map((device) => device.name).join(", ");
126
- const byName = (name: string) => network.devices.find((device) => device.name === name);
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
- return names.map((name) => byName(name) ?? fail(`no device ${name}; known: ${known}`));
129
- }
103
+ return new Cli(configPath, network, options);
104
+ }
130
105
 
131
- function one(): Device {
132
- const [device] = selected(false);
133
- return device ?? fail("name a device");
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
- /** How a device is reached: through its console when `--console` asks, or when it has no management address. */
137
- function optionsFor(device: Device): ApplyOptions {
138
- return options.console || !device.connection.host ? { ...options, console: true } : options;
139
- }
110
+ const { createJiti } = await import("jiti");
111
+ return createJiti(import.meta.url).import(path, { default: true });
112
+ }
140
113
 
141
- /** The adapter of a device, which must be able to use its console when the device is reached through it. */
142
- function adapterOf(device: Device): DeviceAdapter {
143
- const adapter = adapters[device.platform] ?? fail(`no adapter for ${device.platform}`);
144
- if (!optionsFor(device).console) return adapter;
114
+ private static fail(message: string): never {
115
+ console.error(message);
116
+ process.exit(1);
117
+ }
145
118
 
146
- if (!adapter.console) fail(`${device.name}: ${device.platform} cannot be reached through a console yet`);
147
- if (!device.connection.console) fail(`${device.name}: --console needs connection.console`);
148
- return adapter;
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
- const GENERATED_README = `# Written by Circuit
124
+ return minutes;
125
+ }
156
126
 
157
- Do not edit anything here: an edit changes nothing on a device, and Circuit overwrites it. Change the config instead.
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
- \`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.
160
- `;
132
+ return seconds;
133
+ }
161
134
 
162
- /** Record what a device runs, beside a note that the directory is generated. */
163
- function writeState(device: Device, text: string): void {
164
- mkdirSync(stateDirectory, { recursive: true });
165
- writeFileSync(join(generated, "README.md"), GENERATED_README);
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
- /** Validate, print what was found, and stop on an error about one of these devices. */
170
- function guard(devices: Device[]): void {
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
- for (const finding of findings) {
175
- const where = finding.device ? `${finding.device}: ` : "";
176
- const level = finding.level === "error" ? "error" : "warn ";
177
- console.log(` ${level} ${where}${finding.message}`);
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
- const errors = findings.filter((finding) => finding.level === "error").length;
181
- if (errors) fail(`\n${errors} error(s).`);
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
- /** Print a plan. Returns how many commands and problems it holds. */
185
- function show(device: Device, plan: Plan): number {
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
- for (const step of plan.steps) {
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
- for (const problem of plan.problems) console.log(` PROBLEM ${problem}`);
364
+ /** The device with this name. */
365
+ private one(name: string): Device {
366
+ return this.selected([name], false)[0]!;
367
+ }
196
368
 
197
- return commands + plan.problems.length;
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
- function validateCommand(): void {
201
- guard([...network.devices]);
202
- console.log(`${network.devices.length} device(s), no errors.`);
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
- function buildCommand(): void {
206
- const device = one();
207
- process.stdout.write(adapterOf(device).render(device));
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
- function diffCommand(): void {
211
- const devices = selected(true);
212
- guard(devices);
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
- let differences = 0;
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
- for (const device of devices) {
217
- const path = statePath(device);
218
- if (!existsSync(path)) {
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
- const adapter = adapterOf(device);
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
- process.exit(differences ? 1 : 0);
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
- async function snapshotCommand(): Promise<void> {
232
- for (const device of selected(true)) {
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
- // With every device read, a snapshot of a device the config no longer has, such as one renamed, is removed.
239
- if (names.length || !existsSync(stateDirectory)) return;
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
- const current = new Set(network.devices.map((device) => basename(statePath(device))));
242
- for (const file of readdirSync(stateDirectory)) {
243
- if (current.has(file)) continue;
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
- rmSync(join(stateDirectory, file));
246
- console.log(`${file}: removed, no such device in the config`);
440
+ return commands + plan.problems.length;
247
441
  }
248
- }
249
442
 
250
- async function applyCommand(): Promise<void> {
251
- const device = one();
252
- guard([device]);
253
- const adapter = adapterOf(device);
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
- // The plan is made against what the device runs now, never against an old snapshot.
256
- const live = await adapter.read(device, network.devices, optionsFor(device));
257
- writeState(device, live);
453
+ private diff(names: string[]): void {
454
+ const devices = this.selected(names, true);
455
+ this.guard(devices);
258
456
 
259
- const plan = adapter.plan(adapter.render(device), live, options);
260
- if (!show(device, plan)) return;
457
+ let differences = 0;
261
458
 
262
- if (plan.problems.length) fail("\nNot sent: resolve the problems first.");
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
- if (!confirmed) {
265
- console.log("\nNothing sent. Re-run with --confirm.");
266
- return;
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
- await adapter.apply(device, network.devices, plan, optionsFor(device));
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
- writeState(device, await adapter.read(device, network.devices, optionsFor(device)));
272
- console.log("snapshot updated");
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
- async function refreshCommand(): Promise<void> {
276
- const device = one();
277
- const fill = adapterOf(device).fill ?? fail(`${device.platform} cannot hold a fetched prefix set`);
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
- for (const [name, set] of Object.entries(device.prefix_sets ?? {})) {
280
- if (!("source" in set)) continue;
496
+ rmSync(join(this.stateDirectory, file));
497
+ console.log(`${file}: removed, no such device in the config`);
498
+ }
499
+ }
281
500
 
282
- const { registry, query, family } = set.source;
283
- const prefixes = await registries[registry](query, family);
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
- // An empty or absurd answer is a broken feed, not a policy.
286
- if (prefixes.length < 1 || prefixes.length > 100_000) {
287
- fail(`${name}: ${prefixes.length} prefixes from ${registry}, refusing`);
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
- console.log(`${name}: ${prefixes.length} prefixes from ${registry} ${query}`);
291
- if (confirmed) await fill(device, network.devices, name, family, prefixes);
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
- if (!confirmed) console.log("\nNothing sent. Re-run with --confirm.");
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
- /** A WireGuard public key from its private key, so the device's never has to be read off the box. */
298
- function publicKeyOf(privateKey: string): string {
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
- const key = createPrivateKey({ key: der, format: "der", type: "pkcs8" });
304
- const publicDer = createPublicKey(key).export({ format: "der", type: "spki" });
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
- return publicDer.subarray(-32).toString("base64");
307
- }
528
+ const plan = adapter.plan(adapter.render(device), live, this.options);
529
+ if (!Cli.printPlan(device, plan)) return;
308
530
 
309
- function wireguardCommand(): void {
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
- function communitiesCommand(): void {
339
- const communities = network.communities ?? fail("the network declares no communities");
533
+ if (!confirmed) {
534
+ console.log("\nNothing sent. Re-run with --confirm.");
535
+ return;
536
+ }
340
537
 
341
- for (const { community, description } of communities) {
342
- // The format splits each line at its first comma.
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
- async function peeringdbCommand(): Promise<void> {
349
- const plan = await planPeeringDb(network);
541
+ await adapter.apply(device, this.network.devices, plan, this.optionsFor(device));
350
542
 
351
- for (const name of plan.unknown) {
352
- console.log(` PeeringDB lists ${name}, which the config does not. Remove it by hand if the network left.`);
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
- for (const change of plan.changes) console.log(` ${change.summary}`);
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
- if (!plan.changes.length) {
358
- console.log("PeeringDB matches the config.");
359
- return;
555
+ console.log("no problems found");
360
556
  }
361
557
 
362
- if (!confirmed) {
363
- console.log("\nNothing sent. Re-run with --confirm.");
364
- return;
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
- await sendPeeringDb(network, plan.changes);
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
- async function librenmsCommand(): Promise<void> {
371
- const plan = await planLibreNms(network);
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
- for (const hostname of plan.unknown)
374
- console.log(` LibreNMS monitors ${hostname}, which the config does not. Remove it by hand if it is gone.`);
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
- if (!plan.additions.length) {
380
- console.log("LibreNMS monitors every device it can.");
381
- return;
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
- if (!confirmed) {
385
- console.log("\nNothing sent. Re-run with --confirm.");
386
- return;
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
- await sendLibreNms(network, plan.additions);
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
- function prometheusCommand(): void {
393
- if (flags.includes("--auths")) {
394
- const { yaml, notes } = snmpExporterAuths(network);
395
- for (const note of notes) console.error(note);
396
- process.stdout.write(yaml);
397
- return;
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
- const { targets, withoutSnmp } = prometheusTargets(network);
401
- for (const device of withoutSnmp) console.error(`${device.name} has no SNMP in its config; left out`);
402
- console.log(JSON.stringify(targets, null, 4));
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
- /** Every secret reference in a value, however deep. */
406
- function secretsIn(value: unknown, found = new Set<string>()): Set<string> {
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
- return found;
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
- function secretsCommand(): void {
415
- const references = [...secretsIn([network.devices, network.peeringdb, network.librenms])].sort();
416
- let missing = 0;
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
- for (const reference of references) {
419
- const name = reference.replace(/^file:/, "");
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
- reveal(reference);
423
- console.log(` ok ${name}, from ${sourceOf(reference)}`);
731
+ opened = await SerialConsole.open(serial, 1);
424
732
  } catch (error) {
425
- missing++;
426
- console.log(` missing ${name}: ${(error as Error).message}`);
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.log(`
431
- ${references.length} secret(s), ${missing} missing.`);
432
- process.exit(missing ? 1 : 0);
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
- /** The value of `--name=value`, or undefined when it is not given. */
436
- const flagValue = (name: string) => flags.find((flag) => flag.startsWith(`--${name}=`))?.slice(name.length + 3);
864
+ const key = createPrivateKey({ key: der, format: "der", type: "pkcs8" });
865
+ const publicDer = createPublicKey(key).export({ format: "der", type: "spki" });
437
866
 
438
- /** How a device compares with its last snapshot, offline, as `diff` plans it. */
439
- function driftOf(device: Device): Drift {
440
- const path = statePath(device);
441
- if (!existsSync(path)) return { status: "no snapshot", lines: [] };
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
- try {
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
- ...plan.steps.flatMap((step) => [step.title, ...step.show.map((line) => ` ${line}`)]),
449
- ...plan.problems.map((problem) => `PROBLEM ${problem}`),
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
- return { status: lines.length ? "differs" : "matches", lines };
453
- } catch (error) {
454
- return { status: "cannot plan", lines: [(error as Error).message] };
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
- /** 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. */
459
- function annotatedGraph(layers: readonly Layer[], deviceNames: readonly string[]): Annotated {
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
- function graphCommand(): void {
473
- const requested = (flagValue("layer") ?? "l1").split(",");
474
- const layers = requested.includes("all") ? [...LAYERS] : (requested as Layer[]);
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
- const unknown = layers.filter((layer) => !LAYERS.includes(layer));
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
- const format = flagValue("format") ?? "mermaid";
480
- const devices = selected(true);
481
- const annotated = annotatedGraph(layers, names.length ? devices.map((device) => device.name) : []);
918
+ if (!plan.changes.length) {
919
+ console.log("PeeringDB matches the config.");
920
+ return;
921
+ }
482
922
 
483
- if (format === "mermaid") process.stdout.write(mermaid(annotated));
484
- else if (format === "dot") process.stdout.write(dot(annotated));
485
- else if (format === "json") console.log(JSON.stringify(annotated, null, 4));
486
- else fail(`no format ${format}; known: mermaid, dot, json`);
487
- }
923
+ if (!confirmed) {
924
+ console.log("\nNothing sent. Re-run with --confirm.");
925
+ return;
926
+ }
488
927
 
489
- /** One segment and what uses each address in it. */
490
- function printSegment(segment: Segment, indent: string): void {
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
- const width = Math.max(...segment.uses.map((use) => use.address.length)) + 2;
495
- for (const use of segment.uses) console.log(`${indent} ${use.address.padEnd(width)}${use.what}`);
496
- }
931
+ private async librenms(confirmed: boolean): Promise<void> {
932
+ const plan = await LibreNms.plan(this.network);
497
933
 
498
- function addressesCommand(): void {
499
- const [within] = names;
500
- if (within && !isCidr(within)) fail(`${within} is not a prefix, such as 10.0.0.0/16`);
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
- const lengthText = flagValue("length");
503
- const length = lengthText === undefined ? undefined : Number(lengthText);
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
- const report = addressesOf(network.devices, within);
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
- if (flagValue("format") === "json") {
510
- console.log(JSON.stringify(report, null, 4));
511
- return;
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
- for (const space of report.spaces) {
515
- console.log(`${space.prefix}, ${space.owner}`);
516
- for (const segment of space.segments) printSegment(segment, " ");
517
- for (const block of space.blocks) console.log(` ${block.prefix} ${block.what}`);
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 (length === undefined) {
520
- console.log(` free: ${space.free.join(", ") || "none"}`);
521
- } else {
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
- console.log("");
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
- const { segments, blocks } = report.elsewhere;
531
- if (segments.length || blocks.length) {
532
- console.log("Outside the space the network originates");
533
- for (const segment of segments) printSegment(segment, " ");
534
- for (const block of blocks) console.log(` ${block.prefix} ${block.what}`);
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
- if (!report.spaces.length && !segments.length && !blocks.length) console.log("The config holds no addresses.");
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
- function renameCommand(): void {
541
- const [from, to] = names;
542
- if (!from || !to) fail("usage: circuit rename <device> <new-name> [--file] [--confirm]");
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
- const device = network.devices.find((candidate) => candidate.name === from);
545
- if (!device) fail(`no device ${from}; known: ${network.devices.map((candidate) => candidate.name).join(", ")}`);
546
- if (network.devices.some((candidate) => candidate.name === to)) fail(`${to} is already a device`);
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
- const snapshot = statePath(device);
550
- const snapshots = existsSync(snapshot) ? [relative(root, snapshot)] : [];
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
- let plan: RenamePlan;
553
- try {
554
- plan = planRename(root, from, to, { file: flags.includes("--file"), snapshots });
555
- } catch (error) {
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
- for (const change of plan.changes) {
560
- console.log(`${change.file}:${change.line}`);
561
- console.log(` - ${change.before.trim()}`);
562
- console.log(` + ${change.after.trim()}`);
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
- for (const move of plan.moves) console.log(`move ${move.from} to ${move.to}`);
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
- if (plan.mentions.length) {
568
- console.log(`\nStill naming ${from}, left as they are: change them by hand if they mean the device.`);
569
- for (const mention of plan.mentions) console.log(` ${mention.file}:${mention.line} ${mention.text.slice(0, 160)}`);
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
- if (!confirmed) {
573
- console.log("\nNothing written. Re-run with --confirm.");
574
- return;
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
- applyRename(root, plan);
578
- console.log(`\nRenamed. Run circuit validate, then circuit apply ${to}: the device keeps its old hostname until then.`);
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
- async function studioCommand(): Promise<void> {
582
- if (flags.includes("--json")) {
583
- const sources = projectSources(root);
584
- const data = studioData(network.devices, annotatedGraph(LAYERS, []), {
585
- render: (device) => adapterOf(device).render(device),
586
- snapshot: (device) => (existsSync(statePath(device)) ? readFileSync(statePath(device), "utf8") : undefined),
587
- files: (device) => ({
588
- // Written with forward slashes on every system, as git and the page take them.
589
- source: deviceFiles(root, device.name, sources)[0]?.split(sep).join("/"),
590
- snapshot: existsSync(statePath(device)) ? relative(root, statePath(device)).split(sep).join("/") : undefined,
591
- }),
592
- });
593
- console.log(JSON.stringify(data));
594
- return;
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
- const port = Number(flagValue("port") ?? 4600);
598
- if (!Number.isInteger(port) || port < 1 || port > 65535) fail("--port takes a number from 1 to 65535");
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
- const { isLoopback, studio } = await import("../studio/server");
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
- // Off this machine, Studio must be told the names it is reached by: every other name is refused.
605
- if (!isLoopback(host) && !allowHosts.length) {
606
- fail(`Studio on ${host} needs --allow-host=<name>, the name people reach it by, such as studio.example.com`);
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
- await studio({ configPath, root, port, open: !flags.includes("--no-open"), host, allowHosts });
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
- const commands: Record<string, () => void | Promise<void>> = {
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));