@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,652 @@
1
+ // Huawei VRP, as on the CE series.
2
+ //
3
+ // Configuration reads as blocks indented under their header, `#` between blocks, and `undo` to remove a line. Changes stage in a candidate and take effect only at `commit`, so a refused line leaves nothing applied: the candidate is cleared. `commit` does not write to flash; `save` does, and without it a reboot brings the old configuration back. There is no scheduler to restore from, so access is proved by a fresh login before saving.
4
+
5
+ import type { Client, ClientChannel } from "ssh2";
6
+ import { Address } from "../../core/address";
7
+ import type { Neighbor } from "../../core/cabling";
8
+ import { Secrets } from "../../core/secrets";
9
+ import type { Device } from "../../schema";
10
+ import { ConsoleShell, SerialConsole, type ConsoleDialect } from "../../transport/console";
11
+ import { Ssh } from "../../transport/ssh";
12
+ import { Catalog } from "../catalog";
13
+ import { Derive } from "../derive";
14
+ import { LineDiff, type Dialect, type Group } from "../line-diff";
15
+ import { type ApplyOptions, type OsSupport, type Plan, type ReadOptions, type ReadSession, Platform } from "../platform";
16
+ import { VrpRender } from "./render";
17
+
18
+ export class Vrp extends Platform {
19
+ /** How the console asks for a login: the same question to change the password, always answered no, a system view left with `return`, and the line's banner after a logout or a refused password answered with Enter. Not yet proved on a device. */
20
+ static readonly CONSOLE: ConsoleDialect = {
21
+ login: /Username:\s*$/i,
22
+ password: /Password:\s*$/i,
23
+ prompt: /<[^>\n]+>\s*$/,
24
+ logout: "quit",
25
+ refused: /The password is invalid/i,
26
+ others: [
27
+ [/Please Press ENTER\.?\s*$/i, "\n"],
28
+ [/Change now\? ?\[Y\/N\]:?\s*$/i, "N\n"],
29
+ [/\[~?\*?[^\]\n]+\]\s*$/, "return\n"],
30
+ ],
31
+ };
32
+
33
+ /** Lines the device prints whatever is configured, or that describe the hardware rather than its configuration. */
34
+ private static readonly DEVICE_FACTS = [
35
+ "!",
36
+ "device ",
37
+ "system resource",
38
+ "drop-profile",
39
+ "dcb ",
40
+ "diffserv domain",
41
+ "vlan reserved",
42
+ "interface MEth",
43
+ "interface NULL",
44
+ "stack",
45
+ "vm-manager",
46
+ "return",
47
+ "authentication-scheme default",
48
+ "authorization-scheme default",
49
+ "accounting-scheme default",
50
+ "domain default",
51
+ "Info:",
52
+ // Derived from the switch's MAC once SNMP is on.
53
+ "snmp-agent local-engineid",
54
+ ];
55
+
56
+ /** Lines whose removal takes only their keyword, not their value. */
57
+ private static readonly UNDO_BY_KEYWORD =
58
+ /^(description|name|port default vlan|port link-type|port trunk pvid vlan|set authentication password|eth-trunk|ip binding vpn-instance|ssh server cipher|ssh server hmac|ssh server key-exchange|info-center max-logfile-number|info-center source default channel \d+ log level|info-center loghost \S+|alarm \S+ severity|ip route-static bfd \S+|mac-address limit)\b/;
59
+
60
+ /** Lines a new line of the same kind replaces whole, so the old one is not undone first. */
61
+ private static readonly REPLACED_WHOLE = /^(ip route-static bfd \S+) /;
62
+
63
+ private static readonly DIALECT: Dialect = {
64
+ block: /^(interface |vlan \d+$|acl number |aaa$|user-interface |ospf \d+|ip vpn-instance |bfd$|alarm$)/,
65
+ indented: true,
66
+ terminator: "#",
67
+ ignore: (line) => Vrp.DEVICE_FACTS.some((fact) => line.startsWith(fact)),
68
+ undo: Vrp.undo,
69
+ leave: "quit",
70
+ // The console refuses to drop its password while password login is still on.
71
+ first: /^authentication-mode /,
72
+ canon: Vrp.canon,
73
+ order: Vrp.order,
74
+ };
75
+
76
+ /** The platform offers an ECDSA host key it then signs wrongly, so RSA is asked for. */
77
+ private static readonly HOST_KEYS = ["ssh-rsa"];
78
+
79
+ /** What the device prints when it refuses a line. */
80
+ private static readonly REFUSED = /^\s*Error|Unrecognized command|Incomplete command|Wrong parameter|Too many parameters/m;
81
+
82
+ /** A setting whose password the platform asks for interactively: the command, then the password, then the password again. */
83
+ private static readonly INTERACTIVE =
84
+ /^(snmp-agent usm-user v3 \S+ (?:authentication|privacy)-mode \S+) cipher <secret:([A-Za-z0-9_./:-]+)>$/;
85
+
86
+ private static readonly NOT_SAVED = "Not saved: a reboot restores the previous configuration.";
87
+
88
+ /** Commands that only read. A `?` sent by a script is run, not taken as help, so a line with one is refused. */
89
+ private static readonly READS = /^display\s[^?]*$/;
90
+
91
+ readonly name = "vrp";
92
+
93
+ readonly os: OsSupport = {
94
+ name: "Huawei VRP",
95
+ versions: "V200R005",
96
+ provedOn: "CE6855-48S6Q-HI, V200R005C00SPC800",
97
+ checked: false,
98
+ };
99
+
100
+ readonly extension = ".cfg";
101
+
102
+ override readonly console = Vrp.CONSOLE;
103
+
104
+ unsupported(device: Device): string[] {
105
+ const found: string[] = [];
106
+
107
+ if (device.management?.interfaces) found.push("management.interfaces, which only FortiOS builds for now");
108
+
109
+ for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
110
+ if (iface.type === "bridge") found.push(`interfaces.${name}, a bridge, which only FortiOS builds for now`);
111
+ if (iface.type === "vlan" && iface.mac) found.push(`interfaces.${name}.mac, which only RouterOS sets for now`);
112
+ }
113
+
114
+ for (const [name, port] of Object.entries(device.ports ?? {})) {
115
+ if (!port) continue;
116
+
117
+ for (const field of ["speed", "acl", "mtu", "addresses", "vrrp", "vrf"] as const) {
118
+ if (port[field] !== undefined) found.push(`ports.${name}.${field}, ports are switched; use a VLAN interface`);
119
+ }
120
+
121
+ for (const field of ["mac_limit", "ethertypes"] as const) {
122
+ if (port[field] !== undefined && port.lag) found.push(`ports.${name}.${field} on a LAG member, set it on the LAG`);
123
+ }
124
+
125
+ if (port.lldp === true && !device.lldp) found.push(`ports.${name}.lldp true while LLDP is off on the device`);
126
+ const storm = Object.values(port.storm_control ?? {});
127
+ if (storm.some((threshold) => "pps" in threshold)) found.push(`ports.${name}.storm_control in pps, the platform takes percent`);
128
+ }
129
+
130
+ for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
131
+ if (iface.type !== "vlan" && iface.type !== "lag") found.push(`interfaces.${name}, a ${iface.type}`);
132
+ if (iface.type === "lag" && iface.addresses?.length) found.push(`interfaces.${name}.addresses, LAGs are switched`);
133
+ if (iface.type === "lag" && iface.mac_limit !== undefined) found.push(`interfaces.${name}.mac_limit, on a LAG is not proved`);
134
+ if (iface.type === "lag" && iface.ethertypes) found.push(`interfaces.${name}.ethertypes, on a LAG is not proved`);
135
+ if (iface.mtu !== undefined) found.push(`interfaces.${name}.mtu`);
136
+ if (iface.ipv6_ra) found.push(`interfaces.${name}.ipv6_ra`);
137
+ for (const group of iface.vrrp ?? [])
138
+ if (Address.family(group.address) === "ipv6") found.push(`interfaces.${name}.vrrp for IPv6`);
139
+ }
140
+
141
+ for (const [name, vrf] of Object.entries(device.vrfs ?? {})) {
142
+ if (vrf.description) found.push(`vrfs.${name}.description`);
143
+ }
144
+
145
+ const routing = device.routing;
146
+ if (routing?.bgp) found.push("routing.bgp");
147
+ if (routing?.rpki) found.push("routing.rpki");
148
+ if (routing?.ospf?.families?.includes("ipv6")) found.push("routing.ospf for IPv6");
149
+
150
+ const bfd = routing?.bfd;
151
+ if (bfd && !(Number.isInteger(bfd.interval) && bfd.interval >= 50 && bfd.interval <= 1000))
152
+ found.push("routing.bfd.interval, between 50 and 1000 ms on the platform");
153
+ if (bfd && !(Number.isInteger(bfd.multiplier) && bfd.multiplier >= 3 && bfd.multiplier <= 50))
154
+ found.push("routing.bfd.multiplier, between 3 and 50 on the platform");
155
+
156
+ for (const route of routing?.static ?? []) {
157
+ if (!route.bfd) continue;
158
+
159
+ const where = `routing.static ${route.prefix} bfd`;
160
+ if (route.vrf) found.push(`${where} in a VRF, not proved`);
161
+ else if (!route.via || Address.family(route.via) !== "ipv4") found.push(`${where}, needs an IPv4 via`);
162
+ else if (!VrpRender.localAddressTo(device, route.via))
163
+ found.push(`${where}, the device has no address on the network of ${route.via}`);
164
+ }
165
+
166
+ for (const [index, route] of (routing?.static ?? []).entries()) {
167
+ if (route.description && Address.family(Address.split(route.prefix).addr) === "ipv6")
168
+ found.push(`routing.static[${index}].description for IPv6`);
169
+ }
170
+
171
+ for (const field of ["firewall", "dhcp", "acls", "policies", "prefix_sets", "hardware_offload", "certificates"] as const) {
172
+ if (device[field] !== undefined) found.push(field);
173
+ }
174
+
175
+ for (const [name, relay] of Object.entries(device.dhcp_relay ?? {})) {
176
+ if (device.interfaces?.[relay.interface]?.type !== "vlan")
177
+ found.push(`dhcp_relay.${name}.interface, relays run on VLAN interfaces`);
178
+ }
179
+
180
+ // The platform reserves this name.
181
+ if (device.users && "admin" in device.users) found.push("users.admin, the platform reserves the name");
182
+
183
+ const management = device.management ?? {};
184
+
185
+ for (const field of ["http", "https", "api", "api_tls", "ftp", "native", "privilege_password"] as const) {
186
+ if (management[field]) found.push(`management.${field}`);
187
+ }
188
+
189
+ if (management.ssh?.auth_timeout || management.ssh?.auth_retries) found.push("management.ssh.auth_timeout and auth_retries");
190
+
191
+ if (device.lldp && device.lldp !== true) found.push("lldp.interfaces, use lldp on the ports instead");
192
+
193
+ const system = device.system ?? {};
194
+
195
+ if (system.dns || system.release_channel || system.banner) found.push("system.dns, release_channel and banner");
196
+
197
+ const local = system.logging?.local;
198
+ if (local?.lines_per_file !== undefined)
199
+ found.push("system.logging.local.lines_per_file, the platform sizes a log file in megabytes");
200
+ if (local?.files !== undefined && !(Number.isInteger(local.files) && local.files >= 3 && local.files <= 500))
201
+ found.push("system.logging.local.files, between 3 and 500 on the platform");
202
+
203
+ for (const collector of system.logging?.remote ?? []) {
204
+ if (Address.family(collector.address) === "ipv6") found.push(`system.logging.remote ${collector.address}, IPv6 is not proved`);
205
+ }
206
+
207
+ for (const source of management.snmp?.allow ?? []) {
208
+ if (Address.family(Address.split(source).addr) === "ipv6") found.push(`management.snmp.allow ${source}, IPv6 is not proved`);
209
+ }
210
+
211
+ if (system.transceiver_monitoring !== undefined) found.push("system.transceiver_monitoring");
212
+
213
+ // SNMP takes sources of its own here, in an ACL of its own.
214
+ found.push(...Derive.switchUnsupported(device).filter((field) => !field.startsWith("management.snmp.allow")));
215
+ found.push(...Derive.unproved(device, ["alarm-severity", "bfd-timers", "static-route-bfd", "mac-limit", "ethertype-filter"]));
216
+
217
+ const flow = device.flow_export;
218
+ if (flow && flow.protocol !== "sflow") found.push(`flow_export.protocol ${flow.protocol}, the platform exports sFlow`);
219
+ const sampling = flow?.protocol === "sflow" ? flow.sampling : undefined;
220
+ const samplingInRange = sampling !== undefined && Number.isInteger(sampling) && sampling >= 4096 && sampling <= 4_294_967_295;
221
+
222
+ if (flow?.protocol === "sflow" && !samplingInRange) {
223
+ found.push("flow_export.sampling, required and between 4096 and 4294967295: the platform samples, one packet in 4096 at most");
224
+ }
225
+
226
+ return found;
227
+ }
228
+
229
+ render(device: Device): string {
230
+ return VrpRender.of(device);
231
+ }
232
+
233
+ /** What the device runs, credentials scrubbed. */
234
+ async read(device: Device, _network: readonly Device[] = [], options: ReadOptions = {}): Promise<string> {
235
+ if (options.console) {
236
+ return Vrp.onConsole(device, async (channel) =>
237
+ Vrp.configurationOf(device, await Ssh.send(channel, "display current-configuration", 120_000, 3000))
238
+ );
239
+ }
240
+
241
+ const client = await Vrp.login(device);
242
+
243
+ try {
244
+ const channel = await Vrp.shell(client);
245
+ const dump = await Ssh.send(channel, "display current-configuration", 120_000, 3000);
246
+ channel.end();
247
+
248
+ return Vrp.configurationOf(device, dump);
249
+ } finally {
250
+ client.end();
251
+ }
252
+ }
253
+
254
+ /**
255
+ * What changes, in the order the platform accepts.
256
+ *
257
+ * A VLAN list changes by ID. VLANs no longer used go first, with their VLAN interfaces, so their names and numbers are free before a new VLAN takes them: a VLAN name is unique, and a VLAN with an interface cannot be removed. The whole plan is one candidate, so going first leaves nothing half done.
258
+ */
259
+ plan(desired: string, current: string, options: ApplyOptions): Plan {
260
+ const compared = LineDiff.compare(desired, current, Vrp.DIALECT);
261
+ const groups = options.secrets ? LineDiff.withSecrets(compared, desired, Vrp.DIALECT) : compared;
262
+ LineDiff.vlanListChange(
263
+ groups,
264
+ "vlan batch ",
265
+ Vrp.batchIds,
266
+ (ids) => [`vlan batch ${VrpRender.vlanBatch(ids)}`],
267
+ (ids) => [`vlan batch ${VrpRender.vlanBatch(ids)}`]
268
+ );
269
+
270
+ Vrp.removeLoghosts(groups, desired, current);
271
+ Vrp.replaceRules(groups, current);
272
+ Vrp.emptyAlarmView(groups, current);
273
+ Vrp.keepReplacedWhole(groups);
274
+
275
+ const top = groups.find((group) => group.path.length === 0);
276
+ const removedBatch = top?.remove.find((line) => line.startsWith("vlan batch ")) ?? "";
277
+ const removedIds = removedBatch ? Vrp.batchIds(removedBatch) : [];
278
+
279
+ const firstRemovals = [
280
+ ...removedIds.map((id) => `interface Vlanif${id}`).filter((line) => top?.remove.includes(line)),
281
+ ...removedIds.map((id) => `vlan ${id}`).filter((line) => top?.remove.includes(line)),
282
+ ...(removedBatch ? [removedBatch] : []),
283
+ ];
284
+
285
+ // An ACL still bound to a service cannot be removed, so ACLs go after every binding has moved.
286
+ const lastRemovals = top?.remove.filter((line) => /^acl number \d+$/.test(line)) ?? [];
287
+
288
+ if (top) top.remove = top.remove.filter((line) => !firstRemovals.includes(line) && !lastRemovals.includes(line));
289
+
290
+ const planned = LineDiff.steps(
291
+ groups.filter((group) => group.add.length || group.remove.length),
292
+ Vrp.DIALECT
293
+ );
294
+
295
+ if (firstRemovals.length) {
296
+ // `undo vlan batch` removes a VLAN's own settings with it, so only the interfaces and the list are sent.
297
+ const sent = firstRemovals.filter((line) => !/^vlan \d+$/.test(line)).map(Vrp.undo);
298
+ planned.unshift({ title: "VLANs no longer used", show: firstRemovals.map((line) => `- ${line}`), send: sent });
299
+ }
300
+
301
+ if (lastRemovals.length) {
302
+ planned.push({ title: "ACLs no longer used", show: lastRemovals.map((line) => `- ${line}`), send: lastRemovals.map(Vrp.undo) });
303
+ }
304
+
305
+ return { steps: planned, problems: [] };
306
+ }
307
+
308
+ async apply(device: Device, _network: readonly Device[], plan: Plan, options: ApplyOptions): Promise<void> {
309
+ // On the console, a fresh login on the same console is the proof, and the save is sent in it.
310
+ if (options.console) {
311
+ await Vrp.onConsole(device, async (channel, relogin) => {
312
+ await Vrp.commitPlan(channel, plan);
313
+ await relogin().catch((error: Error) => {
314
+ throw new Error(`committed, but a fresh login on the console failed: ${error.message}. ${Vrp.NOT_SAVED}`);
315
+ });
316
+ await Vrp.save(channel);
317
+ });
318
+
319
+ console.log(" committed, login proved, saved");
320
+ return;
321
+ }
322
+
323
+ const client = await Vrp.login(device);
324
+ const channel = await Vrp.shell(client);
325
+
326
+ try {
327
+ await Vrp.commitPlan(channel, plan);
328
+
329
+ const proof = await Vrp.login(device).catch(() => undefined);
330
+ if (!proof)
331
+ throw new Error(`committed, but a fresh login failed. ${Vrp.NOT_SAVED} Recover over the open session or the console.`);
332
+ proof.end();
333
+
334
+ await Vrp.save(channel);
335
+ } finally {
336
+ channel.end();
337
+ client.end();
338
+ }
339
+
340
+ console.log(" committed, login proved, saved");
341
+ }
342
+
343
+ async neighbors(device: Device, _network: readonly Device[] = [], options: ReadOptions = {}): Promise<Neighbor[]> {
344
+ const session = await this.session(device, options);
345
+
346
+ try {
347
+ return Vrp.neighborsOf(device, await session.run("display lldp neighbor", 60_000));
348
+ } finally {
349
+ session.close();
350
+ }
351
+ }
352
+
353
+ /**
354
+ * The neighbors in `display lldp neighbor`: a header per port, `10GE1/0/2 has 1 neighbor(s):`, then a block per neighbor where `Port ID` comes before `System name`. A port Circuit does not know, such as the management port, is left out.
355
+ */
356
+ static neighborsOf(device: Device, output: string): Neighbor[] {
357
+ const ports = Catalog.ports(device.platform, device.model);
358
+ const byName = new Map(ports.map((port) => [Catalog.spell(device.platform, device.model, port), port]));
359
+
360
+ const neighbors: Neighbor[] = [];
361
+ let port: string | undefined;
362
+ let remotePort: string | undefined;
363
+
364
+ for (const line of output.replace(/\r/g, "").split("\n")) {
365
+ const header = /^(\S+) has \d+ neighbor\(s\)/.exec(line);
366
+ if (header) {
367
+ port = byName.get(header[1]!);
368
+ remotePort = undefined;
369
+ continue;
370
+ }
371
+
372
+ const remote = /^Port ID\s*:(.*)$/.exec(line);
373
+ if (remote) remotePort = remote[1]!.trim();
374
+
375
+ const system = /^System name\s*:(.*)$/.exec(line)?.[1]?.trim();
376
+ if (port && remotePort && system) neighbors.push({ port, system, remote_port: remotePort });
377
+ }
378
+
379
+ return neighbors;
380
+ }
381
+
382
+ readOnly(command: string): boolean {
383
+ return Vrp.READS.test(command);
384
+ }
385
+
386
+ /** One shell for every command, as the platform takes commands only there. */
387
+ async session(device: Device, options: ReadOptions = {}): Promise<ReadSession> {
388
+ if (options.console) {
389
+ const opened = await Vrp.openConsole(device);
390
+ const shell = new ConsoleShell(opened.channel, Vrp.CONSOLE, ConsoleShell.SETTLE);
391
+
392
+ return {
393
+ run: (command, timeout) => shell.run(command, timeout),
394
+ close: () => {
395
+ shell.logout();
396
+ opened.close();
397
+ },
398
+ };
399
+ }
400
+
401
+ const client = await Vrp.login(device);
402
+ const channel = await Vrp.shell(client).catch((error: Error) => {
403
+ client.end();
404
+ throw error;
405
+ });
406
+ const shell = new ConsoleShell(channel, Vrp.CONSOLE, ConsoleShell.SETTLE);
407
+
408
+ return {
409
+ run: (command, timeout) => shell.run(command, timeout),
410
+ close: () => {
411
+ channel.end();
412
+ client.end();
413
+ },
414
+ };
415
+ }
416
+
417
+ private static undo(line: string): string {
418
+ // A negated line is undone by stating it.
419
+ if (line.startsWith("undo ")) return line.slice("undo ".length);
420
+ // An ACL rule is removed by its number.
421
+ if (/^rule \d+/.test(line)) return `undo ${line.split(" ").slice(0, 2).join(" ")}`;
422
+
423
+ const keyword = Vrp.UNDO_BY_KEYWORD.exec(line);
424
+ if (keyword) return `undo ${keyword[1]}`;
425
+ // A static route is removed by its destination and next hop; its preference, description or BFD after them is refused.
426
+ if (/^ipv6 route-static |^ip route-static /.test(line)) return `undo ${line.replace(/ (preference|description|bfd) .*$/, "")}`;
427
+ // Storm control is removed per kind, without its rates.
428
+ if (line.startsWith("storm control ")) return `undo ${line.replace(/ (min-rate|max-rate).*$/, "")}`;
429
+
430
+ return `undo ${line}`;
431
+ }
432
+
433
+ /** Secrets the device prints as ciphers and the render writes as markers compare equal. */
434
+ private static canon(line: string): string {
435
+ return line.replace(/((?:password|community read|-mode \S+) )(?:irreversible-)?cipher \S+/g, "$1cipher <secret>");
436
+ }
437
+
438
+ /** Blocks other blocks refer to come first: VRFs, VLANs, the ACL and users, then device-wide lines, then LAGs, ports, VLAN interfaces, OSPF and logins. */
439
+ private static order(path: string[]): number {
440
+ const header = path[0] ?? "";
441
+ if (!header) return 1;
442
+
443
+ if (/^(ip vpn-instance|vlan |acl number|aaa|bfd)/.test(header)) return 0;
444
+
445
+ if (header.startsWith("interface Eth-Trunk")) return 2;
446
+
447
+ if (header.startsWith("interface Vlanif")) return 4;
448
+
449
+ if (header.startsWith("interface ")) return 3;
450
+
451
+ if (header.startsWith("ospf ")) return 5;
452
+
453
+ return 6;
454
+ }
455
+
456
+ /**
457
+ * A log host the config no longer has is removed. One at the default level prints as `info-center loghost <address>`, whose head is the bare command, so a new log host would otherwise seem to replace it.
458
+ */
459
+ private static removeLoghosts(groups: Group[], desired: string, current: string): void {
460
+ const loghosts = (text: string) => text.split("\n").filter((line) => line.startsWith("info-center loghost "));
461
+ const addressOf = (line: string) => line.split(" ")[2];
462
+ const wanted = new Set(loghosts(desired).map(addressOf));
463
+
464
+ let top = groups.find((group) => group.path.length === 0);
465
+ if (!top) {
466
+ top = { path: [], remove: [], add: [] };
467
+ groups.push(top);
468
+ }
469
+
470
+ for (const line of loghosts(current)) {
471
+ if (!wanted.has(addressOf(line)) && !top.remove.includes(line)) top.remove.push(line);
472
+ }
473
+ }
474
+
475
+ /** An ACL rule sent over a rule of the same number merges into it: `rule 10 deny` over `rule 10 permit source A` leaves `rule 10 deny source A`. A changed rule is undone first. */
476
+ private static replaceRules(groups: Group[], current: string): void {
477
+ const existing = LineDiff.entries(current, Vrp.DIALECT);
478
+
479
+ for (const group of groups) {
480
+ if (!group.path[0]?.startsWith("acl number ")) continue;
481
+
482
+ for (const line of group.add) {
483
+ const rule = /^rule \d+ /.exec(line)?.[0];
484
+ if (!rule) continue;
485
+
486
+ const old = existing.find((entry) => entry.path.join("\n") === group.path.join("\n") && entry.line.startsWith(rule));
487
+ if (old && !group.remove.includes(old.line)) group.remove.push(old.line);
488
+ }
489
+ }
490
+ }
491
+
492
+ /** A static route's BFD template sent again replaces the old one whole, options left out included, so the old one is not undone: a route still uses it. */
493
+ private static keepReplacedWhole(groups: Group[]): void {
494
+ const kind = (line: string) => Vrp.REPLACED_WHOLE.exec(line)?.[1];
495
+
496
+ for (const group of groups) {
497
+ const added = new Set(group.add.map(kind).filter(Boolean));
498
+ group.remove = group.remove.filter((line) => !added.has(kind(line)));
499
+ }
500
+ }
501
+
502
+ /** `undo alarm` is not a command: the `alarm` view goes once each line in it is undone. */
503
+ private static emptyAlarmView(groups: Group[], current: string): void {
504
+ const top = groups.find((group) => group.path.length === 0);
505
+ if (!top?.remove.includes("alarm")) return;
506
+
507
+ top.remove = top.remove.filter((line) => line !== "alarm");
508
+ const lines = LineDiff.entries(current, Vrp.DIALECT).filter((entry) => entry.path[0] === "alarm" && entry.path.length === 1);
509
+ groups.push({ path: ["alarm"], remove: lines.map((entry) => entry.line), add: [] });
510
+ }
511
+
512
+ /** The VLAN IDs of a `vlan batch` line: `vlan batch 10 20 to 30`. */
513
+ private static batchIds(line: string): number[] {
514
+ const words = line.replace(/^vlan batch /, "").split(" ");
515
+ const ids: number[] = [];
516
+
517
+ for (let index = 0; index < words.length; index++) {
518
+ const first = Number(words[index]);
519
+
520
+ if (words[index + 1] === "to") {
521
+ const last = Number(words[index + 2]);
522
+ for (let id = first; id <= last; id++) ids.push(id);
523
+ index += 2;
524
+ } else {
525
+ ids.push(first);
526
+ }
527
+ }
528
+
529
+ return ids;
530
+ }
531
+
532
+ private static async login(device: Device): Promise<Client> {
533
+ const { port, user, password } = Secrets.loginOf(device);
534
+
535
+ return Ssh.connect({
536
+ host: Ssh.hostOf(device),
537
+ port,
538
+ user,
539
+ password,
540
+ hostKeys: Vrp.HOST_KEYS,
541
+ });
542
+ }
543
+
544
+ /**
545
+ * An interactive shell at the user prompt, paging off.
546
+ *
547
+ * Every login asks whether to change the password. Answering yes replaces it at once and every stored credential with it, so the answer is always no. Anything sent before the question is answered is taken as the answer.
548
+ */
549
+ private static async shell(client: Client): Promise<ClientChannel> {
550
+ const channel = await Ssh.shell(client);
551
+ const banner = await Ssh.readUntil(
552
+ channel,
553
+ (text) => /Change now\? ?\[Y\/N\]/i.test(text) || /<[^>]+>\s*$/.test(text),
554
+ 15_000,
555
+ 1500
556
+ );
557
+ if (/Change now\?/i.test(banner)) await Ssh.send(channel, "N", 10_000, 1500);
558
+ await Ssh.send(channel, "screen-length 0 temporary", 10_000, 1500);
559
+ return channel;
560
+ }
561
+
562
+ /** Log in on the console with paging off. Returns the console, a way to log in afresh, and how to let the console go. */
563
+ private static async openConsole(device: Device) {
564
+ const { console, user, password } = SerialConsole.of(device);
565
+ const opened = await SerialConsole.open(console);
566
+
567
+ const login = async () => {
568
+ await SerialConsole.login(opened.channel, Vrp.CONSOLE, user, password, console.passwordOnly);
569
+ await Ssh.send(opened.channel, "screen-length 0 temporary", 10_000, 1500);
570
+ };
571
+
572
+ try {
573
+ await login();
574
+ } catch (error) {
575
+ opened.close();
576
+ throw error;
577
+ }
578
+
579
+ return { channel: opened.channel, login, close: opened.close };
580
+ }
581
+
582
+ /** Log in on the console with paging off, run `work` with a way to log in afresh, and log out. */
583
+ private static async onConsole<T>(
584
+ device: Device,
585
+ work: (channel: ClientChannel, relogin: () => Promise<void>) => Promise<T>
586
+ ): Promise<T> {
587
+ const opened = await Vrp.openConsole(device);
588
+
589
+ try {
590
+ const result = await work(opened.channel, opened.login);
591
+ opened.channel.write(`${Vrp.CONSOLE.logout}\n`);
592
+ return result;
593
+ } finally {
594
+ opened.close();
595
+ }
596
+ }
597
+
598
+ /** The configuration in a dump, without the prompt or the stamp of the last change. */
599
+ private static configurationOf(device: Device, dump: string): string {
600
+ const lines = dump.replace(/\r/g, "").split("\n");
601
+ const start = lines.findIndex((line) => line.startsWith("!Software Version"));
602
+ if (start < 0) throw new Error(`${device.name} returned no configuration`);
603
+
604
+ const body = lines.slice(start).filter((line) => !/^<.*>$/.test(line.trim()) && !line.startsWith("!Last configuration"));
605
+ return LineDiff.scrub(body.join("\n").trim() + "\n");
606
+ }
607
+
608
+ /** Send one line, answering a confirmation, and return what the device said. */
609
+ private static async sendLine(channel: ClientChannel, line: string): Promise<string> {
610
+ const interactive = Vrp.INTERACTIVE.exec(line.trim());
611
+ if (interactive) {
612
+ const [, command, reference] = interactive;
613
+ const password = Secrets.valueOf(reference!);
614
+ let answer = await Ssh.send(channel, command!, 15_000, 900);
615
+ answer += await Ssh.send(channel, password, 15_000, 900);
616
+ answer += await Ssh.send(channel, password, 15_000, 900);
617
+ return answer;
618
+ }
619
+
620
+ let answer = await Ssh.send(channel, Secrets.substitute(line), 20_000, 800);
621
+ if (/\[Y\/N\]/i.test(answer)) answer += await Ssh.send(channel, "Y", 20_000, 800);
622
+ return answer;
623
+ }
624
+
625
+ /** Send a plan into the candidate and commit it. A refused line clears the candidate, which leaves the device as it was. */
626
+ private static async commitPlan(channel: ClientChannel, plan: Plan): Promise<void> {
627
+ try {
628
+ await Ssh.send(channel, "system-view", 10_000, 1500);
629
+
630
+ for (const step of plan.steps) {
631
+ for (const line of step.send) {
632
+ const answer = await Vrp.sendLine(channel, line);
633
+ if (Vrp.REFUSED.test(answer))
634
+ throw new Error(`${step.title}: ${line}\n device said: ${answer.trim().slice(0, 300)}`);
635
+ }
636
+ }
637
+
638
+ const committed = await Ssh.send(channel, "commit", 60_000, 3000);
639
+ if (Vrp.REFUSED.test(committed)) throw new Error(`commit refused: ${committed.trim().slice(0, 300)}`);
640
+ await Ssh.send(channel, "quit", 10_000);
641
+ } catch (error) {
642
+ await Ssh.send(channel, "clear configuration candidate", 10_000).catch(() => undefined);
643
+ throw error;
644
+ }
645
+ }
646
+
647
+ /** Save what runs, so a reboot keeps it. */
648
+ private static async save(channel: ClientChannel): Promise<void> {
649
+ const saving = await Ssh.send(channel, "save", 20_000, 2000);
650
+ if (/\[Y\/N\]/i.test(saving)) await Ssh.send(channel, "Y", 60_000, 5000);
651
+ }
652
+ }