@takodotid/circuit 0.34.3 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. package/README.md +14 -15
  2. package/dist/chunks/index-20kcjxjg.js +174 -0
  3. package/dist/chunks/index-2r4bbhac.js +977 -0
  4. package/dist/chunks/index-n7w3tg5y.js +149 -0
  5. package/dist/chunks/new-w3zys5y4.js +172 -0
  6. package/dist/chunks/server-gzsf5me5.js +259 -0
  7. package/dist/cli/index.js +11345 -9024
  8. package/dist/helpers/index.js +594 -0
  9. package/dist/index.js +9 -28
  10. package/dist/types/core/{addresses.d.ts → address-space.d.ts} +23 -7
  11. package/dist/types/core/address.d.ts +44 -0
  12. package/dist/types/core/cabling.d.ts +41 -0
  13. package/dist/types/core/cross-checks.d.ts +28 -0
  14. package/dist/types/core/define.d.ts +25 -3
  15. package/dist/types/core/project-files.d.ts +12 -0
  16. package/dist/types/core/schema-docs.d.ts +13 -4
  17. package/dist/types/core/secrets.d.ts +62 -21
  18. package/dist/types/core/{graph.d.ts → topology.d.ts} +32 -12
  19. package/dist/types/core/utils.d.ts +8 -0
  20. package/dist/types/core/validator.d.ts +19 -0
  21. package/dist/types/helpers/bgp.d.ts +97 -0
  22. package/dist/types/helpers/checks.d.ts +32 -0
  23. package/dist/types/helpers/exchange.d.ts +49 -0
  24. package/dist/types/helpers/filters.d.ts +51 -0
  25. package/dist/types/helpers/index.d.ts +10 -0
  26. package/dist/types/helpers/route-server.d.ts +52 -0
  27. package/dist/types/index.d.ts +5 -3
  28. package/dist/types/integrations/librenms.d.ts +60 -0
  29. package/dist/types/integrations/netbox.d.ts +89 -0
  30. package/dist/types/integrations/peeringdb.d.ts +24 -0
  31. package/dist/types/integrations/prometheus.d.ts +24 -0
  32. package/dist/types/integrations/registries.d.ts +8 -0
  33. package/dist/types/platforms/bgp-wait.d.ts +18 -0
  34. package/dist/types/platforms/catalog.d.ts +109 -0
  35. package/dist/types/platforms/derive.d.ts +17 -0
  36. package/dist/types/platforms/fortios/firewall.d.ts +45 -0
  37. package/dist/types/platforms/fortios/index.d.ts +134 -0
  38. package/dist/types/platforms/fortios/names.d.ts +52 -0
  39. package/dist/types/platforms/fortios/plan.d.ts +104 -0
  40. package/dist/types/platforms/fortios/render.d.ts +75 -0
  41. package/dist/types/platforms/fortios/routing.d.ts +56 -0
  42. package/dist/types/platforms/fortios/tree.d.ts +37 -0
  43. package/dist/types/platforms/index.d.ts +13 -0
  44. package/dist/types/platforms/junos/bgp.d.ts +44 -0
  45. package/dist/types/platforms/junos/index.d.ts +114 -0
  46. package/dist/types/platforms/junos/render.d.ts +154 -0
  47. package/dist/types/platforms/line-diff.d.ts +67 -0
  48. package/dist/types/platforms/platform.d.ts +120 -0
  49. package/dist/types/platforms/raisecom-ros/index.d.ts +71 -0
  50. package/dist/types/platforms/raisecom-ros/render.d.ts +41 -0
  51. package/dist/types/platforms/routeros/export.d.ts +59 -0
  52. package/dist/types/platforms/routeros/index.d.ts +96 -0
  53. package/dist/types/{adapters/devices → platforms}/routeros/models.d.ts +4 -0
  54. package/dist/types/platforms/routeros/plan.d.ts +54 -0
  55. package/dist/types/platforms/routeros/render.d.ts +240 -0
  56. package/dist/types/platforms/vrp/index.d.ts +83 -0
  57. package/dist/types/platforms/vrp/render.d.ts +96 -0
  58. package/dist/types/schema/common.d.ts +12 -6
  59. package/dist/types/schema/index.d.ts +1 -0
  60. package/dist/types/schema/interfaces.d.ts +14 -6
  61. package/dist/types/schema/policy.d.ts +18 -10
  62. package/dist/types/schema/routing.d.ts +14 -1
  63. package/dist/types/schema/system.d.ts +16 -6
  64. package/dist/types/schema/time-zones.d.ts +2 -0
  65. package/dist/types/transport/console.d.ts +79 -23
  66. package/dist/types/transport/path-check.d.ts +32 -0
  67. package/dist/types/transport/ssh.d.ts +17 -15
  68. package/package.json +18 -12
  69. package/src/cli/graph-format.ts +91 -0
  70. package/src/cli/index.ts +1074 -484
  71. package/src/cli/new.ts +208 -175
  72. package/src/cli/rename.ts +97 -91
  73. package/src/core/address-space.ts +320 -0
  74. package/src/core/address.ts +202 -0
  75. package/src/core/cabling.ts +154 -0
  76. package/src/core/cross-checks.ts +364 -0
  77. package/src/core/define.ts +26 -3
  78. package/src/core/project-files.ts +35 -0
  79. package/src/core/schema-docs.ts +138 -130
  80. package/src/core/secrets.ts +170 -76
  81. package/src/core/topology.ts +656 -0
  82. package/src/core/utils.ts +37 -0
  83. package/src/core/validator.ts +407 -0
  84. package/src/helpers/bgp.ts +301 -0
  85. package/src/helpers/checks.ts +178 -0
  86. package/src/helpers/exchange.ts +117 -0
  87. package/src/helpers/filters.ts +107 -0
  88. package/src/helpers/index.ts +12 -0
  89. package/src/helpers/route-server.ts +212 -0
  90. package/src/index.ts +6 -3
  91. package/src/integrations/librenms.ts +291 -0
  92. package/src/integrations/netbox.ts +620 -0
  93. package/src/integrations/peeringdb.ts +173 -0
  94. package/src/integrations/prometheus.ts +90 -0
  95. package/src/integrations/registries.ts +25 -0
  96. package/src/platforms/bgp-wait.ts +41 -0
  97. package/src/platforms/catalog.ts +69 -0
  98. package/src/platforms/derive.ts +117 -0
  99. package/src/{adapters/devices → platforms}/fortios/TRAPS.md +6 -0
  100. package/src/platforms/fortios/firewall.ts +377 -0
  101. package/src/platforms/fortios/index.ts +1060 -0
  102. package/src/platforms/fortios/names.ts +130 -0
  103. package/src/platforms/fortios/plan.ts +929 -0
  104. package/src/platforms/fortios/render.ts +779 -0
  105. package/src/platforms/fortios/routing.ts +476 -0
  106. package/src/platforms/fortios/tree.ts +129 -0
  107. package/src/platforms/index.ts +36 -0
  108. package/src/{adapters/devices → platforms}/junos/TRAPS.md +8 -0
  109. package/src/platforms/junos/bgp.ts +293 -0
  110. package/src/platforms/junos/index.ts +1006 -0
  111. package/src/platforms/junos/render.ts +967 -0
  112. package/src/platforms/line-diff.ts +244 -0
  113. package/src/platforms/platform.ts +122 -0
  114. package/src/platforms/raisecom-ros/TRAPS.md +40 -0
  115. package/src/platforms/raisecom-ros/index.ts +731 -0
  116. package/src/platforms/raisecom-ros/render.ts +328 -0
  117. package/src/{adapters/devices → platforms}/routeros/TRAPS.md +16 -2
  118. package/src/platforms/routeros/export.ts +342 -0
  119. package/src/platforms/routeros/index.ts +636 -0
  120. package/src/{adapters/devices → platforms}/routeros/models.ts +2 -0
  121. package/src/platforms/routeros/plan.ts +444 -0
  122. package/src/platforms/routeros/render.ts +1483 -0
  123. package/src/platforms/vrp/TRAPS.md +45 -0
  124. package/src/platforms/vrp/index.ts +652 -0
  125. package/src/platforms/vrp/render.ts +615 -0
  126. package/src/schema/common.ts +15 -6
  127. package/src/schema/index.ts +1 -0
  128. package/src/schema/interfaces.ts +15 -5
  129. package/src/schema/policy.ts +22 -10
  130. package/src/schema/routing.ts +16 -1
  131. package/src/schema/system.ts +16 -6
  132. package/src/schema/time-zones.ts +482 -0
  133. package/src/studio/data.ts +172 -168
  134. package/src/studio/git.ts +95 -90
  135. package/src/studio/public/app.js +161 -150
  136. package/src/studio/public/compare.js +219 -212
  137. package/src/studio/public/diff.js +77 -75
  138. package/src/studio/public/dom.js +118 -108
  139. package/src/studio/public/evaluate.js +584 -545
  140. package/src/studio/public/export.js +120 -118
  141. package/src/studio/public/graph.js +1343 -1266
  142. package/src/studio/public/net.js +115 -109
  143. package/src/studio/public/tables.js +1135 -1083
  144. package/src/studio/public/tools.js +561 -529
  145. package/src/studio/server.ts +202 -196
  146. package/src/transport/console.ts +363 -271
  147. package/src/transport/path-check.ts +91 -0
  148. package/src/transport/ssh.ts +98 -94
  149. package/templates/colocation/checks.ts +11 -6
  150. package/templates/colocation/edge-01.ts +9 -9
  151. package/templates/colocation/routing.ts +2 -2
  152. package/templates/colocation/tenancy.ts +3 -3
  153. package/templates/colocation/tor-01.ts +3 -3
  154. package/templates/edge-router/checks.ts +4 -4
  155. package/templates/edge-router/edge-01.ts +5 -5
  156. package/templates/edge-router/routing.ts +2 -2
  157. package/templates/project/AGENTS.md +5 -1
  158. package/templates/single-site/checks.ts +2 -2
  159. package/dist/chunks/index-47debt16.js +0 -956
  160. package/dist/chunks/index-v8zgr7wm.js +0 -128
  161. package/dist/chunks/new-ny89s7y7.js +0 -166
  162. package/dist/chunks/server-tjbb3rqc.js +0 -251
  163. package/dist/presets/index.js +0 -394
  164. package/dist/types/adapters/devices/catalog.d.ts +0 -86
  165. package/dist/types/adapters/devices/derive.d.ts +0 -7
  166. package/dist/types/adapters/devices/fortios/firewall.d.ts +0 -23
  167. package/dist/types/adapters/devices/fortios/index.d.ts +0 -58
  168. package/dist/types/adapters/devices/fortios/names.d.ts +0 -48
  169. package/dist/types/adapters/devices/fortios/plan.d.ts +0 -28
  170. package/dist/types/adapters/devices/fortios/render.d.ts +0 -38
  171. package/dist/types/adapters/devices/fortios/routing.d.ts +0 -32
  172. package/dist/types/adapters/devices/fortios/tree.d.ts +0 -33
  173. package/dist/types/adapters/devices/index.d.ts +0 -3
  174. package/dist/types/adapters/devices/junos/bgp.d.ts +0 -11
  175. package/dist/types/adapters/devices/junos/index.d.ts +0 -42
  176. package/dist/types/adapters/devices/junos/render.d.ts +0 -52
  177. package/dist/types/adapters/devices/lines.d.ts +0 -56
  178. package/dist/types/adapters/devices/raisecom-ros/index.d.ts +0 -28
  179. package/dist/types/adapters/devices/routeros/export.d.ts +0 -39
  180. package/dist/types/adapters/devices/routeros/index.d.ts +0 -30
  181. package/dist/types/adapters/devices/routeros/plan.d.ts +0 -4
  182. package/dist/types/adapters/devices/routeros/render.d.ts +0 -100
  183. package/dist/types/adapters/devices/types.d.ts +0 -40
  184. package/dist/types/adapters/devices/vrp/index.d.ts +0 -27
  185. package/dist/types/adapters/registries/index.d.ts +0 -3
  186. package/dist/types/adapters/registries/librenms.d.ts +0 -15
  187. package/dist/types/adapters/registries/peeringdb.d.ts +0 -16
  188. package/dist/types/adapters/registries/prometheus.d.ts +0 -16
  189. package/dist/types/core/across.d.ts +0 -4
  190. package/dist/types/core/addr.d.ts +0 -34
  191. package/dist/types/core/files.d.ts +0 -6
  192. package/dist/types/core/merge.d.ts +0 -4
  193. package/dist/types/core/time.d.ts +0 -2
  194. package/dist/types/core/validate.d.ts +0 -2
  195. package/dist/types/presets/index.d.ts +0 -183
  196. package/src/adapters/devices/catalog.ts +0 -41
  197. package/src/adapters/devices/derive.ts +0 -59
  198. package/src/adapters/devices/fortios/firewall.ts +0 -362
  199. package/src/adapters/devices/fortios/index.ts +0 -923
  200. package/src/adapters/devices/fortios/names.ts +0 -115
  201. package/src/adapters/devices/fortios/plan.ts +0 -888
  202. package/src/adapters/devices/fortios/render.ts +0 -768
  203. package/src/adapters/devices/fortios/routing.ts +0 -444
  204. package/src/adapters/devices/fortios/tree.ts +0 -121
  205. package/src/adapters/devices/index.ts +0 -9
  206. package/src/adapters/devices/junos/bgp.ts +0 -269
  207. package/src/adapters/devices/junos/index.ts +0 -862
  208. package/src/adapters/devices/junos/render.ts +0 -872
  209. package/src/adapters/devices/lines.ts +0 -212
  210. package/src/adapters/devices/raisecom-ros/TRAPS.md +0 -26
  211. package/src/adapters/devices/raisecom-ros/index.ts +0 -700
  212. package/src/adapters/devices/routeros/export.ts +0 -321
  213. package/src/adapters/devices/routeros/index.ts +0 -346
  214. package/src/adapters/devices/routeros/plan.ts +0 -398
  215. package/src/adapters/devices/routeros/render.ts +0 -1490
  216. package/src/adapters/devices/types.ts +0 -38
  217. package/src/adapters/devices/vrp/TRAPS.md +0 -26
  218. package/src/adapters/devices/vrp/index.ts +0 -865
  219. package/src/adapters/registries/index.ts +0 -19
  220. package/src/adapters/registries/librenms.ts +0 -92
  221. package/src/adapters/registries/peeringdb.ts +0 -169
  222. package/src/adapters/registries/prometheus.ts +0 -71
  223. package/src/cli/graph.ts +0 -89
  224. package/src/core/across.ts +0 -350
  225. package/src/core/addr.ts +0 -174
  226. package/src/core/addresses.ts +0 -316
  227. package/src/core/files.ts +0 -32
  228. package/src/core/graph.ts +0 -640
  229. package/src/core/merge.ts +0 -19
  230. package/src/core/time.ts +0 -15
  231. package/src/core/validate.ts +0 -348
  232. package/src/presets/index.ts +0 -579
  233. /package/dist/types/{adapters/devices → platforms}/fortios/models.d.ts +0 -0
  234. /package/dist/types/{adapters/devices → platforms}/junos/models.d.ts +0 -0
  235. /package/dist/types/{adapters/devices → platforms}/raisecom-ros/models.d.ts +0 -0
  236. /package/dist/types/{adapters/devices → platforms}/vrp/models.d.ts +0 -0
  237. /package/src/{adapters/devices → platforms}/fortios/models.ts +0 -0
  238. /package/src/{adapters/devices → platforms}/junos/models.ts +0 -0
  239. /package/src/{adapters/devices → platforms}/raisecom-ros/models.ts +0 -0
  240. /package/src/{adapters/devices → platforms}/vrp/models.ts +0 -0
