@takodotid/circuit 0.43.0 → 1.0.0-rc.1

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 (257) hide show
  1. package/README.md +115 -37
  2. package/package.json +21 -58
  3. package/src/checks/across.ts +402 -0
  4. package/src/checks/built-in.ts +256 -0
  5. package/src/checks/device.ts +436 -0
  6. package/src/checks/index.ts +53 -0
  7. package/src/cli/advisories.ts +108 -0
  8. package/src/cli/app.ts +67 -0
  9. package/src/cli/editor-types.generated.ts +30 -0
  10. package/src/cli/editor-types.ts +69 -0
  11. package/src/cli/format.ts +124 -0
  12. package/src/cli/git-hooks.ts +79 -0
  13. package/src/cli/index.ts +348 -1170
  14. package/src/cli/migrate.ts +554 -0
  15. package/src/cli/project-commands.ts +656 -0
  16. package/src/cli/releases.ts +374 -0
  17. package/src/cli/remember.ts +339 -0
  18. package/src/cli/rename.ts +27 -33
  19. package/src/cli/secrets-password.ts +60 -0
  20. package/src/cli/secrets.ts +258 -0
  21. package/src/cli/setup.ts +300 -0
  22. package/src/cli/signing.ts +23 -0
  23. package/src/cli/text-editor.ts +268 -0
  24. package/src/cli/ui.ts +298 -0
  25. package/src/cli/version.ts +2 -0
  26. package/src/cli/versions.ts +158 -0
  27. package/src/config/address.ts +102 -0
  28. package/src/config/circuit.d.ts +30 -0
  29. package/src/config/common.ts +65 -0
  30. package/src/config/communities.ts +479 -0
  31. package/src/config/define.ts +684 -0
  32. package/src/config/device.ts +164 -0
  33. package/src/config/firewall.ts +150 -0
  34. package/src/config/index.ts +22 -0
  35. package/src/{schema → config}/interfaces.ts +121 -85
  36. package/src/config/keys.ts +434 -0
  37. package/src/config/management.ts +61 -0
  38. package/src/config/merge.ts +335 -0
  39. package/src/config/models.ts +70 -0
  40. package/src/config/monitoring.ts +64 -0
  41. package/src/config/network.ts +180 -0
  42. package/src/config/presets.ts +149 -0
  43. package/src/config/routing.ts +401 -0
  44. package/src/config/services.ts +51 -0
  45. package/src/config/switching.ts +84 -0
  46. package/src/config/system.ts +103 -0
  47. package/src/{schema → config}/time-zones.ts +1 -1
  48. package/src/core/address.ts +1 -1
  49. package/src/core/cabling.ts +8 -43
  50. package/src/core/graph.ts +40 -0
  51. package/src/{integrations → core}/registries.ts +5 -3
  52. package/src/core/secrets-file.ts +539 -0
  53. package/src/core/secrets.ts +83 -64
  54. package/src/core/topology.ts +103 -146
  55. package/src/core/utils.ts +12 -1
  56. package/src/model.ts +83 -0
  57. package/src/platforms/derive.ts +64 -37
  58. package/src/platforms/finding.ts +9 -0
  59. package/src/platforms/fortios/firewall.ts +76 -64
  60. package/src/platforms/fortios/index.ts +189 -125
  61. package/src/platforms/fortios/names.ts +51 -18
  62. package/src/platforms/fortios/plan.ts +132 -44
  63. package/src/platforms/fortios/render.ts +69 -62
  64. package/src/platforms/fortios/routing.ts +89 -58
  65. package/src/platforms/fortios/vdoms.ts +47 -16
  66. package/src/platforms/junos/TRAPS.md +2 -2
  67. package/src/platforms/junos/bgp.ts +63 -42
  68. package/src/platforms/junos/index.ts +122 -89
  69. package/src/platforms/junos/render.ts +139 -104
  70. package/src/platforms/platform.ts +10 -8
  71. package/src/platforms/raisecom-ros/TRAPS.md +1 -1
  72. package/src/platforms/raisecom-ros/index.ts +89 -70
  73. package/src/platforms/raisecom-ros/render.ts +49 -45
  74. package/src/platforms/routeros/TRAPS.md +3 -3
  75. package/src/platforms/routeros/index.ts +121 -60
  76. package/src/platforms/routeros/render.ts +214 -195
  77. package/src/platforms/vrp/TRAPS.md +1 -1
  78. package/src/platforms/vrp/index.ts +74 -50
  79. package/src/platforms/vrp/render.ts +67 -56
  80. package/src/project/project.ts +725 -0
  81. package/src/studio/app/env.d.ts +9 -0
  82. package/src/studio/app/index.html +18 -0
  83. package/src/studio/app/src/App.vue +95 -0
  84. package/src/studio/app/src/components/AddressesView.vue +38 -0
  85. package/src/studio/app/src/components/CablesView.vue +29 -0
  86. package/src/studio/app/src/components/CompareView.vue +160 -0
  87. package/src/studio/app/src/components/DataTable.vue +49 -0
  88. package/src/studio/app/src/components/DeviceView.vue +237 -0
  89. package/src/studio/app/src/components/DevicesView.vue +32 -0
  90. package/src/studio/app/src/components/Faceplate.vue +75 -0
  91. package/src/studio/app/src/components/GraphView.vue +282 -0
  92. package/src/studio/app/src/components/LineDiff.vue +24 -0
  93. package/src/studio/app/src/components/ProblemsView.vue +42 -0
  94. package/src/studio/app/src/components/SearchView.vue +72 -0
  95. package/src/studio/app/src/components/SecretsView.vue +112 -0
  96. package/src/studio/app/src/components/SessionsView.vue +35 -0
  97. package/src/studio/app/src/components/Settings.vue +95 -0
  98. package/src/studio/app/src/components/ToolsView.vue +118 -0
  99. package/src/studio/app/src/components/VlansView.vue +50 -0
  100. package/src/studio/app/src/lib/csv.ts +19 -0
  101. package/src/studio/{public/diff.js → app/src/lib/diff.ts} +40 -35
  102. package/src/studio/app/src/lib/format.ts +38 -0
  103. package/src/studio/app/src/lib/net.ts +68 -0
  104. package/src/studio/app/src/lib/secrets-crypto.ts +35 -0
  105. package/src/studio/app/src/lib/store.ts +71 -0
  106. package/src/studio/app/src/lib/tools.ts +217 -0
  107. package/src/studio/app/src/main.ts +9 -0
  108. package/src/studio/{public → app/src}/style.css +14 -4
  109. package/src/studio/app/tsconfig.json +17 -0
  110. package/src/studio/app/vite.config.ts +16 -0
  111. package/src/studio/assets.generated.ts +3 -0
  112. package/src/studio/data.ts +96 -188
  113. package/src/studio/git.ts +27 -43
  114. package/src/studio/server.ts +196 -107
  115. package/src/studio/types.ts +141 -0
  116. package/src/transport/console.ts +3 -3
  117. package/src/transport/ssh.ts +1 -1
  118. package/dist/chunks/index-3b343xva.js +0 -177
  119. package/dist/chunks/index-6wb41q67.js +0 -62
  120. package/dist/chunks/index-n7w3tg5y.js +0 -149
  121. package/dist/chunks/index-yzw86rc8.js +0 -921
  122. package/dist/chunks/new-w3zys5y4.js +0 -172
  123. package/dist/chunks/server-cxr9ynxc.js +0 -259
  124. package/dist/cli/index.js +0 -12872
  125. package/dist/helpers/index.js +0 -881
  126. package/dist/index.js +0 -28
  127. package/dist/types/core/address-space.d.ts +0 -62
  128. package/dist/types/core/address.d.ts +0 -44
  129. package/dist/types/core/cabling.d.ts +0 -41
  130. package/dist/types/core/cross-checks.d.ts +0 -28
  131. package/dist/types/core/define.d.ts +0 -79
  132. package/dist/types/core/project-files.d.ts +0 -12
  133. package/dist/types/core/schema-docs.d.ts +0 -25
  134. package/dist/types/core/secrets.d.ts +0 -62
  135. package/dist/types/core/topology.d.ts +0 -96
  136. package/dist/types/core/utils.d.ts +0 -10
  137. package/dist/types/core/validator.d.ts +0 -23
  138. package/dist/types/helpers/bgp.d.ts +0 -195
  139. package/dist/types/helpers/cables.d.ts +0 -69
  140. package/dist/types/helpers/checks.d.ts +0 -36
  141. package/dist/types/helpers/exchange.d.ts +0 -55
  142. package/dist/types/helpers/filters.d.ts +0 -51
  143. package/dist/types/helpers/index.d.ts +0 -14
  144. package/dist/types/helpers/route-server.d.ts +0 -52
  145. package/dist/types/helpers/upstreams.d.ts +0 -192
  146. package/dist/types/index.d.ts +0 -10
  147. package/dist/types/integrations/librenms.d.ts +0 -60
  148. package/dist/types/integrations/netbox.d.ts +0 -89
  149. package/dist/types/integrations/peeringdb.d.ts +0 -24
  150. package/dist/types/integrations/prometheus.d.ts +0 -24
  151. package/dist/types/integrations/registries.d.ts +0 -8
  152. package/dist/types/platforms/bgp-wait.d.ts +0 -24
  153. package/dist/types/platforms/catalog.d.ts +0 -109
  154. package/dist/types/platforms/derive.d.ts +0 -17
  155. package/dist/types/platforms/fortios/firewall.d.ts +0 -47
  156. package/dist/types/platforms/fortios/index.d.ts +0 -140
  157. package/dist/types/platforms/fortios/models.d.ts +0 -14
  158. package/dist/types/platforms/fortios/names.d.ts +0 -52
  159. package/dist/types/platforms/fortios/plan.d.ts +0 -104
  160. package/dist/types/platforms/fortios/render.d.ts +0 -77
  161. package/dist/types/platforms/fortios/routing.d.ts +0 -56
  162. package/dist/types/platforms/fortios/tree.d.ts +0 -37
  163. package/dist/types/platforms/fortios/vdoms.d.ts +0 -50
  164. package/dist/types/platforms/index.d.ts +0 -13
  165. package/dist/types/platforms/junos/bgp.d.ts +0 -44
  166. package/dist/types/platforms/junos/index.d.ts +0 -114
  167. package/dist/types/platforms/junos/models.d.ts +0 -14
  168. package/dist/types/platforms/junos/render.d.ts +0 -154
  169. package/dist/types/platforms/line-diff.d.ts +0 -67
  170. package/dist/types/platforms/platform.d.ts +0 -135
  171. package/dist/types/platforms/raisecom-ros/index.d.ts +0 -71
  172. package/dist/types/platforms/raisecom-ros/models.d.ts +0 -14
  173. package/dist/types/platforms/raisecom-ros/render.d.ts +0 -41
  174. package/dist/types/platforms/routeros/export.d.ts +0 -59
  175. package/dist/types/platforms/routeros/index.d.ts +0 -116
  176. package/dist/types/platforms/routeros/models.d.ts +0 -18
  177. package/dist/types/platforms/routeros/plan.d.ts +0 -58
  178. package/dist/types/platforms/routeros/render.d.ts +0 -250
  179. package/dist/types/platforms/vrp/index.d.ts +0 -83
  180. package/dist/types/platforms/vrp/models.d.ts +0 -12
  181. package/dist/types/platforms/vrp/render.d.ts +0 -96
  182. package/dist/types/schema/common.d.ts +0 -30
  183. package/dist/types/schema/device.d.ts +0 -126
  184. package/dist/types/schema/firewall.d.ts +0 -131
  185. package/dist/types/schema/index.d.ts +0 -8
  186. package/dist/types/schema/interfaces.d.ts +0 -205
  187. package/dist/types/schema/policy.d.ts +0 -107
  188. package/dist/types/schema/routing.d.ts +0 -161
  189. package/dist/types/schema/system.d.ts +0 -198
  190. package/dist/types/schema/time-zones.d.ts +0 -2
  191. package/dist/types/transport/console.d.ts +0 -115
  192. package/dist/types/transport/path-check.d.ts +0 -32
  193. package/dist/types/transport/ssh.d.ts +0 -27
  194. package/src/cli/graph-format.ts +0 -91
  195. package/src/cli/new.ts +0 -234
  196. package/src/core/address-space.ts +0 -320
  197. package/src/core/cross-checks.ts +0 -369
  198. package/src/core/define.ts +0 -124
  199. package/src/core/project-files.ts +0 -35
  200. package/src/core/schema-docs.ts +0 -174
  201. package/src/core/validator.ts +0 -508
  202. package/src/helpers/bgp.ts +0 -473
  203. package/src/helpers/cables.ts +0 -74
  204. package/src/helpers/checks.ts +0 -201
  205. package/src/helpers/exchange.ts +0 -123
  206. package/src/helpers/filters.ts +0 -107
  207. package/src/helpers/index.ts +0 -25
  208. package/src/helpers/route-server.ts +0 -212
  209. package/src/helpers/upstreams.ts +0 -369
  210. package/src/index.ts +0 -13
  211. package/src/integrations/librenms.ts +0 -291
  212. package/src/integrations/netbox.ts +0 -620
  213. package/src/integrations/peeringdb.ts +0 -173
  214. package/src/integrations/prometheus.ts +0 -90
  215. package/src/schema/common.ts +0 -41
  216. package/src/schema/device.ts +0 -145
  217. package/src/schema/firewall.ts +0 -140
  218. package/src/schema/index.ts +0 -8
  219. package/src/schema/policy.ts +0 -115
  220. package/src/schema/routing.ts +0 -173
  221. package/src/schema/system.ts +0 -203
  222. package/src/studio/public/app.js +0 -186
  223. package/src/studio/public/compare.js +0 -250
  224. package/src/studio/public/dom.js +0 -138
  225. package/src/studio/public/evaluate.js +0 -687
  226. package/src/studio/public/export.js +0 -130
  227. package/src/studio/public/graph.js +0 -1541
  228. package/src/studio/public/index.html +0 -83
  229. package/src/studio/public/net.js +0 -134
  230. package/src/studio/public/tables.js +0 -1289
  231. package/src/studio/public/tools.js +0 -610
  232. package/templates/colocation/checks.ts +0 -53
  233. package/templates/colocation/circuit.config.ts +0 -9
  234. package/templates/colocation/edge-01.ts +0 -133
  235. package/templates/colocation/routing.ts +0 -13
  236. package/templates/colocation/tenancy.ts +0 -76
  237. package/templates/colocation/tenants/acme.ts +0 -19
  238. package/templates/colocation/tenants/index.ts +0 -6
  239. package/templates/colocation/tor-01.ts +0 -33
  240. package/templates/edge-router/checks.ts +0 -20
  241. package/templates/edge-router/circuit.config.ts +0 -8
  242. package/templates/edge-router/edge-01.ts +0 -110
  243. package/templates/edge-router/routing.ts +0 -13
  244. package/templates/project/AGENTS.md +0 -51
  245. package/templates/project/CLAUDE.md +0 -1
  246. package/templates/project/README.md +0 -16
  247. package/templates/project/gitignore +0 -5
  248. package/templates/project/tsconfig.json +0 -13
  249. package/templates/single-site/checks.ts +0 -26
  250. package/templates/single-site/circuit.config.ts +0 -8
  251. package/templates/single-site/router.ts +0 -86
  252. package/templates/single-site/site.ts +0 -18
  253. package/templates/single-site/switch.ts +0 -34
  254. /package/src/studio/{public → app/public}/favicon.svg +0 -0
  255. /package/src/studio/{public → app/public}/special-gothic-latin-ext.woff2 +0 -0
  256. /package/src/studio/{public → app/public}/special-gothic-latin.woff2 +0 -0
  257. /package/src/studio/{public → app/public}/special-gothic-license.txt +0 -0
