@takodotid/circuit 0.4.3

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 (38) hide show
  1. package/LICENSE +106 -0
  2. package/README.md +195 -0
  3. package/package.json +83 -0
  4. package/src/adapters/devices/catalog.ts +38 -0
  5. package/src/adapters/devices/derive.ts +27 -0
  6. package/src/adapters/devices/index.ts +7 -0
  7. package/src/adapters/devices/lines.ts +212 -0
  8. package/src/adapters/devices/raisecom-ros/TRAPS.md +22 -0
  9. package/src/adapters/devices/raisecom-ros/index.ts +603 -0
  10. package/src/adapters/devices/raisecom-ros/models.ts +7 -0
  11. package/src/adapters/devices/routeros/TRAPS.md +33 -0
  12. package/src/adapters/devices/routeros/export.ts +319 -0
  13. package/src/adapters/devices/routeros/index.ts +261 -0
  14. package/src/adapters/devices/routeros/models.ts +8 -0
  15. package/src/adapters/devices/routeros/plan.ts +370 -0
  16. package/src/adapters/devices/routeros/render.ts +1431 -0
  17. package/src/adapters/devices/types.ts +31 -0
  18. package/src/adapters/devices/vrp/TRAPS.md +22 -0
  19. package/src/adapters/devices/vrp/index.ts +742 -0
  20. package/src/adapters/devices/vrp/models.ts +4 -0
  21. package/src/adapters/registries/index.ts +19 -0
  22. package/src/adapters/registries/peeringdb.ts +169 -0
  23. package/src/cli/index.ts +295 -0
  24. package/src/core/addr.ts +145 -0
  25. package/src/core/define.ts +94 -0
  26. package/src/core/secrets.ts +68 -0
  27. package/src/core/time.ts +15 -0
  28. package/src/core/validate.ts +380 -0
  29. package/src/index.ts +6 -0
  30. package/src/schema/common.ts +32 -0
  31. package/src/schema/device.ts +95 -0
  32. package/src/schema/firewall.ts +128 -0
  33. package/src/schema/index.ts +7 -0
  34. package/src/schema/interfaces.ts +192 -0
  35. package/src/schema/policy.ts +88 -0
  36. package/src/schema/routing.ts +120 -0
  37. package/src/schema/system.ts +191 -0
  38. package/src/transport/ssh.ts +125 -0
