@takodotid/circuit 0.34.3 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. package/README.md +13 -14
  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 +17 -11
  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
@@ -0,0 +1,107 @@
1
+ import { Address } from "../core/address";
2
+ import type { AclRule, FilterRule, Prefix, TcpFlag } from "../schema";
3
+
4
+ /** What `Filters.antiAmplification` leaves alone. */
5
+ export type AntiAmplificationOptions = {
6
+ /** Prefixes inside the destinations whose UDP fragments pass: for a host that receives UDP too large for one packet, such as SIP or some VPNs. Every fragment after the first is dropped when absent. */
7
+ keep_fragments?: readonly Prefix[];
8
+ };
9
+
10
+ /** Address lists and edge filters: hardware ACL rules and firewall rules for traffic from outside. */
11
+ export class Filters {
12
+ /** IPv4 sources that never arrive from the internet: loopback, link-local and "this network", after RFC 6890. */
13
+ static readonly MARTIANS = ["127.0.0.0/8", "169.254.0.0/16", "0.0.0.0/8"] as const satisfies readonly Prefix[];
14
+
15
+ /** IPv4 private space, RFC 1918. */
16
+ static readonly PRIVATE_RANGES = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"] as const satisfies readonly Prefix[];
17
+
18
+ /** UDP ports of services that answer a small request with a much larger reply, which attackers use to reflect floods at a victim. A reflected flood arrives from these ports. */
19
+ static readonly AMPLIFIERS = {
20
+ qotd: 17,
21
+ chargen: 19,
22
+ dns: 53,
23
+ portmap: 111,
24
+ ntp: 123,
25
+ netbios: 137,
26
+ snmp: 161,
27
+ cldap: 389,
28
+ slp: 427,
29
+ rip: 520,
30
+ "ms-sql": 1434,
31
+ ssdp: 1900,
32
+ ard: 3283,
33
+ "ws-discovery": 3702,
34
+ mdns: 5353,
35
+ coap: 5683,
36
+ ubiquiti: 10001,
37
+ memcached: 11211,
38
+ dvr: 37810,
39
+ } as const satisfies Record<string, number>;
40
+
41
+ /** Of the amplifiers, the services hosts do ask across the internet: resolvers, time servers, monitoring. A reply to a real request goes back to the asking port, 1024 or above, or to the service's own port when two servers talk. */
42
+ private static readonly ASKED: ReadonlySet<number> = new Set([Filters.AMPLIFIERS.dns, Filters.AMPLIFIERS.ntp, Filters.AMPLIFIERS.snmp]);
43
+
44
+ /** TCP flag combinations no legitimate packet carries: name, flags set, flags unset. */
45
+ private static readonly BAD_TCP_FLAGS = [
46
+ ["FIN+SYN", ["fin", "syn"], []],
47
+ ["SYN+RST", ["syn", "rst"], []],
48
+ ["FIN+RST", ["fin", "rst"], []],
49
+ ["FIN without ACK", ["fin"], ["ack"]],
50
+ ["FIN+URG", ["fin", "urg"], []],
51
+ ["Xmas scan", ["fin", "syn", "psh", "urg"], []],
52
+ ] as const satisfies readonly (readonly [string, readonly TcpFlag[], readonly TcpFlag[]])[];
53
+
54
+ /** Hardware ACL rules dropping traffic arriving on a VLAN from sources that cannot be there: your own space, martians, private ranges. One rule per source. */
55
+ static antiSpoofing<V extends string>(vlan: V, sources: readonly Prefix[]): AclRule<V>[] {
56
+ return sources.map((src) => ({
57
+ description: `${vlan}: drop source ${src}`,
58
+ match: { vlan, src },
59
+ action: "drop",
60
+ }));
61
+ }
62
+
63
+ /**
64
+ * Hardware ACL rules dropping reflected UDP floods toward `destinations`, in the switch chip, without limiting anything else.
65
+ *
66
+ * - Replies from an amplifier no host asks across the internet are dropped.
67
+ * - Replies from DNS, NTP and SNMP are dropped only toward a port below 1024 that is not the service's own. A host that runs its own resolver, time server or monitoring keeps its answers. The service's own port passes first, so every drop takes the one range 0 to 1023: a switch chip has few port-range checkers, and on RouterOS a rule past them is invalid with every rule after it.
68
+ * - The fragments after the first carry no port, and a switch chip reads them as port 0. They are dropped, but toward `keep_fragments`.
69
+ */
70
+ static antiAmplification(destinations: readonly Prefix[], options: AntiAmplificationOptions = {}): AclRule<never>[] {
71
+ return destinations.flatMap((dst) => [
72
+ ...(options.keep_fragments ?? [])
73
+ .filter((prefix) => Address.contains(dst, prefix))
74
+ .map((prefix): AclRule<never> => ({
75
+ description: `UDP fragments to ${prefix} kept`,
76
+ match: { protocol: "udp", dst: prefix, src_port: 0 },
77
+ action: "accept",
78
+ })),
79
+ { description: `UDP fragments to ${dst}`, match: { protocol: "udp", dst, src_port: 0 }, action: "drop" },
80
+ ...Object.entries(Filters.AMPLIFIERS).flatMap(([name, port]): AclRule<never>[] =>
81
+ Filters.ASKED.has(port)
82
+ ? [
83
+ {
84
+ description: `UDP to ${dst} from ${name}, between two servers`,
85
+ match: { protocol: "udp", dst, src_port: port, dst_port: port },
86
+ action: "accept",
87
+ },
88
+ {
89
+ description: `UDP to ${dst} from ${name}, to a port below 1024`,
90
+ match: { protocol: "udp", dst, src_port: port, dst_port: { min: 0, max: 1023 } },
91
+ action: "drop",
92
+ },
93
+ ]
94
+ : [{ description: `UDP to ${dst} from ${name}`, match: { protocol: "udp", dst, src_port: port }, action: "drop" }]
95
+ ),
96
+ ]);
97
+ }
98
+
99
+ /** Firewall rules dropping packets with a TCP flag combination no legitimate packet carries, for a forward chain. */
100
+ static badTcpFlags(): FilterRule<never, never>[] {
101
+ return Filters.BAD_TCP_FLAGS.map(([name, set, unset]) => ({
102
+ description: `Bad flags ${name}`,
103
+ match: { protocol: "tcp", tcp_flags: { set, unset } },
104
+ action: "drop",
105
+ }));
106
+ }
107
+ }
@@ -0,0 +1,12 @@
1
+ // Building blocks most networks write the same way: address lists, edge filters, a BGP import sanity policy, an exchange's member ports and route servers, checks, and helpers for checks of your own. Each returns plain config to place where it belongs, so nothing here applies itself.
2
+
3
+ export { Bgp } from "./bgp";
4
+ export type { CommunityScheme, SanityLengths } from "./bgp";
5
+ export { Checks } from "./checks";
6
+ export type { TrustBoundary } from "./checks";
7
+ export { Exchange } from "./exchange";
8
+ export type { ExchangeMember, InternetExchange } from "./exchange";
9
+ export { Filters } from "./filters";
10
+ export { RouteServerConfig } from "./route-server";
11
+ export type { RouteServer } from "./route-server";
12
+ export type { AntiAmplificationOptions } from "./filters";
@@ -0,0 +1,212 @@
1
+ import { Address } from "../core/address";
2
+ import type { Family, IPv4, IPv6, Prefix } from "../schema";
3
+ import type { ExchangeMember } from "./exchange";
4
+
5
+ /** A route server on an exchange's peering LAN: a server running BIRD 2 or OpenBGPD that passes every member's routes to every other member. */
6
+ export type RouteServer = {
7
+ /** Its name, such as `rs1`, written at the top of its config. */
8
+ name: string;
9
+ /** Its own AS. Members peer with it, but it never adds itself to a path, so members see each other's paths as they were sent. */
10
+ asn: number;
11
+ /** Its BGP router ID, an IPv4 address of the server. Its address on the peering LAN is the usual pick. */
12
+ router_id: IPv4;
13
+ /** Its IPv4 address on the peering LAN. Every member with an IPv4 address gets an IPv4 session from it. */
14
+ ipv4?: IPv4;
15
+ /** Its IPv6 address on the peering LAN. Every member with an IPv6 address gets an IPv6 session from it. */
16
+ ipv6?: IPv6;
17
+ };
18
+
19
+ /** A route server's config, written from an exchange's member list. Circuit writes the file; it never logs in to the server. */
20
+ export class RouteServerConfig {
21
+ /** The shortest and longest prefix a member may send, by family. */
22
+ static readonly LENGTHS: Readonly<Record<Family, { shortest: number; longest: number }>> = {
23
+ ipv4: { shortest: 8, longest: 24 },
24
+ ipv6: { shortest: 12, longest: 48 },
25
+ };
26
+
27
+ /** The rules a route must pass, the same in every config. */
28
+ private static readonly RULES = [
29
+ "# 1. the first AS in its path is the member's own,",
30
+ "# 2. its next hop is the member's own address on the peering LAN,",
31
+ "# 3. it is not inside the peering LAN,",
32
+ `# 4. it is /${RouteServerConfig.LENGTHS.ipv4.shortest} to /${RouteServerConfig.LENGTHS.ipv4.longest} long for IPv4, /${RouteServerConfig.LENGTHS.ipv6.shortest} to /${RouteServerConfig.LENGTHS.ipv6.longest} for IPv6.`,
33
+ ];
34
+
35
+ /**
36
+ * The BIRD 2 config of one route server: one session per member and family, each passing on only the routes the member may send.
37
+ *
38
+ * Throws when the server has no address on the peering LAN, has one outside it, or two members would get sessions of one name.
39
+ */
40
+ static bird(server: RouteServer, lan: { ipv4?: Prefix; ipv6?: Prefix }, members: readonly ExchangeMember[]): string {
41
+ const families = RouteServerConfig.families(server, lan);
42
+ const sessions: string[] = [];
43
+ const names = new Map<string, string>();
44
+
45
+ for (const member of members) {
46
+ for (const family of families) {
47
+ const neighbor = member[family];
48
+ if (!neighbor) continue;
49
+
50
+ const name = RouteServerConfig.protocolName(member.name, family);
51
+ const holder = names.get(name);
52
+ if (holder) throw new Error(`members ${holder} and ${member.name} would both have session ${name}`);
53
+ names.set(name, member.name);
54
+
55
+ sessions.push(RouteServerConfig.birdSession(name, server, member, family, neighbor, lan[family]!));
56
+ }
57
+ }
58
+
59
+ const header = [
60
+ `# ${server.name}, a route server of the exchange, for BIRD 2.`,
61
+ "# Circuit wrote it from the member list: change the list, not this file.",
62
+ "",
63
+ `router id ${server.router_id};`,
64
+ "",
65
+ "# Learns the server's interfaces and addresses. It sends no route anywhere.",
66
+ "protocol device {",
67
+ "}",
68
+ "",
69
+ "# One session per member and family. A route a member sends is kept only when:",
70
+ ...RouteServerConfig.RULES,
71
+ "# Every member gets every route the others sent, as they sent it.",
72
+ "# A route that is not kept stays visible: birdc show route filtered.",
73
+ ];
74
+
75
+ return [...header, ...sessions.flatMap((session) => ["", session])].join("\n") + "\n";
76
+ }
77
+
78
+ /**
79
+ * The OpenBGPD config (`bgpd.conf`) of one route server: one session per member and family, in one group per family, and filter rules that pass on only the routes each member may send.
80
+ *
81
+ * Throws when the server has no address on the peering LAN, or has one outside it.
82
+ */
83
+ static openbgpd(server: RouteServer, lan: { ipv4?: Prefix; ipv6?: Prefix }, members: readonly ExchangeMember[]): string {
84
+ const families = RouteServerConfig.families(server, lan).filter((family) => members.some((member) => member[family]));
85
+
86
+ const lines = [
87
+ `# ${server.name}, a route server of the exchange, for OpenBGPD.`,
88
+ "# Circuit wrote it from the member list: change the list, not this file.",
89
+ "",
90
+ `AS ${server.asn}`,
91
+ `router-id ${server.router_id}`,
92
+ "",
93
+ "# The server passes routes on and carries no traffic, so it keeps them out of its own routing table.",
94
+ "fib-update no",
95
+ "",
96
+ "# Members see each other's paths as they were sent: the server never adds its own AS, and passes MED and communities on untouched.",
97
+ "transparent-as yes",
98
+ "",
99
+ "# A route that is not kept stays visible: bgpctl show rib filtered.",
100
+ "rde rib Loc-RIB include filtered",
101
+ ];
102
+
103
+ for (const family of families) {
104
+ lines.push("", ...RouteServerConfig.openbgpdGroup(server, family, members));
105
+ }
106
+
107
+ lines.push("", "# A route a member sends is kept only when:", ...RouteServerConfig.RULES);
108
+ lines.push("# The last rule a route matches decides, and a route no rule allows is dropped.");
109
+
110
+ for (const family of families) {
111
+ const { shortest, longest } = RouteServerConfig.LENGTHS[family];
112
+ const any = family === "ipv4" ? "0.0.0.0/0" : "::/0";
113
+
114
+ // `prefixlen 8-24` without spaces is a syntax error in bgpd 9.1, though its manual shows it.
115
+ lines.push(
116
+ `allow from group "${family} members" peer-as neighbor-as nexthop neighbor prefix ${any} prefixlen ${shortest} - ${longest}`
117
+ );
118
+ }
119
+
120
+ for (const family of families) {
121
+ lines.push(`deny from any prefix ${Address.prefixOf(lan[family]!)} or-longer`);
122
+ }
123
+
124
+ lines.push("", "# Every member gets every route the others sent, as they sent it.", "allow to any");
125
+
126
+ return lines.join("\n") + "\n";
127
+ }
128
+
129
+ /** The families the server has an address in, each checked to be inside the peering LAN. */
130
+ private static families(server: RouteServer, lan: { ipv4?: Prefix; ipv6?: Prefix }): Family[] {
131
+ const families = (["ipv4", "ipv6"] as const).filter((family) => server[family]);
132
+ if (!families.length) throw new Error(`route server ${server.name} has no address on the peering LAN`);
133
+
134
+ for (const family of families) {
135
+ const prefix = lan[family];
136
+ const address = server[family]!;
137
+ if (!prefix || !Address.contains(prefix, address))
138
+ throw new Error(`${address} of route server ${server.name} is outside the peering LAN`);
139
+ }
140
+
141
+ return families;
142
+ }
143
+
144
+ /** A BIRD symbol: letters, digits and underscores, starting with a letter. */
145
+ private static protocolName(member: string, family: Family): string {
146
+ const name = `${member.toLowerCase().replace(/[^a-z0-9_]/g, "_")}_${family}`;
147
+ return /^[a-z]/.test(name) ? name : `m_${name}`;
148
+ }
149
+
150
+ /** A member's name and AS, safe inside double quotes. */
151
+ private static description(member: ExchangeMember): string {
152
+ return `"${member.name.replace(/["\\]/g, "")}, AS${member.asn}"`;
153
+ }
154
+
155
+ private static birdSession(
156
+ name: string,
157
+ server: RouteServer,
158
+ member: ExchangeMember,
159
+ family: Family,
160
+ neighbor: string,
161
+ lan: Prefix
162
+ ): string {
163
+ const { shortest, longest } = RouteServerConfig.LENGTHS[family];
164
+
165
+ return [
166
+ `protocol bgp ${name} {`,
167
+ ` description ${RouteServerConfig.description(member)};`,
168
+ ` local ${server[family]} as ${server.asn};`,
169
+ ` neighbor ${neighbor} as ${member.asn};`,
170
+ " rs client;",
171
+ ` ${family} {`,
172
+ " import keep filtered on;",
173
+ " import filter {",
174
+ ` if bgp_path.first != ${member.asn} then reject;`,
175
+ ` if bgp_next_hop != ${neighbor} then reject;`,
176
+ ` if net ~ [ ${Address.prefixOf(lan)}+ ] then reject;`,
177
+ ` if net.len < ${shortest} || net.len > ${longest} then reject;`,
178
+ " accept;",
179
+ " };",
180
+ " export all;",
181
+ " };",
182
+ "}",
183
+ ].join("\n");
184
+ }
185
+
186
+ /** One family's sessions: a group holding every member with an address in that family. */
187
+ private static openbgpdGroup(server: RouteServer, family: Family, members: readonly ExchangeMember[]): string[] {
188
+ const lines = [
189
+ `# One session per member with an ${family === "ipv4" ? "IPv4" : "IPv6"} address.`,
190
+ `group "${family} members" {`,
191
+ ` local-address ${server[family]}`,
192
+ " # A route whose first AS is not the member's own is dropped by the rules below. The default would close the whole session.",
193
+ " enforce neighbor-as no",
194
+ ];
195
+
196
+ for (const member of members) {
197
+ const neighbor = member[family];
198
+ if (!neighbor) continue;
199
+
200
+ lines.push(
201
+ ` neighbor ${neighbor} {`,
202
+ ` remote-as ${member.asn}`,
203
+ ` descr ${RouteServerConfig.description(member)}`,
204
+ " }"
205
+ );
206
+ }
207
+
208
+ lines.push("}");
209
+
210
+ return lines;
211
+ }
212
+ }
package/src/index.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  export { defineDevice, defineNetwork, secret, secretFile } from "./core/define";
2
- export { merge } from "./core/merge";
3
- export type { Check, Community, Finding, Network } from "./core/define";
2
+ export { Utils } from "./core/utils";
3
+ export type { Check, Community, Finding, NetBoxDevice, Network } from "./core/define";
4
4
  export type * from "./schema";