@@ -0,0 +1,436 @@
1
+ // Rules one device has to satisfy, whatever its design. What reading the project already stops on (unknown keys, every name a device refers to, ports the model
2
+ // doesn't have, links that don't link back) is not checked again here.
3
+
4
+ import { X509Certificate } from "node:crypto";
5
+ import { Address } from "../core/address";
6
+ import { Utils } from "../core/utils";
7
+ import type { Device, Interface, InterfaceKind, Routed, Switched } from "../model";
8
+ import { Catalog } from "../platforms/catalog";
9
+ import { Derive } from "../platforms/derive";
10
+ import type { Finding } from "../platforms/finding";
11
+ import { Platforms } from "../platforms";
12
+
13
+ type Report = (message: string) => void;
14
+
15
+ type Checker = (where: string, value: string) => void;
16
+
17
+ /** A port or an interface that can hold an address, with its path, such as `interfaces.ethernet.10g-1`. */
18
+ export type Holder = { where: string; name: string; kind: "ethernet" | InterfaceKind; settings: Routed };
19
+
20
+ export class DeviceChecks {
21
+ /** Every rule on one device, and what its model's hardware holds. */
22
+ static run(device: Device): Finding[] {
23
+ const findings: Finding[] = [];
24
+ const report: Report = (message) => findings.push({ level: "error", device: device.name, message });
25
+
26
+ DeviceChecks.check(device, report);
27
+ findings.push(...DeviceChecks.limits(device));
28
+
29
+ return findings;
30
+ }
31
+
32
+ /** Ports, then interfaces group by group, that can hold an address. */
33
+ static holders(device: Device): Holder[] {
34
+ return [
35
+ ...Derive.ports(device).map(([name, settings]): Holder => ({
36
+ where: `interfaces.ethernet.${name}`,
37
+ name,
38
+ kind: "ethernet",
39
+ settings,
40
+ })),
41
+ ...Derive.interfaces(device).map(({ name, kind, settings }): Holder => ({
42
+ where: `interfaces.${kind}.${name}`,
43
+ name,
44
+ kind,
45
+ settings,
46
+ })),
47
+ ];
48
+ }
49
+
50
+ /** An address entry's own address: the string, or `address` of `{ address, peer_address }`. */
51
+ static addressOf(entry: NonNullable<Routed["addresses"]>[number]): string {
52
+ return typeof entry === "string" ? entry : entry.address;
53
+ }
54
+
55
+ private static check(device: Device, report: Report): void {
56
+ let ports: string[] = [];
57
+
58
+ try {
59
+ ports = Catalog.ports(device.platform, device.model);
60
+ } catch (error) {
61
+ report((error as Error).message);
62
+ }
63
+
64
+ const cidr: Checker = (where, value) => {
65
+ if (!Address.isCidr(value)) report(`${where}: '${value}' is not an address with a length`);
66
+ };
67
+ const ip: Checker = (where, value) => {
68
+ if (!Address.isValid(value)) report(`${where}: '${value}' is not an address`);
69
+ };
70
+
71
+ if (!device.connection.host && !device.connection.console)
72
+ report("connection has neither a host nor a console to reach the device through");
73
+
74
+ DeviceChecks.vlans(device, report);
75
+ DeviceChecks.addresses(device, report, cidr, ip);
76
+ DeviceChecks.ports(device, report);
77
+ DeviceChecks.interfaces(device, ports, report, ip);
78
+ DeviceChecks.routing(device, report, cidr, ip);
79
+ DeviceChecks.policies(device, report, cidr);
80
+ DeviceChecks.firewall(device, cidr);
81
+ DeviceChecks.services(device, report, cidr);
82
+ DeviceChecks.certificates(device, report);
83
+ DeviceChecks.vdoms(device, report, cidr, ip);
84
+ }
85
+
86
+ /** A management VDOM: FortiOS only, not `root`, with no LAG member listed apart from its LAG, and routes that leave through its own interfaces. */
87
+ private static vdoms(device: Device, report: Report, cidr: Checker, ip: Checker): void {
88
+ const management = device.system?.vdoms?.management;
89
+ if (!management) return;
90
+
91
+ if (device.platform !== "fortios") report("system.vdoms is a FortiOS setting");
92
+ if (management.name === "root")
93
+ report("system.vdoms.management.name is root, the VDOM everything else is in; give it a name of its own");
94
+
95
+ for (const name of management.interfaces) {
96
+ const lag = device.interfaces?.ethernet?.[name]?.in_lag;
97
+ if (lag) report(`system.vdoms.management.interfaces has ${name}, which is in LAG ${lag}; list the LAG`);
98
+ }
99
+
100
+ for (const route of management.static_routes ?? []) {
101
+ const where = `system.vdoms.management.static_routes ${route.prefix}`;
102
+ cidr(where, route.prefix);
103
+ if (route.next_hop) ip(where, route.next_hop);
104
+ if (!route.next_hop && !route.interface && !route.blackhole) report(`${where} has no next hop`);
105
+ if (route.interface && !management.interfaces.includes(route.interface))
106
+ report(`${where} leaves through ${route.interface}, which is not in the VDOM`);
107
+ }
108
+ }
109
+
110
+ /** What the model's hardware holds, from the catalog. A LAG with too many members is an error; too many switch rules is a warning, since the limit is only roughly known. */
111
+ private static limits(device: Device): Finding[] {
112
+ const limits = Catalog.limits(device.platform, device.model);
113
+ const findings: Finding[] = [];
114
+ const report = (level: Finding["level"], message: string) => findings.push({ level, device: device.name, message });
115
+
116
+ if (limits.lag_members !== undefined) {
117
+ for (const name of Object.keys(device.interfaces?.lag ?? {})) {
118
+ const members = Derive.ports(device).filter(([, port]) => port.in_lag === name).length;
119
+ if (members > limits.lag_members) {
120
+ report("error", `interfaces.lag.${name} has ${members} members; ${device.model} holds at most ${limits.lag_members}`);
121
+ }
122
+ }
123
+ }
124
+
125
+ if (limits.switch_rules === undefined && limits.switch_port_ranges === undefined) return findings;
126
+
127
+ const rules = Platforms.find(device.platform)?.switchRules?.(device);
128
+ if (!rules) return findings;
129
+
130
+ const after = "A rule it cannot hold is invalid, and so is every rule after it";
131
+
132
+ if (limits.switch_rules !== undefined && rules.count > limits.switch_rules) {
133
+ report(
134
+ "warning",
135
+ `switching.acls make ${rules.count} switch rules; a ${device.model} held about ${limits.switch_rules}. ${after}`
136
+ );
137
+ }
138
+
139
+ if (limits.switch_port_ranges !== undefined && rules.ranges > limits.switch_port_ranges) {
140
+ report(
141
+ "warning",
142
+ `switching.acls use ${rules.ranges} distinct port ranges; a ${device.model} held ${limits.switch_port_ranges}. ${after}`
143
+ );
144
+ }
145
+
146
+ return findings;
147
+ }
148
+
149
+ private static vlans(device: Device, report: Report): void {
150
+ const ids = new Map<number, string>();
151
+
152
+ for (const [name, vlan] of Object.entries(device.switching?.vlans ?? {})) {
153
+ if (!vlan) continue;
154
+
155
+ if (!Number.isInteger(vlan.id) || vlan.id < 1 || vlan.id > 4094)
156
+ report(`switching.vlans.${name} id ${vlan.id} is outside 1-4094`);
157
+ if (ids.has(vlan.id)) report(`switching.vlans ${ids.get(vlan.id)} and ${name} share id ${vlan.id}`);
158
+ ids.set(vlan.id, name);
159
+ }
160
+ }
161
+
162
+ /** Every address is valid, and no address sits on two interfaces of one routing table. Two VRFs may hold the same address. */
163
+ private static addresses(device: Device, report: Report, cidr: Checker, ip: Checker): void {
164
+ const holderOf = new Map<string, string>();
165
+
166
+ for (const { where, settings } of DeviceChecks.holders(device)) {
167
+ for (const entry of settings.addresses ?? []) {
168
+ const address = DeviceChecks.addressOf(entry);
169
+ cidr(where, address);
170
+ if (typeof entry !== "string") ip(where, entry.peer_address);
171
+
172
+ const bare = address.split("/")[0]!;
173
+ const key = `${settings.vrf ?? ""} ${bare}`;
174
+ if (holderOf.has(key)) report(`${bare} is on both ${holderOf.get(key)} and ${where}`);
175
+ holderOf.set(key, where);
176
+ }
177
+
178
+ for (const group of settings.vrrp ?? []) {
179
+ ip(`${where} VRRP ${group.id}`, group.address);
180
+ if (group.id < 1 || group.id > 255) report(`${where} VRRP id ${group.id} is outside 1-255`);
181
+ }
182
+ }
183
+ }
184
+
185
+ private static ports(device: Device, report: Report): void {
186
+ for (const [name, port] of Derive.ports(device)) {
187
+ const where = `interfaces.ethernet.${name}`;
188
+ const switched = Boolean(port.access_vlan || port.trunk_vlans);
189
+
190
+ if (port.access_vlan && port.trunk_vlans) report(`${where} is both access and trunk`);
191
+ if (port.native_vlan && !port.trunk_vlans) report(`${where} has a native VLAN but is not a trunk`);
192
+ if (switched && port.addresses?.length) report(`${where} is switched and routed at once`);
193
+
194
+ if (port.in_lag && (switched || port.addresses)) {
195
+ report(`${where}.in_lag is ${port.in_lag}, and the port states its own switching or addresses; the LAG holds them`);
196
+ }
197
+
198
+ DeviceChecks.frames(where, port, report);
199
+ }
200
+ }
201
+
202
+ /** A MAC limit is a whole number above 0, and an ethertype filter lets at least one kind in, each once. */
203
+ private static frames(where: string, settings: Switched, report: Report): void {
204
+ const limit = settings.mac_limit;
205
+ if (limit !== undefined && !(Number.isInteger(limit) && limit >= 1))
206
+ report(`${where}.mac_limit ${limit} is not a whole number above 0`);
207
+
208
+ const kinds = settings.ethertypes;
209
+ if (kinds && !kinds.length) report(`${where}.ethertypes is empty, so it would drop every frame`);
210
+ if (kinds && new Set(kinds).size !== kinds.length) report(`${where}.ethertypes names a kind twice`);
211
+ }
212
+
213
+ private static interfaces(device: Device, ports: readonly string[], report: Report, ip: Checker): void {
214
+ const bridges = Object.entries(device.interfaces?.bridge ?? {});
215
+
216
+ for (const { name, kind, settings } of Derive.interfaces(device)) {
217
+ const where = `interfaces.${kind}.${name}`;
218
+ if (ports.includes(name)) report(`${where} has the name of a port`);
219
+
220
+ if (kind === "gre" || kind === "vxlan") {
221
+ const tunnel = settings as Extract<Interface, { remote_address: string }>;
222
+ ip(where, tunnel.local_address);
223
+ ip(where, tunnel.remote_address);
224
+ }
225
+
226
+ if (kind === "lag") {
227
+ DeviceChecks.frames(where, settings as Switched, report);
228
+
229
+ const members = Derive.ports(device).filter(([, port]) => port.in_lag === name);
230
+ if (!members.length) report(`${where} has no member ports`);
231
+ }
232
+
233
+ if (kind === "bridge") {
234
+ const members = (settings as Extract<Interface, { member_interfaces: unknown }>).member_interfaces;
235
+ if (members.length < 2) report(`${where} joins fewer than two interfaces`);
236
+
237
+ for (const member of members) {
238
+ if (device.interfaces?.vlan_interface?.[member]?.addresses?.length)
239
+ report(`${where}: member ${member} has an address of its own`);
240
+
241
+ const other = bridges.find(([otherName, bridge]) => otherName !== name && bridge?.member_interfaces.includes(member));
242
+ if (other) report(`${where}: member ${member} is also in interfaces.bridge.${other[0]}`);
243
+ }
244
+ }
245
+
246
+ if (kind === "wireguard") {
247
+ for (const peer of (settings as Extract<Interface, { listen_port: number }>).peers ?? []) {
248
+ for (const address of peer.allowed_addresses) {
249
+ if (!Address.isCidr(address)) report(`${where} peer ${peer.name}: '${address}' is not an address with a length`);
250
+ }
251
+ }
252
+ }
253
+ }
254
+ }
255
+
256
+ private static routing(device: Device, report: Report, cidr: Checker, ip: Checker): void {
257
+ const routing = device.routing;
258
+
259
+ for (const route of routing?.static_routes ?? []) {
260
+ const where = `routing.static_routes ${route.prefix}`;
261
+ cidr("routing.static_routes", route.prefix);
262
+ if (!route.next_hop && !route.interface && !route.blackhole) report(`${where} has no next hop`);
263
+ if (route.next_hop) ip(where, route.next_hop);
264
+ if (route.bfd && !route.next_hop) report(`${where} has bfd without next_hop, the address BFD watches`);
265
+ if (route.vrf && Utils.otherTable(route.routing_table))
266
+ report(`${where} has both vrf and routing_table; a route is in one of them`);
267
+ }
268
+
269
+ DeviceChecks.tables(device, report, cidr);
270
+
271
+ const bgp = routing?.bgp;
272
+
273
+ for (const prefix of bgp?.originated_prefixes ?? []) cidr("routing.bgp.originated_prefixes", prefix);
274
+
275
+ for (const [name, neighbor] of Object.entries(bgp?.neighbors ?? {})) {
276
+ const where = `routing.bgp.neighbors.${name}`;
277
+ ip(where, neighbor.address);
278
+
279
+ const group = neighbor.neighbor_group ? bgp?.neighbor_groups?.[neighbor.neighbor_group] : undefined;
280
+ if (!(neighbor.remote_as ?? group?.remote_as)) report(`${where} has no remote_as`);
281
+
282
+ const local = neighbor.local_address ?? group?.local_address;
283
+ if (local && Address.family(local) !== Address.family(neighbor.address))
284
+ report(`${where} local_address and address differ in family`);
285
+ }
286
+
287
+ for (const areaId of Object.keys(routing?.ospf?.areas ?? {})) {
288
+ if (!/^\d+\.\d+\.\d+\.\d+$/.test(areaId)) report(`routing.ospf.areas '${areaId}' is not written as a dotted area ID`);
289
+ }
290
+ }
291
+
292
+ /** Routing tables have names of their own and something installing routes in them, and policy routing rules can each be reached. */
293
+ private static tables(device: Device, report: Report, cidr: Checker): void {
294
+ const routing = device.routing;
295
+ const bgp = routing?.bgp;
296
+
297
+ const installed = new Set<string>();
298
+ for (const route of routing?.static_routes ?? []) {
299
+ const table = Utils.otherTable(route.routing_table);
300
+ if (table) installed.add(table);
301
+ }
302
+ for (const neighbor of Object.values(bgp?.neighbors ?? {})) {
303
+ const group = neighbor.neighbor_group ? bgp?.neighbor_groups?.[neighbor.neighbor_group] : undefined;
304
+ const table = Utils.otherTable(neighbor.routing_table ?? group?.routing_table);
305
+ if (table) installed.add(table);
306
+ }
307
+
308
+ for (const name of Object.keys(routing?.tables ?? {})) {
309
+ if (name === "main") report("routing.tables has main, the main table every device has; give the table another name");
310
+ if (name in (routing?.vrfs ?? {})) report(`routing.tables ${name} has the name of a VRF; give the table another name`);
311
+ if (name !== "main" && !installed.has(name))
312
+ report(
313
+ `routing.tables ${name}: nothing installs routes in it; give a static route or a BGP neighbor routing_table: "${name}", or remove the table`
314
+ );
315
+ }
316
+
317
+ const rules = routing?.policy_routing ?? [];
318
+ rules.forEach((rule, index) => {
319
+ const where = `routing.policy_routing[${index}]${rule.description ? ` (${rule.description})` : ""}`;
320
+ const match = rule.match ?? {};
321
+
322
+ if (match.src) cidr(where, match.src);
323
+ if (match.dst) cidr(where, match.dst);
324
+
325
+ if (match.src && match.dst && Address.isCidr(match.src) && Address.isCidr(match.dst)) {
326
+ if (Address.family(Address.host(match.src)) !== Address.family(Address.host(match.dst)))
327
+ report(`${where}: src and dst differ in family, so no packet matches both`);
328
+ }
329
+
330
+ if (rule.routing_table === "main" && rule.fallback)
331
+ report(`${where} looks up main and has fallback; fallback is for another table, so remove it`);
332
+
333
+ const matchesAll = !match.src && !match.dst && !match.in_interface;
334
+ if (matchesAll && index < rules.length - 1)
335
+ report(`${where} matches every packet, so the rules after it never run; give it a match, or move it last`);
336
+ });
337
+ }
338
+
339
+ private static policies(device: Device, report: Report, cidr: Checker): void {
340
+ const policies = device.routing?.policies ?? {};
341
+
342
+ for (const [name, rules] of Object.entries(policies)) {
343
+ (rules ?? []).forEach((rule, index) => {
344
+ const prefix = "if" in rule ? rule.if?.prefix : undefined;
345
+ if (prefix) cidr(`routing.policies.${name}[${index}]`, prefix);
346
+ });
347
+ }
348
+
349
+ // A policy that calls itself, directly or not, never finishes.
350
+ const visit = (policy: string, path: string[]): void => {
351
+ if (path.includes(policy)) return report(`routing.policies: ${[...path, policy].join(" -> ")} calls itself`);
352
+
353
+ for (const rule of policies[policy] ?? []) if ("call" in rule && rule.call) visit(rule.call, [...path, policy]);
354
+ };
355
+ for (const policy of Object.keys(policies)) visit(policy, []);
356
+ }
357
+
358
+ private static firewall(device: Device, cidr: Checker): void {
359
+ const firewall = device.firewall;
360
+
361
+ for (const [name, members] of Object.entries(firewall?.address_sets ?? {})) {
362
+ for (const member of members ?? []) {
363
+ if (!Address.isValid(member)) cidr(`firewall.address_sets.${name}`, member);
364
+ }
365
+ }
366
+
367
+ const chains = [
368
+ ...Object.entries(firewall?.filter ?? {}).map(([chain, settings]) => ({
369
+ where: `firewall.filter.${chain}.rules`,
370
+ rules: settings?.rules ?? [],
371
+ })),
372
+ { where: "firewall.nat.source", rules: firewall?.nat?.source ?? [] },
373
+ { where: "firewall.nat.destination", rules: firewall?.nat?.destination ?? [] },
374
+ ];
375
+
376
+ for (const { where, rules } of chains) {
377
+ for (const rule of rules) {
378
+ // The model holds one prefix a side; a list is taken as the language writes it.
379
+ for (const address of [rule.if?.src, rule.if?.dst].flat()) {
380
+ if (address) cidr(where, address);
381
+ }
382
+ }
383
+ }
384
+ }
385
+
386
+ private static services(device: Device, report: Report, cidr: Checker): void {
387
+ for (const [name, server] of Object.entries(device.services?.dhcp_server ?? {})) {
388
+ const where = `services.dhcp_server.${name}`;
389
+ cidr(where, server.network);
390
+ if (!Address.isCidr(server.network)) continue;
391
+
392
+ const reserved = (server.reservations ?? []).map((reservation) => reservation.address);
393
+ const addresses = [server.gateway, ...server.pool, ...reserved];
394
+
395
+ for (const address of addresses) {
396
+ if (!Address.contains(server.network, address)) report(`${where}: ${address} is outside ${server.network}`);
397
+ }
398
+ }
399
+
400
+ // `management.allow` may be an address set's addresses, and a set holds an address alone too.
401
+ const allow = device.management?.allow;
402
+ if (Array.isArray(allow)) for (const prefix of allow) if (!Address.isValid(prefix)) cidr("management.allow", prefix);
403
+
404
+ const snmp = device.monitoring?.snmp;
405
+ if (snmp && !snmp.community && !snmp.users) report("monitoring.snmp is on with neither a community nor a user");
406
+ }
407
+
408
+ private static certificates(device: Device, report: Report): void {
409
+ const certificates = device.system?.certificates ?? {};
410
+
411
+ for (const [name, certificate] of Object.entries(certificates)) {
412
+ if (!certificate) continue;
413
+
414
+ let pem: string;
415
+ try {
416
+ pem = Utils.projectFile(certificate.certificate_file);
417
+ } catch {
418
+ report(`system.certificates.${name}: certificate_file ${certificate.certificate_file} cannot be read`);
419
+ continue;
420
+ }
421
+
422
+ try {
423
+ new X509Certificate(pem);
424
+ } catch {
425
+ report(`system.certificates.${name} is not a PEM certificate`);
426
+ }
427
+ }
428
+
429
+ for (const service of ["https", "api_tls"] as const) {
430
+ const name = device.management?.[service]?.certificate;
431
+ if (name && certificates[name] && !certificates[name].private_key) {
432
+ report(`management.${service}.certificate ${name} has no private key to serve with`);
433
+ }
434
+ }
435
+ }
436
+ }
@@ -0,0 +1,53 @@
1
+ // What `circuit validate` finds in a network read with `readProject`: each device's own rules, what its platform cannot express or does otherwise, what its
2
+ // model's hardware holds, the rules across devices, and the built-in checks the network turns on. What reading the project stops on is not checked again.
3
+
4
+ import type { Device } from "../model";
5
+ import type { Finding } from "../platforms/finding";
6
+ import { Platforms } from "../platforms";
7
+ import { CrossChecks } from "./across";
8
+ import { BuiltInChecks, type NetworkChecks } from "./built-in";
9
+ import { DeviceChecks } from "./device";
10
+
11
+ export type { NetworkChecks } from "./built-in";
12
+
13
+ export class Validation {
14
+ /** Every finding on the network's devices, in the model, with the built-in checks its `checks` turns on. Own checks in `checks` are left out: reading the project runs them. */
15
+ static run(devices: readonly Device[], checks: NetworkChecks | undefined, options: { experimental?: boolean } = {}): Finding[] {
16
+ const findings: Finding[] = [];
17
+
18
+ for (const device of devices) {
19
+ findings.push(...DeviceChecks.run(device));
20
+
21
+ const adapter = Platforms.find(device.platform);
22
+ if (!adapter) {
23
+ findings.push({ level: "error", device: device.name, message: `no adapter for platform ${device.platform}` });
24
+ continue;
25
+ }
26
+
27
+ for (const field of adapter.unsupported(device)) {
28
+ findings.push({ level: "error", device: device.name, message: `${device.platform} cannot express ${field}` });
29
+ }
30
+ for (const field of adapter.experimental?.(device) ?? []) {
31
+ findings.push(
32
+ options.experimental
33
+ ? {
34
+ level: "warning",
35
+ device: device.name,
36
+ message: `${field} is not proved on a device yet. Sent because of --experimental: try it on a lab device first.`,
37
+ }
38
+ : {
39
+ level: "error",
40
+ device: device.name,
41
+ message: `${field} is not proved on a device yet. Add --experimental to use it anyway, at your own risk.`,
42
+ }
43
+ );
44
+ }
45
+ findings.push(...(adapter.checks?.(device) ?? []));
46
+ }
47
+
48
+ findings.push(...CrossChecks.run(devices));
49
+ findings.push(...BuiltInChecks.run(devices, checks));
50
+
51
+ return findings;
52
+ }
53
+ }
@@ -0,0 +1,108 @@
1
+ // Security advisories for Circuit: GitHub's, published on the repository, the source npm audit reads too. Each names the versions it affects and the versions that fix it. A version an advisory affects never runs: the person moves to a fixed one first. Read straight from the repository, so an advisory counts the moment it is published, and kept for a few minutes, as GitHub answers 60 requests an hour without a sign-in.
2
+
3
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+
6
+ /** One advisory, as Circuit keeps it. */
7
+ export type Advisory = {
8
+ /** Such as `GHSA-xxxx-xxxx-xxxx`. */
9
+ id: string;
10
+ summary: string;
11
+ severity: string;
12
+ url: string;
13
+ /** The versions it affects, each as GitHub writes a range: `>= 1.10.0, < 1.16.0`. */
14
+ affected: string[];
15
+ /** The versions that fix it, such as `1.16.0` and `1.9.5`. */
16
+ patched: string[];
17
+ };
18
+
19
+ /** How a version compares with another: below 0 older, 0 the same, above 0 newer. */
20
+ type Compare = (a: string, b: string) => number;
21
+
22
+ export class Advisories {
23
+ static readonly API = "https://api.github.com/repos/takodotid/circuit/security-advisories?state=published&per_page=100";
24
+ static readonly PACKAGE = "@takodotid/circuit";
25
+
26
+ /** How long advisories read count before Circuit asks GitHub again. */
27
+ private static readonly FRESH_SECONDS = 120;
28
+ private static readonly TIMEOUT_MS = 3000;
29
+
30
+ /** Whether `version` is in a range as GitHub writes it: constraints joined by commas, each `<`, `<=`, `>`, `>=` or `=` and a version. */
31
+ static inRange(range: string, version: string, compare: Compare): boolean {
32
+ return range
33
+ .split(",")
34
+ .map((part) => part.trim())
35
+ .filter(Boolean)
36
+ .every((part) => {
37
+ const [, op = "=", bound = ""] = /^(<=|>=|<|>|=)?\s*v?(\S+)$/.exec(part) ?? [];
38
+ const order = compare(version, bound);
39
+ if (op === "<") return order < 0;
40
+ if (op === "<=") return order <= 0;
41
+ if (op === ">") return order > 0;
42
+ if (op === ">=") return order >= 0;
43
+ return order === 0;
44
+ });
45
+ }
46
+
47
+ /** The advisories that affect `version`. */
48
+ static affecting(advisories: readonly Advisory[], version: string, compare: Compare): Advisory[] {
49
+ return advisories.filter((advisory) => advisory.affected.some((range) => Advisories.inRange(range, version, compare)));
50
+ }
51
+
52
+ /** GitHub's answer, kept to what Circuit uses: only what affects the npm package. */
53
+ static parse(answer: unknown): Advisory[] {
54
+ if (!Array.isArray(answer)) return [];
55
+
56
+ return answer.flatMap((item): Advisory[] => {
57
+ const vulnerabilities = (Array.isArray(item?.vulnerabilities) ? item.vulnerabilities : []).filter(
58
+ (vulnerability: any) => vulnerability?.package?.name === Advisories.PACKAGE
59
+ );
60
+ if (!vulnerabilities.length || typeof item.ghsa_id !== "string") return [];
61
+
62
+ return [
63
+ {
64
+ id: item.ghsa_id,
65
+ summary: String(item.summary ?? ""),
66
+ severity: String(item.severity ?? "unknown"),
67
+ url: String(item.html_url ?? `https://github.com/advisories/${item.ghsa_id}`),
68
+ affected: vulnerabilities
69
+ .map((vulnerability: any) => String(vulnerability.vulnerable_version_range ?? ""))
70
+ .filter(Boolean),
71
+ patched: vulnerabilities
72
+ .flatMap((vulnerability: any) => String(vulnerability.patched_versions ?? "").split(","))
73
+ .map((version: string) => version.trim())
74
+ .filter(Boolean),
75
+ },
76
+ ];
77
+ });
78
+ }
79
+
80
+ /** The published advisories: from the cache when read in the last two minutes, else from GitHub. `offline` when GitHub could not be read, with the last answer, if any. */
81
+ static async read(home: string, fresh = false): Promise<{ advisories: Advisory[]; offline: boolean }> {
82
+ const path = join(home, "advisories.json");
83
+ let cached: { checked: number; advisories: Advisory[] } | undefined;
84
+ try {
85
+ cached = JSON.parse(readFileSync(path, "utf8"));
86
+ } catch {}
87
+
88
+ if (!fresh && cached && Date.now() - cached.checked < Advisories.FRESH_SECONDS * 1000)
89
+ return { advisories: cached.advisories, offline: false };
90
+
91
+ try {
92
+ const response = await fetch(Advisories.API, {
93
+ signal: AbortSignal.timeout(Advisories.TIMEOUT_MS),
94
+ headers: { accept: "application/vnd.github+json", "user-agent": "circuit" },
95
+ });
96
+ if (!response.ok) throw new Error(`GitHub answered ${response.status}`);
97
+
98
+ const advisories = Advisories.parse(await response.json());
99
+ try {
100
+ mkdirSync(home, { recursive: true });
101
+ writeFileSync(path, JSON.stringify({ checked: Date.now(), advisories }));
102
+ } catch {}
103
+ return { advisories, offline: false };
104
+ } catch {
105
+ return { advisories: cached?.advisories ?? [], offline: true };
106
+ }
107
+ }
108
+ }
package/src/cli/app.ts ADDED
@@ -0,0 +1,67 @@
1
+ // Circuit installed like an app, by the installer: on Windows in %LOCALAPPDATA%\Programs\Circuit and listed in Settings > Apps, elsewhere in ~/.local/bin. `circuit uninstall` takes it away again: the program, its PATH entry, the Apps entry and ~/.circuit. Projects are never touched.
2
+
3
+ import { spawn, spawnSync } from "node:child_process";
4
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { dirname, join } from "node:path";
7
+ import { Releases } from "./releases";
8
+ import { Ui } from "./ui";
9
+
10
+ export class App {
11
+ /** What the installer writes after a line it adds to a shell's startup file, so uninstall removes that line and no other. */
12
+ static readonly MARKER = "# Added by Circuit";
13
+
14
+ /** The Windows registry key that lists Circuit in Settings > Apps. */
15
+ static readonly UNINSTALL_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Circuit";
16
+
17
+ /** The shell startup files the installer may have added Circuit's folder to. */
18
+ private static readonly PROFILES = [".zshrc", ".bashrc", ".bash_profile", ".profile", ".config/fish/config.fish"];
19
+
20
+ /** Lines that do not end with the marker, so what the installer added goes and the rest stays. */
21
+ static withoutMarked(text: string): string {
22
+ return text
23
+ .split("\n")
24
+ .filter((line) => !line.trimEnd().endsWith(App.MARKER))
25
+ .join("\n");
26
+ }
27
+
28
+ /** Take Circuit off this machine, once the person says yes, or with `--confirm`. */
29
+ static async uninstall(confirmed: boolean): Promise<void> {
30
+ const binary = Releases.binary() ?? Ui.fail("This Circuit runs from its sources, so there is nothing installed to remove.");
31
+ const folder = dirname(binary);
32
+
33
+ if (!confirmed) {
34
+ if (!Ui.interactive) Ui.fail("Not removed. Without a person to answer, add --confirm.");
35
+ Ui.note(`${binary}\n${Releases.home()}\nYour projects stay as they are.`, "This removes");
36
+ if (!(await Ui.confirm("Uninstall Circuit?"))) return;
37
+ }
38
+
39
+ rmSync(Releases.home(), { recursive: true, force: true });
40
+
41
+ if (process.platform === "win32") {
42
+ App.removeFromWindowsPath(folder);
43
+ spawnSync("reg", ["delete", App.UNINSTALL_KEY, "/f"], { stdio: "ignore" });
44
+
45
+ // Windows does not let a running program delete itself, so a separate process deletes it once this one has ended.
46
+ spawn("cmd", ["/c", `ping -n 3 127.0.0.1 >nul & del /f /q "${binary}" & del /f /q "${binary}.old" & rmdir "${folder}"`], {
47
+ detached: true,
48
+ stdio: "ignore",
49
+ windowsHide: true,
50
+ }).unref();
51
+ } else {
52
+ rmSync(binary, { force: true });
53
+ for (const profile of App.PROFILES) {
54
+ const path = join(homedir(), profile);
55
+ if (existsSync(path)) writeFileSync(path, App.withoutMarked(readFileSync(path, "utf8")));
56
+ }
57
+ }
58
+
59
+ Ui.done("Circuit is uninstalled. Open a new terminal so it is gone from there too.");
60
+ }
61
+
62
+ /** Take a folder out of the user's PATH on Windows. */
63
+ private static removeFromWindowsPath(folder: string): void {
64
+ const script = `$p = [Environment]::GetEnvironmentVariable('Path', 'User'); $n = ($p -split ';' | Where-Object { $_ -and $_.TrimEnd('\\') -ne '${folder.replace(/'/g, "''")}'.TrimEnd('\\') }) -join ';'; [Environment]::SetEnvironmentVariable('Path', $n, 'User')`;
65
+ spawnSync("powershell", ["-NoProfile", "-Command", script], { stdio: "ignore" });
66
+ }
67
+ }