@@ -0,0 +1,22 @@
1
+ # Raisecom ROS traps
2
+
3
+ Behaviour that looks like something else. Each one has been hit on a RAX721 running ROS 6.5.2.
4
+
5
+ - Commands apply as they arrive and persist only on `write`.
6
+ - The SSH server offers only an `ssh-rsa` host key. The adapter allows it for this platform alone; key exchange, cipher and MAC still negotiate modern ones.
7
+ - `show running-config` stops about halfway with no error unless `terminal page-break disable` is sent first and the read waits several seconds of silence.
8
+ - A `?` sent by a script is executed. Once it applied an address with a /8 mask.
9
+ - An access-list cannot change while any service holds it: `ACL 1000 is in use`. Every holder is released first and bound again after. `no ssh2 access-list` needs the list number.
10
+ - `portswitch`, `switchport trunk allowed vlan` and `switchport mode` ask for `y` and do nothing without it.
11
+ - A VLAN is removed with `no vlan <id>`, not `no create vlan`.
12
+ - The factory account `raisecom` cannot be removed and its service type cannot change. Only its password is ours.
13
+ - Factory SNMP communities survive hardening, and `private` can write. The render removes both.
14
+ - Several commands are not the usual spelling: `terminal page-break disable`, `telnet-server disable`, `ip rarp server disable`.
15
+ - An access-list description takes one word. A space is refused with `Error input`.
16
+ - The out-of-band port `fastethernet 1/0/1` cannot be shut down and is not in the catalog.
17
+ - A LAG is `interface port-channel N`. LACP is its default and prints nothing; a static LAG prints `mode manual`. The LAG takes layer 2 settings only after `portswitch`, and a port joins with `portswitch` then `port-channel N`.
18
+ - An SNMPv3 user prints as localized keys, `authkey sha <hex> privkey aes128 <hex>`, which authenticate as well as the passphrase. The read redacts them. An access group prints a `notify internet` it was not given. A user's group is removed with `no snmp-server group user <user> usm`.
19
+ - A VRRP group is enabled once it has an address and preempts unless `no vrrp N preempt`. `no vrrp N ip <address>` removes the whole group.
20
+ - OSPF settings for an interface sit on the interface, `ip ospf cost`, `ip ospf network ptp`, `ip ospf passive-interface enable`; passive is undone with `... disable`, not `no`.
21
+ - `create vlan <list> active` holds every VLAN on one line. A change creates only the new VLANs and removes each gone one with `no vlan <id>`.
22
+ - An access-list rule is not replaced by one with the same number: the device answers `Set unsuccessfully.` and keeps the old rule. The old one is removed first. `no rule N` answers slowly, after a second or more.
@@ -0,0 +1,603 @@
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 { familyOf, host, netmask, split, wildcard } from "../../../core/addr";
7
+ import { marker, resolve, substitute } from "../../../core/secrets";
8
+ import { offset } from "../../../core/time";
9
+ import type { Device, Port, SnmpUser, Switched, Vrrp } from "../../../schema";
10
+ import { connect, readUntil, send, shell } from "../../../transport/ssh";
11
+ import { portsOf, spell } from "../catalog";
12
+ import { ipv4Networks, lagIds } from "../derive";
13
+ import { compare, type Dialect, entries, type Group, scrub, steps, vlanListChange } from "../lines";
14
+ import type { DeviceAdapter, Plan, Step } from "../types";
15
+
16
+ /** Lines the device prints whatever is configured, or that are part of moving around the CLI. */
17
+ const DEVICE_FACTS = [
18
+ "Being processed",
19
+ "System current configuration",
20
+ "recv-mode",
21
+ "mpls te periodic-flooding",
22
+ "spanning-tree region-configuration",
23
+ "interface NULL",
24
+ "ip rarp",
25
+ "quit",
26
+ "config",
27
+ "exit",
28
+ // Implied by any layer 2 setting on a port; the plan sends it where needed.
29
+ "portswitch",
30
+ ];
31
+
32
+ /** Lines whose opposite is not `no` and the line. */
33
+ const OPPOSITES: [RegExp, string][] = [
34
+ [/^mode manual$/, "mode lacp"],
35
+ [/^ip ospf passive-interface enable$/, "ip ospf passive-interface disable"],
36
+ [/^lldp disable$/, "lldp enable"],
37
+ [/^spanning-tree disable$/, "spanning-tree enable"],
38
+ [/^switchport mode trunk$/, "switchport mode access"],
39
+ [/^telnet-server disable$/, "telnet-server enable"],
40
+ [/^shutdown$/, "no shutdown"],
41
+ ];
42
+
43
+ /** Lines whose removal takes only their keyword, not their value. */
44
+ const UNDO_BY_KEYWORD =
45
+ /^(description|switchport access vlan|switchport trunk native vlan|switchport trunk allowed vlan|storm-control broadcast|storm-control mode|ip address|port-channel|ip ospf cost|ip ospf network)\b/;
46
+
47
+ function undo(line: string): string {
48
+ for (const [pattern, opposite] of OPPOSITES) if (pattern.test(line)) return opposite;
49
+ if (/^rule \d+/.test(line)) return `no ${line.split(" ").slice(0, 2).join(" ")}`;
50
+
51
+ // A VRRP group goes with its address; its other settings return to their defaults by keyword. Preemption is on unless turned off.
52
+ const vrrp = /^(no )?vrrp (\d+) (ip \S+|priority|timers advertise-interval|preempt)/.exec(line);
53
+ if (vrrp) return vrrp[1] ? `vrrp ${vrrp[2]} preempt` : `no vrrp ${vrrp[2]} ${vrrp[3]}`;
54
+
55
+ const ospfProcess = /^router ospf (\d+)/.exec(line);
56
+ if (ospfProcess) return `no router ospf ${ospfProcess[1]}`;
57
+
58
+ // An SNMPv3 user, its group and its access are each removed by their name alone.
59
+ const snmpUser = /^snmp-server user (\S+)/.exec(line);
60
+ if (snmpUser) return `no snmp-server user ${snmpUser[1]}`;
61
+
62
+ const snmpGroup = /^snmp-server group \S+ user (\S+) usm$/.exec(line);
63
+ if (snmpGroup) return `no snmp-server group user ${snmpGroup[1]} usm`;
64
+
65
+ const snmpAccess = /^snmp-server access (\S+) .* usm (\S+)$/.exec(line);
66
+ if (snmpAccess) return `no snmp-server access ${snmpAccess[1]} usm ${snmpAccess[2]}`;
67
+
68
+ const keyword = UNDO_BY_KEYWORD.exec(line);
69
+ if (keyword) return `no ${keyword[1]}`;
70
+
71
+ return `no ${line}`;
72
+ }
73
+
74
+ export const dialect: Dialect = {
75
+ block: /^(interface |access-list |router ospf )/,
76
+ indented: false,
77
+ terminator: "!",
78
+ ignore: (line) => line.startsWith("!") || DEVICE_FACTS.some((fact) => line.startsWith(fact)),
79
+ undo,
80
+ leave: "exit",
81
+ // A password prints as a cipher and renders as a marker; both are a secret.
82
+ canon: (line) =>
83
+ line
84
+ .replace(/password (cipher )?(\S+)/, "password <secret>")
85
+ .replace(/community <secret:\w+>/, "community <redacted>")
86
+ // An SNMPv3 user prints its localized keys and renders its passphrases; both are secrets.
87
+ .replace(
88
+ /^snmp-server user (\S+) (?:authentication|authkey) (\S+) \S+ (?:privacy|privkey)\s+(\S+) \S+$/,
89
+ "snmp-server user $1 $2 $3 <secret>"
90
+ )
91
+ // An access group prints a notify view the render leaves to its default.
92
+ .replace(/^(snmp-server access \S+ read \S+) notify internet (usm \S+)$/, "$1 $2"),
93
+ // A LAG exists before a port joins it, and an OSPF process comes after the interfaces it names.
94
+ order: (path) => {
95
+ const header = path[0] ?? "";
96
+ if (header.startsWith("interface port-channel")) return 1;
97
+ if (header.startsWith("interface ")) return 2;
98
+ if (header.startsWith("router ospf")) return 3;
99
+ return 0;
100
+ },
101
+ };
102
+
103
+ /** The OSPF process number. */
104
+ const OSPF_PROCESS = 1;
105
+
106
+ /** The SNMPv3 access group every user belongs to, read-only on the whole tree. */
107
+ const SNMP_GROUP = "circuit";
108
+
109
+ const SNMP_AUTH: Partial<Record<SnmpUser["auth"], string>> = { sha1: "sha" };
110
+
111
+ /** The access-list that guards every management service. */
112
+ export const MANAGEMENT_ACL = 1000;
113
+
114
+ /** `99,200-201,999`, the platform's compression of a VLAN list. */
115
+ export function vlanList(ids: number[]): string {
116
+ const sorted = [...new Set(ids)].sort((a, b) => a - b);
117
+ const ranges: string[] = [];
118
+
119
+ for (let i = 0; i < sorted.length; i++) {
120
+ let end = i;
121
+ while (sorted[end + 1] === sorted[end]! + 1) end++;
122
+ ranges.push(i === end ? `${sorted[i]}` : `${sorted[i]}-${sorted[end]}`);
123
+ i = end;
124
+ }
125
+
126
+ return ranges.join(",");
127
+ }
128
+
129
+ type AnyPort = Port<string, string, string, string>;
130
+
131
+ export function render(device: Device): string {
132
+ const lines: string[] = [];
133
+ const vlans = device.vlans ?? {};
134
+ const vlanId = (name: string) => vlans[name]!.id;
135
+ const management = device.management ?? {};
136
+ const allow = management.allow ?? [];
137
+
138
+ const ids = Object.values(vlans).map((vlan) => vlan.id);
139
+ if (ids.length) lines.push(`create vlan ${vlanList(ids)} active`, "!");
140
+
141
+ const lagNumbers = lagIds(device);
142
+
143
+ for (const [name, lag] of Object.entries(device.interfaces ?? {})) {
144
+ if (lag.type !== "lag") continue;
145
+
146
+ lines.push(`interface port-channel ${lagNumbers.get(name)}`);
147
+ if (lag.description) lines.push(`description ${lag.description}`);
148
+ if (lag.mode === "static") lines.push("mode manual");
149
+ lines.push(...switching(lag, vlanId), "!");
150
+ }
151
+
152
+ if (allow.length) {
153
+ // An access-list description takes one word.
154
+ lines.push(`access-list ${MANAGEMENT_ACL}`, " description management");
155
+ allow.forEach((cidr, index) => lines.push(` rule ${(index + 1) * 10} permit ${host(cidr)} ${netmask(split(cidr).len)}`));
156
+ // Stated, so the list reads the same without knowing the firmware's implicit end.
157
+ lines.push(" rule 100 deny any", "!");
158
+ }
159
+
160
+ for (const [name, user] of Object.entries(device.users ?? {})) lines.push(`user name ${name} password ${marker(user.password)}`);
161
+ if (management.privilege_password) lines.push(`enable password ${marker(management.privilege_password)}`);
162
+
163
+ const timezone = device.system?.timezone;
164
+ if (timezone) {
165
+ const minutes = offset(timezone);
166
+ const absolute = Math.abs(minutes);
167
+ lines.push(`clock timezone ${minutes < 0 ? "-" : "+"} ${Math.floor(absolute / 60)} ${absolute % 60} 0`);
168
+ }
169
+
170
+ lines.push("!");
171
+
172
+ for (const port of portsOf(device.platform, device.model)) {
173
+ const settings = device.ports?.[port as keyof typeof device.ports];
174
+ lines.push(`interface ${spell(device.platform, device.model, port)}`, ...physicalPort(settings, vlanId, lagNumbers), "!");
175
+ }
176
+
177
+ const ospf = device.routing?.ospf;
178
+
179
+ for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
180
+ if (iface.type !== "vlan") continue;
181
+
182
+ lines.push(`interface vlan ${vlanId(iface.vlan)}`);
183
+
184
+ for (const entry of iface.addresses ?? []) {
185
+ const address = typeof entry === "string" ? entry : entry.address;
186
+ if (familyOf(address) === "ipv4") lines.push(`ip address ${host(address)} ${netmask(split(address).len)}`);
187
+ }
188
+
189
+ const ospfSettings = Object.values(ospf?.areas ?? {}).find((area) => area.interfaces[name])?.interfaces[name];
190
+ if (ospfSettings?.network === "point-to-point") lines.push("ip ospf network ptp");
191
+ if (ospfSettings?.cost !== undefined) lines.push(`ip ospf cost ${ospfSettings.cost}`);
192
+ if (ospfSettings?.passive) lines.push("ip ospf passive-interface enable");
193
+
194
+ for (const group of iface.vrrp ?? []) lines.push(...vrrpLines(group));
195
+
196
+ lines.push("!");
197
+ }
198
+
199
+ if (ospf) {
200
+ const routerId = device.routing?.router_id;
201
+ lines.push(`router ospf ${OSPF_PROCESS}${routerId ? ` router-id ${routerId}` : ""}`);
202
+
203
+ for (const [areaId, area] of Object.entries(ospf.areas)) {
204
+ for (const iface of Object.keys(area.interfaces)) {
205
+ for (const prefix of ipv4Networks(device, iface)) {
206
+ lines.push(`network ${host(prefix)} ${wildcard(split(prefix).len)} area ${areaId}`);
207
+ }
208
+ }
209
+ }
210
+
211
+ for (const source of ospf.redistribute ?? []) lines.push(`redistribute ${source}`);
212
+ lines.push("!");
213
+ }
214
+
215
+ // Storm control acts only once detection is on.
216
+ if (Object.values(device.ports ?? {}).some((port) => port?.storm_control)) lines.push("storm-control detection enable");
217
+ if (device.system?.transceiver_monitoring) lines.push("transceiver ddm enable");
218
+ // LLDP and spanning tree are off by default and turned on by the config.
219
+ if (device.lldp) lines.push("lldp enable");
220
+ if (device.stp) {
221
+ lines.push("spanning-tree enable");
222
+ if (device.stp.priority !== undefined) lines.push(`spanning-tree priority ${device.stp.priority}`);
223
+ }
224
+
225
+ // The factory communities survive a reset, and one of them can write, so they are always removed.
226
+ lines.push("no snmp-server community public", "no snmp-server community private");
227
+ const snmp = management.snmp;
228
+ if (snmp?.community) lines.push(`snmp-server community ${marker(snmp.community)} ro`);
229
+
230
+ const snmpUsers = Object.entries(snmp?.users ?? {});
231
+ if (snmpUsers.length) lines.push(`snmp-server access ${SNMP_GROUP} read internet usm authpriv`);
232
+
233
+ for (const [name, user] of snmpUsers) {
234
+ const authentication = `authentication ${SNMP_AUTH[user.auth]} ${marker(user.auth_password)}`;
235
+ const privacy = `privacy ${user.privacy} ${marker(user.privacy_password)}`;
236
+ lines.push(`snmp-server user ${name} ${authentication} ${privacy}`, `snmp-server group ${SNMP_GROUP} user ${name} usm`);
237
+ }
238
+
239
+ if ((snmp?.community || snmpUsers.length) && allow.length) lines.push(`snmp-server access-list ${MANAGEMENT_ACL}`);
240
+
241
+ for (const route of device.routing?.static ?? []) {
242
+ const { addr, len } = split(route.prefix);
243
+ if (familyOf(addr) === "ipv4" && route.via) lines.push(`ip route ${addr} ${netmask(len)} ${route.via}`);
244
+ }
245
+
246
+ if (management.telnet) {
247
+ lines.push("telnet-server enable");
248
+ if (allow.length) lines.push(`telnet-server access-list ${MANAGEMENT_ACL}`);
249
+ } else lines.push("telnet-server disable");
250
+
251
+ const ssh = management.ssh;
252
+ if (ssh?.auth_timeout) lines.push(`ssh2 server authentication-timeout ${ssh.auth_timeout}`);
253
+ if (ssh?.auth_retries) lines.push(`ssh2 server authentication-retries ${ssh.auth_retries}`);
254
+ if (ssh && allow.length) lines.push(`ssh2 access-list ${MANAGEMENT_ACL}`);
255
+
256
+ const ntp = device.system?.ntp;
257
+ if (ntp?.servers.length) lines.push("ntp enable", ...ntp.servers.map((server) => `ntp server ${server}`));
258
+
259
+ return lines.join("\n") + "\n";
260
+ }
261
+
262
+ /** Access or trunk membership, for a port or a LAG. */
263
+ function switching(settings: Switched<string>, vlanId: (name: string) => number): string[] {
264
+ const lines: string[] = [];
265
+ if (settings.access_vlan) lines.push(`switchport access vlan ${vlanId(settings.access_vlan)}`);
266
+
267
+ if (settings.trunk_vlans) {
268
+ if (settings.native_vlan) lines.push(`switchport trunk native vlan ${vlanId(settings.native_vlan)}`);
269
+ lines.push(`switchport trunk allowed vlan ${vlanList(settings.trunk_vlans.map(vlanId))}`, "switchport mode trunk");
270
+ }
271
+
272
+ return lines;
273
+ }
274
+
275
+ /** A VRRP group. The platform enables a group once it has an address, and preempts unless told not to. */
276
+ function vrrpLines(group: Vrrp): string[] {
277
+ const lines = [`vrrp ${group.id} ip ${group.address}`];
278
+ if (group.interval !== undefined && group.interval !== 1) lines.push(`vrrp ${group.id} timers advertise-interval ${group.interval}`);
279
+ if (group.priority !== undefined && group.priority !== 100) lines.push(`vrrp ${group.id} priority ${group.priority}`);
280
+ if (!group.preempt) lines.push(`no vrrp ${group.id} preempt`);
281
+ return lines;
282
+ }
283
+
284
+ /** A port the config does not declare is shut down. A LAG member holds its membership and description; the LAG holds the rest. */
285
+ function physicalPort(settings: AnyPort | undefined, vlanId: (name: string) => number, lagNumbers: Map<string, number>): string[] {
286
+ if (!settings) return ["shutdown"];
287
+
288
+ const lines: string[] = [];
289
+ if (settings.description) lines.push(`description ${settings.description}`);
290
+ if (settings.lag) return [...lines, `port-channel ${lagNumbers.get(settings.lag)}`];
291
+
292
+ lines.push(...switching(settings, vlanId));
293
+
294
+ if (settings.lldp === false) lines.push("lldp disable");
295
+ if (settings.stp === false) lines.push("spanning-tree disable");
296
+ const broadcast = settings.storm_control?.broadcast;
297
+ if (broadcast && "pps" in broadcast) lines.push("storm-control mode pps", `storm-control broadcast pps ${broadcast.pps}`);
298
+ return lines;
299
+ }
300
+
301
+ /** 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. */
302
+ const HOST_KEYS = ["ssh-rsa"];
303
+
304
+ export type Session = { channel: ClientChannel; close: () => void };
305
+
306
+ /** An interactive shell, paging off. A session that does not answer as ROS is refused, so nothing meant for it can reach another shell. */
307
+ export async function open(device: Device): Promise<Session> {
308
+ const user = device.users?.[device.connection.user];
309
+ if (!user) throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
310
+
311
+ const client = await connect({
312
+ host: device.connection.host,
313
+ port: device.connection.port,
314
+ user: device.connection.user,
315
+ password: resolve(user.password),
316
+ hostKeys: HOST_KEYS,
317
+ });
318
+ const channel = await shell(client);
319
+ const close = () => {
320
+ channel.end();
321
+ client.end();
322
+ };
323
+
324
+ const banner = await readUntil(channel, (text) => /[#>]\s*$/.test(text.trimEnd()), 30_000);
325
+ const version = await send(channel, "show version", 15_000, 1500);
326
+ if (!/[#>]\s*$/.test(banner.trimEnd()) || !version.includes("ROS")) {
327
+ close();
328
+ throw new Error(`${device.name} did not answer as ROS`);
329
+ }
330
+
331
+ await send(channel, "terminal page-break disable", 15_000, 1000);
332
+ return { channel, close };
333
+ }
334
+
335
+ /** What the device runs, credentials scrubbed. */
336
+ export async function read(device: Device): Promise<string> {
337
+ const { channel, close } = await open(device);
338
+
339
+ try {
340
+ // Without paging off and a long wait for silence, the dump stops halfway with no error.
341
+ const dump = await send(channel, "show running-config", 150_000, 6000);
342
+ const lines = dump.replace(/\r/g, "").split("\n");
343
+ const start = lines.findIndex((line) => line.trim().startsWith("!ROS version"));
344
+ if (start < 0) throw new Error(`${device.name} returned no configuration`);
345
+
346
+ // The prompt is not configuration; uptime, clock and boot count change on an untouched device.
347
+ const body = lines.slice(start).filter((line) => line.startsWith("!") || !/[#>]\s*$/.test(line.trim()));
348
+ const text = body
349
+ .join("\n")
350
+ .replace(/^!(System (up )?time|Boot times):.*\n/gm, "")
351
+ .trim();
352
+ // An SNMPv3 user prints localized keys, which authenticate as well as the passphrase.
353
+ return scrub(text + "\n").replace(/(authkey \S+|privkey\s+\S+) \S+/g, "$1 <redacted>");
354
+ } finally {
355
+ close();
356
+ }
357
+ }
358
+
359
+ export function unsupported(device: Device): string[] {
360
+ const found: string[] = [];
361
+
362
+ for (const [name, port] of Object.entries(device.ports ?? {})) {
363
+ if (!port) continue;
364
+
365
+ for (const field of ["speed", "acl", "mtu", "addresses", "vrrp", "vrf"] as const) {
366
+ if (port[field] !== undefined) found.push(`ports.${name}.${field}`);
367
+ }
368
+
369
+ if (port.lldp === true) found.push(`ports.${name}.lldp true, LLDP is turned on per device`);
370
+ if (typeof port.stp === "object" || port.stp === true) found.push(`ports.${name}.stp other than false`);
371
+ const storm = port.storm_control;
372
+ if (storm && (storm.multicast || storm.unknown_unicast || (storm.broadcast && "percent" in storm.broadcast))) {
373
+ found.push(`ports.${name}.storm_control other than broadcast in pps`);
374
+ }
375
+ }
376
+
377
+ for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
378
+ if (iface.type === "lag") {
379
+ for (const field of ["addresses", "vrrp", "vrf", "mtu", "storm_control", "stp"] as const) {
380
+ if (iface[field] !== undefined) found.push(`interfaces.${name}.${field}, a LAG here only switches`);
381
+ }
382
+ continue;
383
+ }
384
+
385
+ if (iface.type !== "vlan") {
386
+ found.push(`interfaces.${name}, a ${iface.type}`);
387
+ continue;
388
+ }
389
+
390
+ if (iface.vrf) found.push(`interfaces.${name}.vrf`);
391
+
392
+ for (const group of iface.vrrp ?? []) {
393
+ if (familyOf(group.address) === "ipv6") found.push(`interfaces.${name}.vrrp for IPv6`);
394
+ }
395
+ }
396
+
397
+ const routing = device.routing;
398
+
399
+ for (const field of ["bgp", "rpki"] as const) {
400
+ if (routing?.[field]) found.push(`routing.${field}`);
401
+ }
402
+
403
+ const ospf = routing?.ospf;
404
+ if (ospf?.families?.includes("ipv6")) found.push("routing.ospf for IPv6");
405
+
406
+ for (const [areaId, area] of Object.entries(ospf?.areas ?? {})) {
407
+ for (const [name, settings] of Object.entries(area.interfaces)) {
408
+ if (device.interfaces?.[name]?.type !== "vlan")
409
+ found.push(`routing.ospf.areas.${areaId}.${name}, OSPF runs on VLAN interfaces here`);
410
+ if (settings?.bfd) found.push(`routing.ospf.areas.${areaId}.${name}.bfd`);
411
+ }
412
+ }
413
+
414
+ for (const route of routing?.static ?? []) {
415
+ if (!route.via || route.vrf) found.push(`routing.static ${route.prefix}, only an IPv4 next hop in the default table`);
416
+ }
417
+
418
+ const UNSUPPORTED_SECTIONS = [
419
+ "firewall",
420
+ "dhcp",
421
+ "dhcp_relay",
422
+ "acls",
423
+ "policies",
424
+ "prefix_sets",
425
+ "hardware_offload",
426
+ "certificates",
427
+ "vrfs",
428
+ "flow_export",
429
+ ] as const;
430
+
431
+ for (const field of UNSUPPORTED_SECTIONS) {
432
+ if (device[field] !== undefined) found.push(field);
433
+ }
434
+
435
+ const management = device.management ?? {};
436
+
437
+ for (const field of ["http", "https", "api", "api_tls", "ftp", "native", "console"] as const) {
438
+ if (management[field]) found.push(`management.${field}`);
439
+ }
440
+
441
+ for (const [name, user] of Object.entries(management.snmp?.users ?? {})) {
442
+ if (!SNMP_AUTH[user.auth])
443
+ found.push(`management.snmp.users.${name}.auth ${user.auth}, the platform authenticates with SHA1 or MD5`);
444
+ }
445
+
446
+ if (device.lldp && device.lldp !== true) found.push("lldp.interfaces, LLDP is per device");
447
+ if (device.stp?.mode && device.stp.mode !== "mstp") found.push("stp.mode other than mstp");
448
+
449
+ for (const [name, user] of Object.entries(device.users ?? {})) {
450
+ if (user.role !== "admin") found.push(`users.${name}.role other than admin`);
451
+ }
452
+
453
+ const system = device.system ?? {};
454
+
455
+ if (system.dns || system.logging || system.release_channel || system.banner || system.ntp?.serve) {
456
+ found.push("system.dns, logging, release_channel, banner and ntp.serve");
457
+ }
458
+
459
+ return found;
460
+ }
461
+
462
+ /** What the device prints when it refuses a line. */
463
+ const REFUSED = /^(%|Error|Incomplete|Unrecognized)|can not|cannot |is in use|Wrong parameter|invalid|unsuccessful/im;
464
+
465
+ /** Lines that ask for `y` and do nothing without it. */
466
+ const CONFIRM = /^(portswitch|switchport trunk allowed vlan|switchport mode)/;
467
+
468
+ /** A service letting go of an access-list, so the list can change. */
469
+ const RELEASE = /^no (\S+ access-list \d+)$/;
470
+
471
+ export async function apply(device: Device, _network: readonly Device[], plan: Plan): Promise<void> {
472
+ const { channel, close } = await open(device);
473
+ // Bindings released for an access-list edit. A failure binds them again before anything else, so the list keeps guarding management.
474
+ const released: string[] = [];
475
+
476
+ try {
477
+ await send(channel, "config", 10_000);
478
+
479
+ for (const step of plan.steps) {
480
+ for (const line of step.send) {
481
+ let answer = await send(channel, substitute(line), 20_000, 2000);
482
+ if (/[[(]y\/n[\])]|input 'y'/i.test(answer) || CONFIRM.test(line)) answer += await send(channel, "y", 10_000, 1000);
483
+ if (REFUSED.test(answer)) throw new Error(`${step.title}: ${line}\n device said: ${answer.trim().slice(0, 300)}`);
484
+
485
+ const release = RELEASE.exec(line);
486
+ if (release) released.push(release[1]!);
487
+ else if (released.includes(line)) released.splice(released.indexOf(line), 1);
488
+ }
489
+ }
490
+
491
+ await send(channel, "exit", 10_000);
492
+ } catch (error) {
493
+ await send(channel, "end", 10_000).catch(() => undefined);
494
+ await send(channel, "config", 10_000).catch(() => undefined);
495
+ for (const binding of released) await send(channel, binding, 10_000).catch(() => undefined);
496
+ if (released.length) console.log(` bound again: ${released.join(", ")}`);
497
+ throw error;
498
+ } finally {
499
+ close();
500
+ }
501
+
502
+ const proof = await open(device).catch(() => undefined);
503
+ if (!proof)
504
+ throw new Error(
505
+ "applied, but a fresh login failed. Not written: a reboot restores the previous configuration. Recover from the console."
506
+ );
507
+ await send(proof.channel, "write", 60_000, 3000);
508
+ proof.close();
509
+ console.log(" applied, login proved, written");
510
+ }
511
+
512
+ /** Services holding the management access-list. It cannot change while any of them does. */
513
+ const holders = (config: string) =>
514
+ [...config.matchAll(new RegExp(`^([a-z0-9-]+) access-list ${MANAGEMENT_ACL}$`, "gm"))].map((match) => match[1]!);
515
+
516
+ /** The VLAN IDs of a `create vlan 10,20-30 active` line. */
517
+ export function createdIds(line: string): number[] {
518
+ const list = line.replace(/^create vlan /, "").replace(/ active$/, "");
519
+ const ids: number[] = [];
520
+
521
+ for (const part of list.split(",")) {
522
+ const [first, last] = part.split("-").map(Number);
523
+ for (let id = first!; id <= (last ?? first!); id++) ids.push(id);
524
+ }
525
+
526
+ return ids;
527
+ }
528
+
529
+ function plan(desired: string, current: string): ReturnType<DeviceAdapter["plan"]> {
530
+ const groups = compare(desired, current, dialect);
531
+
532
+ // An access-list rule is not overwritten by one with the same number; the old one is removed first.
533
+ const existing = entries(current, dialect);
534
+
535
+ for (const group of groups) {
536
+ if (!group.path[0]?.startsWith("access-list ")) continue;
537
+
538
+ for (const line of group.add) {
539
+ const number = /^rule (\d+) /.exec(line)?.[1];
540
+ if (!number) continue;
541
+
542
+ const old = existing.find(
543
+ (entry) =>
544
+ entry.path.join("\n") === group.path.join("\n") && entry.line.startsWith(`rule ${number} `) && entry.line !== line
545
+ );
546
+ if (old && !group.remove.includes(old.line)) group.remove.push(old.line);
547
+ }
548
+ }
549
+
550
+ // A VLAN is created in a list and removed one at a time, with `no vlan <id>`.
551
+ vlanListChange(
552
+ groups,
553
+ "create vlan ",
554
+ createdIds,
555
+ (ids) => [`create vlan ${vlanList(ids)} active`],
556
+ (ids) => ids.map((id) => `vlan ${id}`)
557
+ );
558
+
559
+ // A port takes layer 2 settings only once it is switched.
560
+ for (const group of groups) {
561
+ const isPort = group.path[0]?.startsWith("interface ") ?? false;
562
+ const switches = group.add.some((line) => line.startsWith("switchport") || line.startsWith("port-channel"));
563
+ if (isPort && switches) group.add.unshift("portswitch");
564
+ }
565
+
566
+ // A LAG is removed only once no port is in it, so its removal goes after every other step.
567
+ const lagRemovals: string[] = [];
568
+
569
+ for (const group of groups) {
570
+ if (group.path.length) continue;
571
+ lagRemovals.push(...group.remove.filter((line) => line.startsWith("interface port-channel")));
572
+ group.remove = group.remove.filter((line) => !line.startsWith("interface port-channel"));
573
+ }
574
+
575
+ // Editing the access-list means releasing every service holding it first, and binding the wanted ones after.
576
+ const releaseAndBind = (group: Group) => {
577
+ if (group.path[0] !== `access-list ${MANAGEMENT_ACL}`) return { before: [], after: [] };
578
+
579
+ return {
580
+ before: holders(current).map((service) => `no ${service} access-list ${MANAGEMENT_ACL}`),
581
+ after: holders(desired).map((service) => `${service} access-list ${MANAGEMENT_ACL}`),
582
+ };
583
+ };
584
+
585
+ const planned = steps(
586
+ groups.filter((group) => group.add.length || group.remove.length),
587
+ dialect,
588
+ releaseAndBind
589
+ );
590
+
591
+ if (lagRemovals.length) {
592
+ const removeLags: Step = {
593
+ title: "LAGs no longer used",
594
+ show: lagRemovals.map((line) => `- ${line}`),
595
+ send: lagRemovals.map(undo),
596
+ };
597
+ planned.push(removeLags);
598
+ }
599
+
600
+ return { steps: planned, problems: [] };
601
+ }
602
+
603
+ export const raisecom: DeviceAdapter = { extension: ".txt", unsupported, render, read, plan, apply };
@@ -0,0 +1,7 @@
1
+ // Port counts per speed class, and how the platform spells a port. `{n}` is the position within the class. The out-of-band management port is not a switch port and is left out.
2
+ export const models = {
3
+ "RAX721-C-6C48": {
4
+ ports: { "100g": 6, "25g": 24, "10g": 24 },
5
+ spelling: { "100g": "hundredgige 1/1/{n}", "25g": "twenty-fivegige 1/2/{n}", "10g": "tengigabitethernet 1/3/{n}" },
6
+ },
7
+ } as const;
@@ -0,0 +1,33 @@
1
+ # RouterOS 7 traps
2
+
3
+ Behaviour that looks like something else. Each one has been hit on a real device.
4
+
5
+ - Every command takes effect as it arrives; there is no candidate configuration. `apply` saves a backup, arms a scheduler that restores it, and restores at once if a command is refused.
6
+ - A `/32` address gives no connected route, so a session over it never opens. State the far end: `{ address, peer }` renders `network=<peer>`.
7
+ - `[find address=X/Y]` unquoted matches nothing, silently. Every selector the plan builds is quoted.
8
+ - `~` in `find` has no anchors. Match exactly.
9
+ - A property of the same group is printed as a bare suffix, `output.filter-chain=X .network=Y`, which only reads back inside that command. The parser expands it before building a `set`.
10
+ - `/routing bgp connection add` without `local.role` is refused, so the render always states one.
11
+ - A connection without `input.filter` accepts everything. A neighbor without a policy is given `REJECT-ALL`.
12
+ - A session edited repeatedly can stick: SYN both ways, no RST, no log. Remove the connection and add it again.
13
+ - A session that never established is missing from `/routing bgp session print`; look at `/routing bgp connection print`.
14
+ - `tcp-md5-key` set from the CLI has been seen discarded without an error, and a mismatch logs nothing. Confirm with `/tool sniffer quick port=179`.
15
+ - Incoming GRE is subject to the input chain. Accept it before testing the tunnel.
16
+ - Switch-chip rules have no address lists and no connection state. A drop is an empty `new-dst-ports`.
17
+ - With hardware offload on, routed traffic bypasses the forward chain entirely. `hardware_offload: false` on an interface is what makes the firewall apply to it, at the cost of CPU. The only proof of offload is the `H` flag on a route.
18
+ - `/ip cloud ddns-enabled` cannot be turned off on 7.23.5.
19
+ - `mac-server` and `mac-winbox` work at layer 2 and ignore the IP firewall; they are limited by interface list.
20
+ - `time-zone-autodetect` is on by default and wins after a restart. The render turns it off.
21
+ - The SSH client drops a password sent together with its newline. Send the password, wait, then the newline.
22
+ - A serial console login redraws lines unless the user name carries `+cwt`.
23
+ - Narrowing a subnet is a change in several places: the address, the DHCP network, and every switch on the LAN. A mismatch breaks only the return path, so a test from the router passes.
24
+ - In `find`, a quoted `"yes"` is a string and matches no boolean, silently. Plain words go bare; only values with a slash or a space are quoted.
25
+ - `:put [:parse "..."]` compiles a command without running it and names a syntax error or an unknown property. `apply` checks every command this way before the backup is taken.
26
+ - A refused command restores the backup, which restarts the router. Expect every session to re-establish over a minute or two.
27
+ - `strong-crypto=yes` also hardens the device's own SSH client, which then fails silently toward devices that only speak older algorithms.
28
+ - SNMPv3 authenticates with MD5 or SHA1 only on 7.23.
29
+ - In a quoted string `$` starts a variable. The adapter escapes it.
30
+ - A script sent over SSH exec runs as one line; a multi-line script fails with `expected closing brace`.
31
+ - `:find` on an array returns a number or `nil`. Globals persist between exec commands of one login.
32
+ - A fixed port speed is named by medium, `speed=10G-baseSR-LR` for an optic or `speed=1G-baseT-full` for copper, and only holds with `auto-negotiation=no`. With auto-negotiation back on, the export no longer prints the speed, so the plan resets only auto-negotiation. `speed=""` is refused as ambiguous and `unset speed` does not exist.
33
+ - `bgp-path-len` counts every AS in the path, prepends included, and takes the same strict comparisons as `dst-len`.