5
5
 
6
6
  /** Address helpers, for checks of your own. */
7
- export { contains, familyOf, host, isAddress, isCidr, isGlobal, network as prefixOf } from "./core/addr";
7
+ export { Address } from "./core/address";
8
+
9
+ /** Secrets, for tools of your own: a secret's value, text with every secret hidden, and how Circuit logs in to a device. */
10
+ export { Secrets } from "./core/secrets";
@@ -0,0 +1,291 @@
1
+ // Keeps LibreNMS in step with the config: every device Circuit manages is monitored there, under its name, polled with the SNMP credentials its config gives it, at the location it reports, and in a group for its site. A device LibreNMS holds that the config does not is reported, never deleted, and so is a group.
2
+ //
3
+ // Written against LibreNMS's API v0 as LibreNMS 26.10 serves it: a device's name is its `display_template`, which LibreNMS 26.5 and later take.
4
+
5
+ import type { Network } from "../core/define";
6
+ import { Secrets } from "../core/secrets";
7
+ import type { Device } from "../schema";
8
+
9
+ /** A device as LibreNMS lists it, with the fields Circuit compares. */
10
+ type Monitored = {
11
+ device_id: number | string;
12
+ hostname: string;
13
+ display?: string | null;
14
+ location?: string | null;
15
+ [field: string]: unknown;
16
+ };
17
+
18
+ /** A device LibreNMS monitors whose settings differ from its config. */
19
+ export type LibreNmsChange = {
20
+ device: Device;
21
+ /** LibreNMS's id for it. */
22
+ id: number;
23
+ /** What differs, in words: `name`, `SNMP credentials` or `location`. Never a secret's value. */
24
+ what: string[];
25
+ /** What is sent: LibreNMS's field names and their values. */
26
+ fields: Record<string, string>;
27
+ };
28
+
29
+ /** A device group for one site, to make or whose devices change. */
30
+ export type LibreNmsGroup = {
31
+ /** The site's name, which is the group's. */
32
+ name: string;
33
+ /** LibreNMS has no group of this name yet. */
34
+ create: boolean;
35
+ /** Devices at the site that are not in the group. */
36
+ add: Device[];
37
+ /** Devices in the group that the config puts at another site. */
38
+ remove: Device[];
39
+ };
40
+
41
+ /** What `librenms` would do. */
42
+ export type LibreNmsPlan = {
43
+ /** Devices to add. */
44
+ additions: Device[];
45
+ /** Devices to change. */
46
+ changes: LibreNmsChange[];
47
+ /** Site groups to make or change. Empty when the network has no `site`. */
48
+ groups: LibreNmsGroup[];
49
+ /** Devices that cannot be added: their config turns SNMP off. */
50
+ withoutSnmp: Device[];
51
+ /** Hostnames LibreNMS holds that no device in the config has. */
52
+ unknown: string[];
53
+ /** Site groups LibreNMS holds as dynamic groups, which Circuit cannot fill, so leaves alone. */
54
+ dynamicGroups: string[];
55
+ /** LibreNMS's id for each device it monitors, by hostname. */
56
+ ids: ReadonlyMap<string, number>;
57
+ };
58
+
59
+ export class LibreNms {
60
+ /** LibreNMS's names for the algorithms. */
61
+ private static readonly AUTH = { sha1: "SHA", sha256: "SHA-256" } as const;
62
+ private static readonly PRIVACY = { aes128: "AES", des: "DES" } as const;
63
+
64
+ /** Compare the config with what LibreNMS monitors. A device is known to LibreNMS by its management address. */
65
+ static async plan(network: Network): Promise<LibreNmsPlan> {
66
+ const settings = LibreNms.settingsOf(network);
67
+
68
+ const listed = await LibreNms.call<{ devices: Monitored[] }>(settings, "GET", "devices?type=all");
69
+ const monitored = new Map(listed.devices.map((device) => [device.hostname, device]));
70
+ const ids = new Map(listed.devices.map((device) => [device.hostname, Number(device.device_id)]));
71
+
72
+ // A device reached only through its console has no address to poll.
73
+ const polled = network.devices.filter((device) => device.connection.host);
74
+ const ours = new Set<string | undefined>(polled.map((device) => device.connection.host));
75
+ const missing = polled.filter((device) => !monitored.has(device.connection.host!));
76
+ const additions = missing.filter((device) => device.management?.snmp);
77
+
78
+ const changes = polled.flatMap((device) => {
79
+ const current = monitored.get(device.connection.host!);
80
+ return current ? LibreNms.changeOf(device, current) : [];
81
+ });
82
+
83
+ const atSites = polled.filter((device) => monitored.has(device.connection.host!) || additions.includes(device));
84
+ const { groups, dynamicGroups } = await LibreNms.groupsOf(network, settings, atSites, ids);
85
+
86
+ return {
87
+ additions,
88
+ changes,
89
+ groups,
90
+ withoutSnmp: missing.filter((device) => !device.management?.snmp),
91
+ unknown: [...monitored.keys()].filter((hostname) => !ours.has(hostname)).sort(),
92
+ dynamicGroups,
93
+ ids,
94
+ };
95
+ }
96
+
97
+ /** How many changes a plan holds. */
98
+ static count(plan: LibreNmsPlan): number {
99
+ return plan.additions.length + plan.changes.length + plan.groups.length;
100
+ }
101
+
102
+ /** Add each device, change each that differs, then make and fill the site groups. */
103
+ static async send(network: Network, plan: LibreNmsPlan): Promise<void> {
104
+ const settings = LibreNms.settingsOf(network);
105
+ const ids = new Map(plan.ids);
106
+
107
+ for (const device of plan.additions) {
108
+ const location = device.management?.snmp?.location;
109
+ const body = {
110
+ hostname: device.connection.host,
111
+ display_template: device.name,
112
+ ...LibreNms.credentials(device),
113
+ ...(location ? { location } : {}),
114
+ };
115
+ const answer = await LibreNms.call<{ message?: string; devices?: Monitored[] }>(settings, "POST", "devices", body);
116
+
117
+ const added = answer.devices?.[0];
118
+ if (added) ids.set(device.connection.host!, Number(added.device_id));
119
+
120
+ console.log(` ${device.name}: ${answer.message}`);
121
+ }
122
+
123
+ for (const change of plan.changes) {
124
+ const fields = Object.keys(change.fields);
125
+ await LibreNms.call(settings, "PATCH", `devices/${change.id}`, {
126
+ field: fields,
127
+ data: fields.map((field) => change.fields[field]),
128
+ });
129
+
130
+ console.log(` ${change.device.name}: changed its ${change.what.join(", ")}`);
131
+ }
132
+
133
+ // A device LibreNMS did not give an id for is left for the next run.
134
+ const idsOf = (devices: readonly Device[]) => devices.flatMap((device) => ids.get(device.connection.host!) ?? []);
135
+
136
+ for (const group of plan.groups) {
137
+ const path = `devicegroups/${encodeURIComponent(group.name)}`;
138
+
139
+ if (group.create) {
140
+ await LibreNms.call(settings, "POST", "devicegroups", {
141
+ name: group.name,
142
+ desc: `The devices at ${group.name}, kept by Circuit`,
143
+ type: "static",
144
+ devices: idsOf(group.add),
145
+ });
146
+ console.log(` made the group ${group.name}`);
147
+ continue;
148
+ }
149
+
150
+ if (group.add.length) await LibreNms.call(settings, "POST", `${path}/devices`, { devices: idsOf(group.add) });
151
+ if (group.remove.length) await LibreNms.call(settings, "DELETE", `${path}/devices`, { devices: idsOf(group.remove) });
152
+
153
+ console.log(` changed the group ${group.name}`);
154
+ }
155
+ }
156
+
157
+ /** What differs between a device's config and LibreNMS's record of it: its name, SNMP credentials and location. */
158
+ private static changeOf(device: Device, current: Monitored): LibreNmsChange[] {
159
+ const what: string[] = [];
160
+ let fields: Record<string, string> = {};
161
+
162
+ if (current.display !== device.name) {
163
+ what.push("name");
164
+ fields.display_template = device.name;
165
+ }
166
+
167
+ const credentials = LibreNms.credentials(device);
168
+ if (credentials && Object.entries(credentials).some(([field, value]) => String(current[field] ?? "") !== value)) {
169
+ what.push("SNMP credentials");
170
+ fields = { ...fields, ...credentials };
171
+ }
172
+
173
+ // LibreNMS reads the location from the device too; setting it now shows it before the next poll.
174
+ const location = device.management?.snmp?.location;
175
+ if (location && current.location !== location) {
176
+ what.push("location");
177
+ fields.location = location;
178
+ }
179
+
180
+ return what.length ? [{ device, id: Number(current.device_id), what, fields }] : [];
181
+ }
182
+
183
+ /** A static group for each site, with the devices at it. Only groups named after a site are read or changed. */
184
+ private static async groupsOf(
185
+ network: Network,
186
+ settings: LibreNmsSettings,
187
+ devices: readonly Device[],
188
+ ids: ReadonlyMap<string, number>
189
+ ): Promise<{ groups: LibreNmsGroup[]; dynamicGroups: string[] }> {
190
+ const siteOf = network.site;
191
+ if (!siteOf) return { groups: [], dynamicGroups: [] };
192
+
193
+ const existing =
194
+ (await LibreNms.call<{ groups?: { name: string; type: string }[] }>(settings, "GET", "devicegroups", undefined, true)).groups ??
195
+ [];
196
+ const sites = [...new Set(devices.map(siteOf))].sort();
197
+
198
+ const groups: LibreNmsGroup[] = [];
199
+ const dynamicGroups: string[] = [];
200
+
201
+ for (const site of sites) {
202
+ const atSite = devices.filter((device) => siteOf(device) === site);
203
+ const found = existing.find((group) => group.name === site);
204
+
205
+ if (!found) {
206
+ groups.push({ name: site, create: true, add: atSite, remove: [] });
207
+ continue;
208
+ }
209
+
210
+ if (found.type !== "static") {
211
+ dynamicGroups.push(site);
212
+ continue;
213
+ }
214
+
215
+ const listed = await LibreNms.call<{ devices?: { device_id: number | string }[] }>(
216
+ settings,
217
+ "GET",
218
+ `devicegroups/${encodeURIComponent(site)}`,
219
+ undefined,
220
+ true
221
+ );
222
+ const members = new Set((listed.devices ?? []).map((member) => Number(member.device_id)));
223
+ const isMember = (device: Device) => members.has(ids.get(device.connection.host!) ?? -1);
224
+
225
+ const add = atSite.filter((device) => !isMember(device));
226
+ const remove = devices.filter((device) => siteOf(device) !== site && isMember(device));
227
+ if (add.length || remove.length) groups.push({ name: site, create: false, add, remove });
228
+ }
229
+
230
+ return { groups, dynamicGroups };
231
+ }
232
+
233
+ private static settingsOf(network: Network): LibreNmsSettings {
234
+ if (!network.librenms) throw new Error("the network has no librenms settings");
235
+
236
+ return {
237
+ base: `${network.librenms.url.replace(/\/+$/, "")}/api/v0`,
238
+ // The header every version of API v0 accepts.
239
+ headers: { "X-Auth-Token": Secrets.reveal(network.librenms.api_token), "Content-Type": "application/json" },
240
+ };
241
+ }
242
+
243
+ /** One request. LibreNMS answers a list with nothing in it with 404, so `emptyOn404` reads that as an empty answer. */
244
+ private static async call<T>(
245
+ settings: LibreNmsSettings,
246
+ method: "GET" | "POST" | "PATCH" | "DELETE",
247
+ path: string,
248
+ body?: unknown,
249
+ emptyOn404 = false
250
+ ): Promise<T> {
251
+ const response = await fetch(`${settings.base}/${path}`, {
252
+ method,
253
+ headers: settings.headers,
254
+ body: body === undefined ? undefined : JSON.stringify(body),
255
+ signal: AbortSignal.timeout(60_000),
256
+ });
257
+
258
+ if (response.status === 404 && emptyOn404) return {} as T;
259
+
260
+ const answer = (await response.json().catch(() => ({}))) as T & { status?: string; message?: string };
261
+ if (!response.ok || answer.status === "error") {
262
+ throw new Error(`LibreNMS refused ${method} ${path}: ${answer.message ?? response.status}`);
263
+ }
264
+
265
+ return answer;
266
+ }
267
+
268
+ /** How LibreNMS polls a device: its first SNMP version 3 user, or else its version 2c community. */
269
+ private static credentials(device: Device): Record<string, string> | undefined {
270
+ const snmp = device.management?.snmp;
271
+ const [name, user] = Object.entries(snmp?.users ?? {})[0] ?? [];
272
+
273
+ if (name && user) {
274
+ return {
275
+ snmpver: "v3",
276
+ authlevel: "authPriv",
277
+ authname: name,
278
+ authpass: Secrets.reveal(user.auth_password),
279
+ authalgo: LibreNms.AUTH[user.auth],
280
+ cryptopass: Secrets.reveal(user.privacy_password),
281
+ cryptoalgo: LibreNms.PRIVACY[user.privacy],
282
+ };
283
+ }
284
+
285
+ if (snmp?.community) return { snmpver: "v2c", community: Secrets.reveal(snmp.community) };
286
+ return undefined;
287
+ }
288
+ }
289
+
290
+ /** Where LibreNMS's API is, and the headers every request sends. */
291
+ type LibreNmsSettings = { base: string; headers: Record<string, string> };