@@ -0,0 +1,731 @@
1
+ // Raisecom ROS, as on the RAX700 series.
2
+ //
3
+ // An interface, an access-list or an OSPF process opens a block, `!` closes it, and `no` removes a line. Commands apply as they arrive and persist only on `write`. There is no rollback, so management is proved reachable in a fresh session before anything is written: a reboot then restores what was there before.
4
+
5
+ import type { 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, type Step, Platform } from "../platform";
16
+ import { RaisecomRosRender } from "./render";
17
+
18
+ /** A session, and how to prove access in a fresh login: a new SSH session, or logging out and in again on the console. */
19
+ export type Session = { channel: ClientChannel; close: () => void; fresh: () => Promise<Session> };
20
+
21
+ export class RaisecomRos extends Platform {
22
+ /** How the console asks for a login and shows its prompt. Not yet proved on a device: the RAX721's console was never cabled to a jump host. */
23
+ static readonly CONSOLE: ConsoleDialect = {
24
+ login: /login:\s*$/i,
25
+ password: /password:\s*$/i,
26
+ prompt: /[#>]\s*$/,
27
+ logout: "exit",
28
+ others: [[/\(config[^)\n]*\)#\s*$/, "end\n"]],
29
+ };
30
+
31
+ /** Lines the device prints whatever is configured, or that are part of moving around the CLI. */
32
+ private static readonly DEVICE_FACTS = [
33
+ "Being processed",
34
+ "System current configuration",
35
+ "recv-mode",
36
+ "mpls te periodic-flooding",
37
+ "spanning-tree region-configuration",
38
+ "interface NULL",
39
+ "ip rarp",
40
+ "quit",
41
+ "config",
42
+ "exit",
43
+ // Implied by any layer 2 setting on a port; the plan sends it where needed.
44
+ "portswitch",
45
+ // A port's speed or duplex set back to auto prints as the default it is, and `no duplex` is refused.
46
+ "speed auto",
47
+ "duplex auto",
48
+ ];
49
+
50
+ /** Lines whose opposite is not `no` and the line. */
51
+ private static readonly OPPOSITES: [RegExp, string][] = [
52
+ [/^mode manual$/, "mode lacp"],
53
+ [/^ip ospf passive-interface enable$/, "ip ospf passive-interface disable"],
54
+ [/^lldp disable$/, "lldp enable"],
55
+ [/^spanning-tree disable$/, "spanning-tree enable"],
56
+ [/^switchport mode trunk$/, "switchport mode access"],
57
+ [/^telnet-server disable$/, "telnet-server enable"],
58
+ [/^shutdown$/, "no shutdown"],
59
+ // The device refuses `no storm-control mode`; kbps is its default and prints nothing.
60
+ [/^storm-control mode pps$/, "storm-control mode kbps"],
61
+ ];
62
+
63
+ /** Lines whose removal takes only their keyword, not their value. */
64
+ private static readonly UNDO_BY_KEYWORD =
65
+ /^(description|switchport access vlan|switchport trunk native vlan|switchport trunk allowed vlan|storm-control broadcast|ip address|port-channel|ip ospf cost|ip ospf network|switchport port-security maximum)\b/;
66
+
67
+ private static readonly DIALECT: Dialect = {
68
+ block: /^(interface |access-list |router ospf |bfd \d+ bind )/,
69
+ indented: false,
70
+ terminator: "!",
71
+ ignore: (line) => line.startsWith("!") || RaisecomRos.DEVICE_FACTS.some((fact) => line.startsWith(fact)),
72
+ undo: RaisecomRos.undo,
73
+ leave: "exit",
74
+ // SNMP takes the new access-list before it lets go of the old one, so it is never left without one.
75
+ first: /^snmp-server access-list /,
76
+ // A password prints as a cipher and renders as a marker; both are a secret.
77
+ canon: (line) =>
78
+ line
79
+ .replace(/password (cipher )?(\S+)/, "password <secret>")
80
+ .replace(/community <secret:\w+>/, "community <redacted>")
81
+ // An SNMPv3 user prints its localized keys and renders its passphrases; both are secrets.
82
+ .replace(
83
+ /^snmp-server user (\S+) (?:authentication|authkey) (\S+) \S+ (?:privacy|privkey)\s+(\S+) \S+$/,
84
+ "snmp-server user $1 $2 $3 <secret>"
85
+ )
86
+ // A route is sent tracking `bfd N` and prints as tracking `bfd-session N`.
87
+ .replace(/ track bfd-session (\d+)$/, " track bfd $1")
88
+ // An access group prints a notify view the render leaves to its default.
89
+ .replace(/^(snmp-server access \S+ read \S+) notify internet (usm \S+)$/, "$1 $2"),
90
+ // A LAG exists before a port joins it, and an OSPF process comes after the interfaces it names.
91
+ order: (path) => {
92
+ const header = path[0] ?? "";
93
+ // A BFD session binds to a VLAN interface, so it comes after the interfaces. Only one session binds to a peer, so an old one goes first, with the routes at the top level; a route takes a session that does not exist yet.
94
+ if (header.startsWith("bfd ")) return 4;
95
+ if (header.startsWith("interface port-channel")) return 1;
96
+ if (header.startsWith("interface ")) return 2;
97
+ if (header.startsWith("router ospf")) return 3;
98
+ return 0;
99
+ },
100
+ };
101
+
102
+ /** The firmware offers only an `ssh-rsa` host key, so that one is allowed for this platform alone. Key exchange, cipher and MAC still negotiate modern ones. */
103
+ private static readonly HOST_KEYS = ["ssh-rsa"];
104
+
105
+ /** How long a dump may go quiet before the prompt, as on a lossy path where a retransmit stalls it for seconds, and how long it may take in all. */
106
+ private static readonly DUMP_QUIET = 30_000;
107
+ private static readonly DUMP_LIMIT = 150_000;
108
+
109
+ /** What the device prints when it refuses a line. */
110
+ private static readonly REFUSED = /^(%|Error|Incomplete|Unrecognized)|can not|cannot |is in use|Wrong parameter|invalid|unsuccessful/im;
111
+
112
+ /** Lines that ask for `y` and do nothing without it. */
113
+ private static readonly CONFIRM = /^(portswitch|switchport trunk allowed vlan|switchport mode)/;
114
+
115
+ /** A service letting go of an access-list, so the list can change. */
116
+ private static readonly RELEASE = /^no (\S+ access-list \d+)$/;
117
+
118
+ /** Lines that each add one more of a kind rather than set a value: `snmp-server access-list 1001` beside `1000` binds both. */
119
+ private static readonly STANDALONE = /^(logging host|snmp-server access-list) /;
120
+
121
+ /** Commands that only read. A `?` sent by a script is run, so a line with one is refused. */
122
+ private static readonly READS = /^show\s[^?]*$/;
123
+
124
+ readonly name = "raisecom-ros";
125
+
126
+ readonly os: OsSupport = {
127
+ name: "Raisecom ROS",
128
+ versions: "6.5",
129
+ provedOn: "RAX721-C-6C48, ROS 6.5.2",
130
+ checked: false,
131
+ };
132
+
133
+ readonly extension = ".txt";
134
+
135
+ override readonly console = RaisecomRos.CONSOLE;
136
+
137
+ unsupported(device: Device): string[] {
138
+ const found: string[] = [];
139
+
140
+ if (device.management?.interfaces) found.push("management.interfaces, which only FortiOS builds for now");
141
+
142
+ for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
143
+ if (iface.type === "bridge") found.push(`interfaces.${name}, a bridge, which only FortiOS builds for now`);
144
+ if (iface.type === "vlan" && iface.mac) found.push(`interfaces.${name}.mac, which only RouterOS sets for now`);
145
+ }
146
+
147
+ for (const [name, port] of Object.entries(device.ports ?? {})) {
148
+ if (!port) continue;
149
+
150
+ for (const field of ["speed", "acl", "mtu", "addresses", "vrrp", "vrf"] as const) {
151
+ if (port[field] !== undefined) found.push(`ports.${name}.${field}`);
152
+ }
153
+
154
+ if (port.lldp === true) found.push(`ports.${name}.lldp true, LLDP is turned on per device`);
155
+ if (port.lag && (port.mac_limit !== undefined || port.ethertypes))
156
+ found.push(`ports.${name}.mac_limit and ethertypes on a LAG member, proved on a port alone`);
157
+ if (port.mac_limit !== undefined && (port.mac_limit < 1 || port.mac_limit > RaisecomRosRender.MAC_LIMIT_MAX))
158
+ found.push(`ports.${name}.mac_limit ${port.mac_limit}, port security takes 1 to ${RaisecomRosRender.MAC_LIMIT_MAX}`);
159
+ if (typeof port.stp === "object" || port.stp === true) found.push(`ports.${name}.stp other than false`);
160
+ const storm = port.storm_control;
161
+ if (storm && (storm.multicast || storm.unknown_unicast || (storm.broadcast && "percent" in storm.broadcast))) {
162
+ found.push(`ports.${name}.storm_control other than broadcast in pps`);
163
+ }
164
+ }
165
+
166
+ for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
167
+ if (iface.type === "lag") {
168
+ for (const field of ["addresses", "vrrp", "vrf", "mtu", "storm_control", "stp"] as const) {
169
+ if (iface[field] !== undefined) found.push(`interfaces.${name}.${field}, a LAG here only switches`);
170
+ }
171
+ for (const field of ["mac_limit", "ethertypes"] as const) {
172
+ if (iface[field] !== undefined) found.push(`interfaces.${name}.${field}, proved on a port alone`);
173
+ }
174
+ continue;
175
+ }
176
+
177
+ if (iface.type !== "vlan") {
178
+ found.push(`interfaces.${name}, a ${iface.type}`);
179
+ continue;
180
+ }
181
+
182
+ for (const field of ["vrf", "description", "mtu", "ipv6_ra"] as const) {
183
+ if (iface[field] !== undefined) found.push(`interfaces.${name}.${field}`);
184
+ }
185
+
186
+ for (const entry of iface.addresses ?? []) {
187
+ const address = typeof entry === "string" ? entry : entry.address;
188
+ if (Address.family(address) === "ipv6") found.push(`interfaces.${name}.addresses ${address}, only IPv4 here`);
189
+ }
190
+
191
+ for (const group of iface.vrrp ?? []) {
192
+ if (Address.family(group.address) === "ipv6") found.push(`interfaces.${name}.vrrp for IPv6`);
193
+ }
194
+ }
195
+
196
+ const routing = device.routing;
197
+
198
+ for (const field of ["bgp", "rpki"] as const) {
199
+ if (routing?.[field]) found.push(`routing.${field}`);
200
+ }
201
+
202
+ const ospf = routing?.ospf;
203
+ if (ospf?.families?.includes("ipv6")) found.push("routing.ospf for IPv6");
204
+
205
+ for (const [areaId, area] of Object.entries(ospf?.areas ?? {})) {
206
+ for (const name of Object.keys(area.interfaces)) {
207
+ if (device.interfaces?.[name]?.type !== "vlan")
208
+ found.push(`routing.ospf.areas.${areaId}.${name}, OSPF runs on VLAN interfaces here`);
209
+ }
210
+ }
211
+
212
+ for (const route of routing?.static ?? []) {
213
+ if (!route.via || route.vrf || Address.family(route.prefix) === "ipv6") {
214
+ found.push(`routing.static ${route.prefix}, only an IPv4 next hop in the default table`);
215
+ }
216
+
217
+ if (route.description || route.distance) found.push(`routing.static ${route.prefix} description and distance`);
218
+
219
+ if (route.bfd && route.via && !RaisecomRosRender.bfdSessions(device).has(route.via)) {
220
+ found.push(`routing.static ${route.prefix} bfd, the next hop must be on a VLAN interface's network`);
221
+ }
222
+ }
223
+
224
+ for (const [name, vlan] of Object.entries(device.vlans ?? {})) {
225
+ if (vlan.description) found.push(`vlans.${name}.description`);
226
+ }
227
+
228
+ const UNSUPPORTED_SECTIONS = [
229
+ "firewall",
230
+ "dhcp",
231
+ "dhcp_relay",
232
+ "acls",
233
+ "policies",
234
+ "prefix_sets",
235
+ "hardware_offload",
236
+ "certificates",
237
+ "vrfs",
238
+ "flow_export",
239
+ ] as const;
240
+
241
+ for (const field of UNSUPPORTED_SECTIONS) {
242
+ if (device[field] !== undefined) found.push(field);
243
+ }
244
+
245
+ const management = device.management ?? {};
246
+
247
+ for (const field of ["http", "https", "api", "api_tls", "ftp", "native", "console"] as const) {
248
+ if (management[field]) found.push(`management.${field}`);
249
+ }
250
+
251
+ for (const [name, user] of Object.entries(management.snmp?.users ?? {})) {
252
+ if (!RaisecomRosRender.SNMP_AUTH[user.auth])
253
+ found.push(`management.snmp.users.${name}.auth ${user.auth}, the platform authenticates with SHA1 or MD5`);
254
+ }
255
+
256
+ if (management.snmp?.contact || management.snmp?.location) found.push("management.snmp.contact and location");
257
+ if (management.ssh?.weak_crypto) found.push("management.ssh.weak_crypto");
258
+
259
+ if (device.lldp && device.lldp !== true) found.push("lldp.interfaces, LLDP is per device");
260
+ if (device.stp?.mode && device.stp.mode !== "mstp") found.push("stp.mode other than mstp");
261
+
262
+ for (const [name, user] of Object.entries(device.users ?? {})) {
263
+ if (user.role !== "admin") found.push(`users.${name}.role other than admin`);
264
+ }
265
+
266
+ const system = device.system ?? {};
267
+
268
+ if (system.dns || system.logging?.local || system.release_channel || system.banner || system.ntp?.serve) {
269
+ found.push("system.dns, logging.local, release_channel, banner and ntp.serve");
270
+ }
271
+
272
+ // SNMP takes its own access-list; SSH and telnet share the management one.
273
+ found.push(...Derive.switchUnsupported(device, ["snmp"]));
274
+ found.push(...Derive.unproved(device, ["mac-limit", "ethertype-filter", "static-route-bfd"]));
275
+
276
+ return found;
277
+ }
278
+
279
+ render(device: Device): string {
280
+ return RaisecomRosRender.of(device);
281
+ }
282
+
283
+ /** What the device runs, credentials scrubbed. */
284
+ async read(device: Device, _network: readonly Device[], options: ReadOptions = {}): Promise<string> {
285
+ const { channel, close } = await RaisecomRos.open(device, options);
286
+
287
+ try {
288
+ // Without paging off the dump stops halfway with no error. A complete dump ends at the prompt, so the read waits for it through a pause of the device's own or a stall on a lossy path; one that never gets there is read again rather than planned against.
289
+ let dump = "";
290
+ for (let attempt = 1; ; attempt++) {
291
+ dump = await RaisecomRos.dump(channel);
292
+ if (/[#>]\s*$/.test(dump.replace(/\r/g, "").trimEnd())) break;
293
+ if (attempt === 3) throw new Error(`${device.name} returned an incomplete configuration three times`);
294
+ // What the device still prints of the cut dump ends before the next one starts.
295
+ await Ssh.send(channel, "", 30_000, 6000);
296
+ }
297
+ const lines = dump.replace(/\r/g, "").split("\n");
298
+ const start = lines.findIndex((line) => line.trim().startsWith("!ROS version"));
299
+ if (start < 0) throw new Error(`${device.name} returned no configuration`);
300
+
301
+ // The prompt is not configuration; uptime, clock and boot count change on an untouched device.
302
+ const body = lines.slice(start).filter((line) => line.startsWith("!") || !/[#>]\s*$/.test(line.trim()));
303
+ const text = body
304
+ .join("\n")
305
+ .replace(/^!(System (up )?time|Boot times):.*\n/gm, "")
306
+ .trim();
307
+ // An SNMPv3 user prints localized keys, which authenticate as well as the passphrase.
308
+ return LineDiff.scrub(text + "\n").replace(/(authkey \S+|privkey\s+\S+) \S+/g, "$1 <redacted>");
309
+ } finally {
310
+ close();
311
+ }
312
+ }
313
+
314
+ /** Read `show running-config` until it ends at the prompt, or until it goes quiet for too long or takes too long in all. */
315
+ private static dump(channel: ClientChannel): Promise<string> {
316
+ const ended = (text: string) => {
317
+ const clean = text.replace(/\r/g, "").trimEnd();
318
+ return clean.includes("!ROS version") && /\n[^\n]*[#>]$/.test(clean);
319
+ };
320
+
321
+ return new Promise((done) => {
322
+ let output = "";
323
+ let quiet: ReturnType<typeof setTimeout>;
324
+ let settle: ReturnType<typeof setTimeout> | undefined;
325
+
326
+ const finish = () => {
327
+ clearTimeout(quiet);
328
+ clearTimeout(settle);
329
+ clearTimeout(limit);
330
+ channel.removeListener("data", onData);
331
+ done(output);
332
+ };
333
+
334
+ const onData = (chunk: Buffer) => {
335
+ output += chunk.toString("utf8");
336
+ clearTimeout(quiet);
337
+ clearTimeout(settle);
338
+ quiet = setTimeout(finish, RaisecomRos.DUMP_QUIET);
339
+ if (ended(output)) settle = setTimeout(finish, 1_000);
340
+ };
341
+
342
+ const limit = setTimeout(finish, RaisecomRos.DUMP_LIMIT);
343
+ quiet = setTimeout(finish, RaisecomRos.DUMP_QUIET);
344
+ channel.on("data", onData);
345
+ channel.write("show running-config\n");
346
+ });
347
+ }
348
+
349
+ /** The LLDP neighbors from `show lldp remote detail`, which spells each port in full. The short form abbreviates ports (`TGE1/3/3`) and pads columns, so a name with a space cannot be told apart there. */
350
+ async neighbors(device: Device, _network: readonly Device[], options: ReadOptions = {}): Promise<Neighbor[]> {
351
+ const session = await this.session(device, options);
352
+
353
+ try {
354
+ return RaisecomRos.lldpRemotes(await session.run("show lldp remote detail", 60_000), device);
355
+ } finally {
356
+ session.close();
357
+ }
358
+ }
359
+
360
+ /**
361
+ * The neighbors in `show lldp remote detail`: a `<port> has N remotes:` line per port, then a block per remote of `Key: value` lines. A long value wraps onto lines that start with spaces, cut mid-word.
362
+ *
363
+ * A remote without a system name is named by its chassis ID. A port the catalog does not have, such as the out-of-band port, is left out.
364
+ */
365
+ static lldpRemotes(text: string, device: Device): Neighbor[] {
366
+ const tidy = (port: string) => port.replace(/\s+/g, "").toLowerCase();
367
+ const ports = new Map(
368
+ Catalog.ports(device.platform, device.model).map((port) => [tidy(Catalog.spell(device.platform, device.model, port)), port])
369
+ );
370
+
371
+ const neighbors: Neighbor[] = [];
372
+ let port: string | undefined;
373
+ let remote: Record<string, string> | undefined;
374
+ let key: string | undefined;
375
+
376
+ const finish = () => {
377
+ const system = remote?.SysName?.trim() || remote?.ChassisId?.trim();
378
+ if (port && system) neighbors.push({ port, system, remote_port: remote?.PortId?.trim() ?? "" });
379
+
380
+ remote = undefined;
381
+ key = undefined;
382
+ };
383
+
384
+ for (const line of text.replace(/\r/g, "").split("\n")) {
385
+ const header = /^(\S+) has\s+\d+ remotes:/.exec(line);
386
+ if (header) {
387
+ finish();
388
+ port = ports.get(tidy(header[1]!));
389
+ continue;
390
+ }
391
+
392
+ if (/^Remote \d+\s*$/.test(line)) {
393
+ finish();
394
+ remote = {};
395
+ continue;
396
+ }
397
+
398
+ if (!remote) continue;
399
+
400
+ const field = /^([A-Za-z][^:]*?):\s*(.*)$/.exec(line);
401
+ if (field) {
402
+ key = field[1]!;
403
+ remote[key] = field[2]!;
404
+ } else if (key && /^\s+\S/.test(line)) remote[key] += line.trimStart();
405
+ else key = undefined;
406
+ }
407
+
408
+ finish();
409
+ return neighbors;
410
+ }
411
+
412
+ plan(desired: string, current: string, options: ApplyOptions): Plan {
413
+ const compared = LineDiff.compare(desired, current, RaisecomRos.DIALECT);
414
+ const groups = options.secrets ? LineDiff.withSecrets(compared, desired, RaisecomRos.DIALECT) : compared;
415
+
416
+ // An access-list rule is not overwritten by one with the same number; the old one is removed first.
417
+ const existing = LineDiff.entries(current, RaisecomRos.DIALECT);
418
+
419
+ for (const group of groups) {
420
+ if (!group.path[0]?.startsWith("access-list ")) continue;
421
+
422
+ for (const line of group.add) {
423
+ const number = /^rule (\d+) /.exec(line)?.[1];
424
+ if (!number) continue;
425
+
426
+ const old = existing.find(
427
+ (entry) =>
428
+ entry.path.join("\n") === group.path.join("\n") && entry.line.startsWith(`rule ${number} `) && entry.line !== line
429
+ );
430
+ if (old && !group.remove.includes(old.line)) group.remove.push(old.line);
431
+ }
432
+ }
433
+
434
+ // A log host, and an access-list SNMP takes, each stand alone: one that is gone is removed even when another is added beside it.
435
+ const wantedLines = new Set(LineDiff.entries(desired, RaisecomRos.DIALECT).map((entry) => entry.line));
436
+ const gone = existing.filter(
437
+ (entry) => !entry.path.length && RaisecomRos.STANDALONE.test(entry.line) && !wantedLines.has(entry.line)
438
+ );
439
+
440
+ if (gone.length) {
441
+ let top = groups.find((group) => !group.path.length);
442
+ if (!top) groups.push((top = { path: [], remove: [], add: [] }));
443
+ for (const entry of gone) if (!top.remove.includes(entry.line)) top.remove.push(entry.line);
444
+ }
445
+
446
+ // A VLAN is created in a list and removed one at a time, with `no vlan <id>`.
447
+ LineDiff.vlanListChange(
448
+ groups,
449
+ "create vlan ",
450
+ RaisecomRos.createdIds,
451
+ (ids) => [`create vlan ${RaisecomRosRender.vlanList(ids)} active`],
452
+ (ids) => ids.map((id) => `vlan ${id}`)
453
+ );
454
+
455
+ // A port takes layer 2 settings only once it is switched.
456
+ for (const group of groups) {
457
+ const isPort = group.path[0]?.startsWith("interface ") ?? false;
458
+ const switches = group.add.some((line) => line.startsWith("switchport") || line.startsWith("port-channel"));
459
+ if (isPort && switches) group.add.unshift("portswitch");
460
+ }
461
+
462
+ // A LAG is removed only once no port is in it, so its removal goes after every other step.
463
+ const lagRemovals: string[] = [];
464
+
465
+ for (const group of groups) {
466
+ if (group.path.length) continue;
467
+ lagRemovals.push(...group.remove.filter((line) => line.startsWith("interface port-channel")));
468
+ group.remove = group.remove.filter((line) => !line.startsWith("interface port-channel"));
469
+ }
470
+
471
+ // An access-list is removed only once no service holds it, so its removal goes after the top level lets go of it.
472
+ const aclRemovals: string[] = [];
473
+
474
+ for (const group of groups) {
475
+ if (group.path.length) continue;
476
+ aclRemovals.push(...group.remove.filter((line) => /^access-list \d+/.test(line)));
477
+ group.remove = group.remove.filter((line) => !/^access-list \d+/.test(line));
478
+ }
479
+
480
+ // Editing an access-list means releasing every service holding it first, and binding the wanted ones after.
481
+ const releaseAndBind = (group: Group) => {
482
+ const acl = /^access-list (\d+)/.exec(group.path[0] ?? "")?.[1];
483
+ if (!acl) return { before: [], after: [] };
484
+
485
+ // A port's filter is bound again only where it stays; a port that newly takes it binds it in its own step.
486
+ const filter = `filter ingress access-list ${acl}`;
487
+ const ports = RaisecomRos.filteredPorts(current, filter);
488
+ const kept = ports.filter((port) => RaisecomRos.filteredPorts(desired, filter).includes(port));
489
+
490
+ return {
491
+ before: [
492
+ ...RaisecomRos.holders(current, acl).map((service) => `no ${service} access-list ${acl}`),
493
+ ...ports.flatMap((port) => [port, `no ${filter}`, RaisecomRos.DIALECT.leave]),
494
+ ],
495
+ after: [
496
+ ...RaisecomRos.holders(desired, acl).map((service) => `${service} access-list ${acl}`),
497
+ ...kept.flatMap((port) => [port, filter, RaisecomRos.DIALECT.leave]),
498
+ ],
499
+ };
500
+ };
501
+
502
+ // The hostname is set outside configuration mode, where `hostname` is refused.
503
+ const hostnames: string[] = [];
504
+
505
+ for (const group of groups) {
506
+ if (group.path.length) continue;
507
+ hostnames.push(...group.add.filter((line) => line.startsWith("hostname ")));
508
+ group.add = group.add.filter((line) => !line.startsWith("hostname "));
509
+ }
510
+
511
+ const planned = LineDiff.steps(
512
+ groups.filter((group) => group.add.length || group.remove.length),
513
+ RaisecomRos.DIALECT,
514
+ releaseAndBind
515
+ );
516
+
517
+ for (const line of hostnames) planned.unshift({ title: "hostname", show: [`+ ${line}`], send: ["exit", line, "config"] });
518
+
519
+ if (aclRemovals.length) {
520
+ planned.push({
521
+ title: "access-lists no longer used",
522
+ show: aclRemovals.map((line) => `- ${line}`),
523
+ send: aclRemovals.map(RaisecomRos.undo),
524
+ });
525
+ }
526
+
527
+ if (lagRemovals.length) {
528
+ const removeLags: Step = {
529
+ title: "LAGs no longer used",
530
+ show: lagRemovals.map((line) => `- ${line}`),
531
+ send: lagRemovals.map(RaisecomRos.undo),
532
+ };
533
+ planned.push(removeLags);
534
+ }
535
+
536
+ return { steps: planned, problems: [] };
537
+ }
538
+
539
+ async apply(device: Device, _network: readonly Device[], plan: Plan, options: ApplyOptions): Promise<void> {
540
+ const session = await RaisecomRos.open(device, options);
541
+ const { channel } = session;
542
+ // Bindings released for an access-list edit. A failure binds them again before anything else, so the list keeps guarding management.
543
+ const released: string[] = [];
544
+
545
+ try {
546
+ await Ssh.send(channel, "config", 10_000);
547
+
548
+ for (const step of plan.steps) {
549
+ for (const line of step.send) {
550
+ let answer = await Ssh.send(channel, Secrets.substitute(line), 20_000, 2000);
551
+ // A confirming line asks for `y` unless the change needs no confirming, when the device sets it at once.
552
+ const asks =
553
+ /[[(]y\/n[\])]|input 'y'/i.test(answer) || (RaisecomRos.CONFIRM.test(line) && !/set successfully/i.test(answer));
554
+ if (asks) answer += await Ssh.send(channel, "y", 10_000, 1000);
555
+ if (RaisecomRos.REFUSED.test(answer))
556
+ throw new Error(`${step.title}: ${line}\n device said: ${answer.trim().slice(0, 300)}`);
557
+
558
+ const release = RaisecomRos.RELEASE.exec(line);
559
+ if (release) released.push(release[1]!);
560
+ else if (released.includes(line)) released.splice(released.indexOf(line), 1);
561
+ }
562
+ }
563
+
564
+ await Ssh.send(channel, "exit", 10_000);
565
+ } catch (error) {
566
+ await Ssh.send(channel, "end", 10_000).catch(() => undefined);
567
+ await Ssh.send(channel, "config", 10_000).catch(() => undefined);
568
+ for (const binding of released) await Ssh.send(channel, binding, 10_000).catch(() => undefined);
569
+ if (released.length) console.log(` bound again: ${released.join(", ")}`);
570
+ session.close();
571
+ throw error;
572
+ }
573
+
574
+ const proof = await session.fresh().catch(() => undefined);
575
+ if (!proof)
576
+ throw new Error(
577
+ "applied, but a fresh login failed. Not written: a reboot restores the previous configuration. Recover from the console."
578
+ );
579
+ await Ssh.send(proof.channel, "write", 60_000, 3000);
580
+ proof.close();
581
+ console.log(" applied, login proved, written");
582
+ }
583
+
584
+ readOnly(command: string): boolean {
585
+ return RaisecomRos.READS.test(command);
586
+ }
587
+
588
+ /** One shell for every command, as the platform takes commands only there. */
589
+ async session(device: Device, options: ReadOptions = {}): Promise<ReadSession> {
590
+ const session = await RaisecomRos.open(device, options);
591
+ const shell = new ConsoleShell(session.channel, RaisecomRos.CONSOLE, ConsoleShell.SETTLE);
592
+
593
+ return { run: (command, timeout) => shell.run(command, timeout), close: session.close };
594
+ }
595
+
596
+ private static undo(line: string): string {
597
+ for (const [pattern, opposite] of RaisecomRos.OPPOSITES) if (pattern.test(line)) return opposite;
598
+ if (/^rule \d+/.test(line)) return `no ${line.split(" ").slice(0, 2).join(" ")}`;
599
+
600
+ // A VRRP group goes with its address; its other settings return to their defaults by keyword. Preemption is on unless turned off.
601
+ const vrrp = /^(no )?vrrp (\d+) (ip \S+|priority|timers advertise-interval|preempt)/.exec(line);
602
+ if (vrrp) return vrrp[1] ? `vrrp ${vrrp[2]} preempt` : `no vrrp ${vrrp[2]} ${vrrp[3]}`;
603
+
604
+ // A route goes by its prefix and next hop, whatever it tracks; a BFD session by its number.
605
+ const route = /^(ip route \S+ \S+ \S+)/.exec(line);
606
+ if (route) return `no ${route[1]}`;
607
+
608
+ const bfdSession = /^bfd (\d+) bind /.exec(line);
609
+ if (bfdSession) return `no bfd ${bfdSession[1]}`;
610
+
611
+ const ospfProcess = /^router ospf (\d+)/.exec(line);
612
+ if (ospfProcess) return `no router ospf ${ospfProcess[1]}`;
613
+
614
+ // An SNMPv3 user, its group and its access are each removed by their name alone.
615
+ const snmpUser = /^snmp-server user (\S+)/.exec(line);
616
+ if (snmpUser) return `no snmp-server user ${snmpUser[1]}`;
617
+
618
+ const snmpGroup = /^snmp-server group \S+ user (\S+) usm$/.exec(line);
619
+ if (snmpGroup) return `no snmp-server group user ${snmpGroup[1]} usm`;
620
+
621
+ const snmpAccess = /^snmp-server access (\S+) .* usm (\S+)$/.exec(line);
622
+ if (snmpAccess) return `no snmp-server access ${snmpAccess[1]} usm ${snmpAccess[2]}`;
623
+
624
+ // A log host goes by its address, whatever its level and port.
625
+ const logHost = /^logging host (\S+)/.exec(line);
626
+ if (logHost) return `no logging host ${logHost[1]}`;
627
+
628
+ const keyword = RaisecomRos.UNDO_BY_KEYWORD.exec(line);
629
+ if (keyword) return `no ${keyword[1]}`;
630
+
631
+ return `no ${line}`;
632
+ }
633
+
634
+ /** An interactive shell, paging off, over SSH or on the console. A session that does not answer as ROS is refused, so nothing meant for it can reach another shell. */
635
+ private static async open(device: Device, options: ReadOptions = {}): Promise<Session> {
636
+ const session = options.console ? await RaisecomRos.consoleShell(device) : await RaisecomRos.sshShell(device);
637
+
638
+ const version = await Ssh.send(session.channel, "show version", 15_000, 1500);
639
+ if (!version.includes("ROS")) {
640
+ session.close();
641
+ throw new Error(`${device.name} did not answer as ROS`);
642
+ }
643
+
644
+ await Ssh.send(session.channel, "terminal page-break disable", 15_000, 1000);
645
+ return session;
646
+ }
647
+
648
+ private static async sshShell(device: Device): Promise<Session> {
649
+ const { port, user, password } = Secrets.loginOf(device);
650
+
651
+ const client = await Ssh.connect({
652
+ host: Ssh.hostOf(device),
653
+ port,
654
+ user,
655
+ password,
656
+ hostKeys: RaisecomRos.HOST_KEYS,
657
+ });
658
+ const channel = await Ssh.shell(client);
659
+ const close = () => {
660
+ channel.end();
661
+ client.end();
662
+ };
663
+
664
+ const banner = await Ssh.readUntil(channel, (text) => /[#>]\s*$/.test(text.trimEnd()), 30_000);
665
+ if (!/[#>]\s*$/.test(banner.trimEnd())) {
666
+ close();
667
+ throw new Error(`${device.name} did not answer as ROS`);
668
+ }
669
+
670
+ return {
671
+ channel,
672
+ close,
673
+ fresh: async () => {
674
+ close();
675
+ return RaisecomRos.open(device);
676
+ },
677
+ };
678
+ }
679
+
680
+ private static async consoleShell(device: Device): Promise<Session> {
681
+ const { console, user, password } = SerialConsole.of(device);
682
+ const opened = await SerialConsole.open(console);
683
+
684
+ try {
685
+ await SerialConsole.login(opened.channel, RaisecomRos.CONSOLE, user, password, console.passwordOnly);
686
+ } catch (error) {
687
+ opened.close();
688
+ throw error;
689
+ }
690
+
691
+ const session: Session = {
692
+ channel: opened.channel,
693
+ close: () => {
694
+ opened.channel.write(`${RaisecomRos.CONSOLE.logout}\n`);
695
+ opened.close();
696
+ },
697
+ fresh: async () => {
698
+ await SerialConsole.login(opened.channel, RaisecomRos.CONSOLE, user, password, console.passwordOnly);
699
+ await Ssh.send(opened.channel, "terminal page-break disable", 15_000, 1000);
700
+ return session;
701
+ },
702
+ };
703
+
704
+ return session;
705
+ }
706
+
707
+ /** The ports, by their `interface` line, that filter what they take in with an access-list. */
708
+ private static filteredPorts(config: string, filter: string): string[] {
709
+ return LineDiff.entries(config, RaisecomRos.DIALECT)
710
+ .filter((entry) => entry.line === filter && entry.path[0]?.startsWith("interface "))
711
+ .map((entry) => entry.path[0]!);
712
+ }
713
+
714
+ /** Services holding an access-list. It cannot change while any of them does. */
715
+ private static holders(config: string, acl: string): string[] {
716
+ return [...config.matchAll(new RegExp(`^([a-z0-9-]+) access-list ${acl}$`, "gm"))].map((match) => match[1]!);
717
+ }
718
+
719
+ /** The VLAN IDs of a `create vlan 10,20-30 active` line. */
720
+ private static createdIds(line: string): number[] {
721
+ const list = line.replace(/^create vlan /, "").replace(/ active$/, "");
722
+ const ids: number[] = [];
723
+
724
+ for (const part of list.split(",")) {
725
+ const [first, last] = part.split("-").map(Number);
726
+ for (let id = first!; id <= (last ?? first!); id++) ids.push(id);
727
+ }
728
+
729
+ return ids;
730
+ }
731
+ }