@takodotid/circuit 0.43.1 → 1.0.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (256) 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 +71 -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 +350 -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/render.ts +69 -62
  63. package/src/platforms/fortios/routing.ts +89 -58
  64. package/src/platforms/fortios/vdoms.ts +47 -16
  65. package/src/platforms/junos/TRAPS.md +2 -2
  66. package/src/platforms/junos/bgp.ts +63 -42
  67. package/src/platforms/junos/index.ts +122 -89
  68. package/src/platforms/junos/render.ts +139 -104
  69. package/src/platforms/platform.ts +10 -8
  70. package/src/platforms/raisecom-ros/TRAPS.md +1 -1
  71. package/src/platforms/raisecom-ros/index.ts +89 -70
  72. package/src/platforms/raisecom-ros/render.ts +49 -45
  73. package/src/platforms/routeros/TRAPS.md +3 -3
  74. package/src/platforms/routeros/index.ts +121 -60
  75. package/src/platforms/routeros/render.ts +214 -195
  76. package/src/platforms/vrp/TRAPS.md +1 -1
  77. package/src/platforms/vrp/index.ts +74 -50
  78. package/src/platforms/vrp/render.ts +67 -56
  79. package/src/project/project.ts +725 -0
  80. package/src/studio/app/env.d.ts +9 -0
  81. package/src/studio/app/index.html +18 -0
  82. package/src/studio/app/src/App.vue +95 -0
  83. package/src/studio/app/src/components/AddressesView.vue +38 -0
  84. package/src/studio/app/src/components/CablesView.vue +29 -0
  85. package/src/studio/app/src/components/CompareView.vue +160 -0
  86. package/src/studio/app/src/components/DataTable.vue +49 -0
  87. package/src/studio/app/src/components/DeviceView.vue +237 -0
  88. package/src/studio/app/src/components/DevicesView.vue +32 -0
  89. package/src/studio/app/src/components/Faceplate.vue +75 -0
  90. package/src/studio/app/src/components/GraphView.vue +282 -0
  91. package/src/studio/app/src/components/LineDiff.vue +24 -0
  92. package/src/studio/app/src/components/ProblemsView.vue +42 -0
  93. package/src/studio/app/src/components/SearchView.vue +72 -0
  94. package/src/studio/app/src/components/SecretsView.vue +112 -0
  95. package/src/studio/app/src/components/SessionsView.vue +35 -0
  96. package/src/studio/app/src/components/Settings.vue +95 -0
  97. package/src/studio/app/src/components/ToolsView.vue +118 -0
  98. package/src/studio/app/src/components/VlansView.vue +50 -0
  99. package/src/studio/app/src/lib/csv.ts +19 -0
  100. package/src/studio/{public/diff.js → app/src/lib/diff.ts} +40 -35
  101. package/src/studio/app/src/lib/format.ts +38 -0
  102. package/src/studio/app/src/lib/net.ts +68 -0
  103. package/src/studio/app/src/lib/secrets-crypto.ts +35 -0
  104. package/src/studio/app/src/lib/store.ts +71 -0
  105. package/src/studio/app/src/lib/tools.ts +217 -0
  106. package/src/studio/app/src/main.ts +9 -0
  107. package/src/studio/{public → app/src}/style.css +14 -4
  108. package/src/studio/app/tsconfig.json +17 -0
  109. package/src/studio/app/vite.config.ts +16 -0
  110. package/src/studio/assets.generated.ts +3 -0
  111. package/src/studio/data.ts +96 -188
  112. package/src/studio/git.ts +27 -43
  113. package/src/studio/server.ts +196 -107
  114. package/src/studio/types.ts +141 -0
  115. package/src/transport/console.ts +3 -3
  116. package/src/transport/ssh.ts +1 -1
  117. package/dist/chunks/index-3b343xva.js +0 -177
  118. package/dist/chunks/index-6wb41q67.js +0 -62
  119. package/dist/chunks/index-n7w3tg5y.js +0 -149
  120. package/dist/chunks/index-yzw86rc8.js +0 -921
  121. package/dist/chunks/new-w3zys5y4.js +0 -172
  122. package/dist/chunks/server-cxr9ynxc.js +0 -259
  123. package/dist/cli/index.js +0 -12922
  124. package/dist/helpers/index.js +0 -881
  125. package/dist/index.js +0 -28
  126. package/dist/types/core/address-space.d.ts +0 -62
  127. package/dist/types/core/address.d.ts +0 -44
  128. package/dist/types/core/cabling.d.ts +0 -41
  129. package/dist/types/core/cross-checks.d.ts +0 -28
  130. package/dist/types/core/define.d.ts +0 -79
  131. package/dist/types/core/project-files.d.ts +0 -12
  132. package/dist/types/core/schema-docs.d.ts +0 -25
  133. package/dist/types/core/secrets.d.ts +0 -62
  134. package/dist/types/core/topology.d.ts +0 -96
  135. package/dist/types/core/utils.d.ts +0 -10
  136. package/dist/types/core/validator.d.ts +0 -23
  137. package/dist/types/helpers/bgp.d.ts +0 -195
  138. package/dist/types/helpers/cables.d.ts +0 -69
  139. package/dist/types/helpers/checks.d.ts +0 -36
  140. package/dist/types/helpers/exchange.d.ts +0 -55
  141. package/dist/types/helpers/filters.d.ts +0 -51
  142. package/dist/types/helpers/index.d.ts +0 -14
  143. package/dist/types/helpers/route-server.d.ts +0 -52
  144. package/dist/types/helpers/upstreams.d.ts +0 -192
  145. package/dist/types/index.d.ts +0 -10
  146. package/dist/types/integrations/librenms.d.ts +0 -60
  147. package/dist/types/integrations/netbox.d.ts +0 -89
  148. package/dist/types/integrations/peeringdb.d.ts +0 -24
  149. package/dist/types/integrations/prometheus.d.ts +0 -24
  150. package/dist/types/integrations/registries.d.ts +0 -8
  151. package/dist/types/platforms/bgp-wait.d.ts +0 -24
  152. package/dist/types/platforms/catalog.d.ts +0 -109
  153. package/dist/types/platforms/derive.d.ts +0 -17
  154. package/dist/types/platforms/fortios/firewall.d.ts +0 -47
  155. package/dist/types/platforms/fortios/index.d.ts +0 -140
  156. package/dist/types/platforms/fortios/models.d.ts +0 -14
  157. package/dist/types/platforms/fortios/names.d.ts +0 -52
  158. package/dist/types/platforms/fortios/plan.d.ts +0 -114
  159. package/dist/types/platforms/fortios/render.d.ts +0 -77
  160. package/dist/types/platforms/fortios/routing.d.ts +0 -56
  161. package/dist/types/platforms/fortios/tree.d.ts +0 -37
  162. package/dist/types/platforms/fortios/vdoms.d.ts +0 -50
  163. package/dist/types/platforms/index.d.ts +0 -13
  164. package/dist/types/platforms/junos/bgp.d.ts +0 -44
  165. package/dist/types/platforms/junos/index.d.ts +0 -114
  166. package/dist/types/platforms/junos/models.d.ts +0 -14
  167. package/dist/types/platforms/junos/render.d.ts +0 -154
  168. package/dist/types/platforms/line-diff.d.ts +0 -67
  169. package/dist/types/platforms/platform.d.ts +0 -135
  170. package/dist/types/platforms/raisecom-ros/index.d.ts +0 -71
  171. package/dist/types/platforms/raisecom-ros/models.d.ts +0 -14
  172. package/dist/types/platforms/raisecom-ros/render.d.ts +0 -41
  173. package/dist/types/platforms/routeros/export.d.ts +0 -59
  174. package/dist/types/platforms/routeros/index.d.ts +0 -116
  175. package/dist/types/platforms/routeros/models.d.ts +0 -18
  176. package/dist/types/platforms/routeros/plan.d.ts +0 -58
  177. package/dist/types/platforms/routeros/render.d.ts +0 -250
  178. package/dist/types/platforms/vrp/index.d.ts +0 -83
  179. package/dist/types/platforms/vrp/models.d.ts +0 -12
  180. package/dist/types/platforms/vrp/render.d.ts +0 -96
  181. package/dist/types/schema/common.d.ts +0 -30
  182. package/dist/types/schema/device.d.ts +0 -126
  183. package/dist/types/schema/firewall.d.ts +0 -131
  184. package/dist/types/schema/index.d.ts +0 -8
  185. package/dist/types/schema/interfaces.d.ts +0 -205
  186. package/dist/types/schema/policy.d.ts +0 -107
  187. package/dist/types/schema/routing.d.ts +0 -161
  188. package/dist/types/schema/system.d.ts +0 -198
  189. package/dist/types/schema/time-zones.d.ts +0 -2
  190. package/dist/types/transport/console.d.ts +0 -115
  191. package/dist/types/transport/path-check.d.ts +0 -32
  192. package/dist/types/transport/ssh.d.ts +0 -27
  193. package/src/cli/graph-format.ts +0 -91
  194. package/src/cli/new.ts +0 -234
  195. package/src/core/address-space.ts +0 -320
  196. package/src/core/cross-checks.ts +0 -369
  197. package/src/core/define.ts +0 -124
  198. package/src/core/project-files.ts +0 -35
  199. package/src/core/schema-docs.ts +0 -174
  200. package/src/core/validator.ts +0 -508
  201. package/src/helpers/bgp.ts +0 -473
  202. package/src/helpers/cables.ts +0 -74
  203. package/src/helpers/checks.ts +0 -201
  204. package/src/helpers/exchange.ts +0 -123
  205. package/src/helpers/filters.ts +0 -107
  206. package/src/helpers/index.ts +0 -25
  207. package/src/helpers/route-server.ts +0 -212
  208. package/src/helpers/upstreams.ts +0 -369
  209. package/src/index.ts +0 -13
  210. package/src/integrations/librenms.ts +0 -291
  211. package/src/integrations/netbox.ts +0 -620
  212. package/src/integrations/peeringdb.ts +0 -173
  213. package/src/integrations/prometheus.ts +0 -90
  214. package/src/schema/common.ts +0 -41
  215. package/src/schema/device.ts +0 -145
  216. package/src/schema/firewall.ts +0 -140
  217. package/src/schema/index.ts +0 -8
  218. package/src/schema/policy.ts +0 -115
  219. package/src/schema/routing.ts +0 -173
  220. package/src/schema/system.ts +0 -203
  221. package/src/studio/public/app.js +0 -186
  222. package/src/studio/public/compare.js +0 -250
  223. package/src/studio/public/dom.js +0 -138
  224. package/src/studio/public/evaluate.js +0 -687
  225. package/src/studio/public/export.js +0 -130
  226. package/src/studio/public/graph.js +0 -1541
  227. package/src/studio/public/index.html +0 -83
  228. package/src/studio/public/net.js +0 -134
  229. package/src/studio/public/tables.js +0 -1289
  230. package/src/studio/public/tools.js +0 -610
  231. package/templates/colocation/checks.ts +0 -53
  232. package/templates/colocation/circuit.config.ts +0 -9
  233. package/templates/colocation/edge-01.ts +0 -133
  234. package/templates/colocation/routing.ts +0 -13
  235. package/templates/colocation/tenancy.ts +0 -76
  236. package/templates/colocation/tenants/acme.ts +0 -19
  237. package/templates/colocation/tenants/index.ts +0 -6
  238. package/templates/colocation/tor-01.ts +0 -33
  239. package/templates/edge-router/checks.ts +0 -20
  240. package/templates/edge-router/circuit.config.ts +0 -8
  241. package/templates/edge-router/edge-01.ts +0 -110
  242. package/templates/edge-router/routing.ts +0 -13
  243. package/templates/project/AGENTS.md +0 -51
  244. package/templates/project/CLAUDE.md +0 -1
  245. package/templates/project/README.md +0 -16
  246. package/templates/project/gitignore +0 -5
  247. package/templates/project/tsconfig.json +0 -13
  248. package/templates/single-site/checks.ts +0 -26
  249. package/templates/single-site/circuit.config.ts +0 -8
  250. package/templates/single-site/router.ts +0 -86
  251. package/templates/single-site/site.ts +0 -18
  252. package/templates/single-site/switch.ts +0 -34
  253. /package/src/studio/{public → app/public}/favicon.svg +0 -0
  254. /package/src/studio/{public → app/public}/special-gothic-latin-ext.woff2 +0 -0
  255. /package/src/studio/{public → app/public}/special-gothic-latin.woff2 +0 -0
  256. /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,71 @@
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: it tries
46
+ // again until the file goes, for about ten seconds. Only cmd's own commands: a program it started, such as ping to wait,
47
+ // would open a console window. The line goes to cmd as written; quoted the usual way, cmd can't read the paths.
48
+ const remove = `del /f /q "${binary}" "${binary}.old" 2>nul & if not exist "${binary}" (rmdir "${folder}" 2>nul & exit)`;
49
+ spawn("cmd", ["/d", "/c", `for /l %i in (1,1,10000) do @(${remove})`], {
50
+ detached: true,
51
+ stdio: "ignore",
52
+ windowsHide: true,
53
+ windowsVerbatimArguments: true,
54
+ }).unref();
55
+ } else {
56
+ rmSync(binary, { force: true });
57
+ for (const profile of App.PROFILES) {
58
+ const path = join(homedir(), profile);
59
+ if (existsSync(path)) writeFileSync(path, App.withoutMarked(readFileSync(path, "utf8")));
60
+ }
61
+ }
62
+
63
+ Ui.done("Circuit is uninstalled. Open a new terminal so it is gone from there too.");
64
+ }
65
+
66
+ /** Take a folder out of the user's PATH on Windows. */
67
+ private static removeFromWindowsPath(folder: string): void {
68
+ 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')`;
69
+ spawnSync("powershell", ["-NoProfile", "-Command", script], { stdio: "ignore" });
70
+ }
71
+ }