@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,742 @@
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 { familyOf, host, netmask, split, wildcard } from "../../../core/addr";
7
+ import { marker, resolve, substitute, valueOfReference } from "../../../core/secrets";
8
+ import { offset } from "../../../core/time";
9
+ import type { Device, Interface, Port, 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, scrub, steps, vlanListChange } from "../lines";
14
+ import type { DeviceAdapter, Plan } from "../types";
15
+
16
+ /** Lines the device prints whatever is configured, or that describe the hardware rather than its configuration. */
17
+ const DEVICE_FACTS = [
18
+ "!",
19
+ "device ",
20
+ "system resource",
21
+ "drop-profile",
22
+ "dcb ",
23
+ "diffserv domain",
24
+ "vlan reserved",
25
+ "interface MEth",
26
+ "interface NULL",
27
+ "stack",
28
+ "vm-manager",
29
+ "return",
30
+ "authentication-scheme default",
31
+ "authorization-scheme default",
32
+ "accounting-scheme default",
33
+ "domain default",
34
+ "Info:",
35
+ ];
36
+
37
+ /** Lines whose removal takes only their keyword, not their value. */
38
+ const UNDO_BY_KEYWORD =
39
+ /^(description|name|port default vlan|port link-type|port trunk pvid vlan|set authentication password|eth-trunk|ip binding vpn-instance)\b/;
40
+
41
+ function undo(line: string): string {
42
+ // A negated line is undone by stating it.
43
+ if (line.startsWith("undo ")) return line.slice("undo ".length);
44
+ // An ACL rule is removed by its number.
45
+ if (/^rule \d+/.test(line)) return `undo ${line.split(" ").slice(0, 2).join(" ")}`;
46
+
47
+ const keyword = UNDO_BY_KEYWORD.exec(line);
48
+ if (keyword) return `undo ${keyword[1]}`;
49
+ // Storm control is removed per kind, without its rates.
50
+ if (line.startsWith("storm control ")) return `undo ${line.replace(/ (min-rate|max-rate).*$/, "")}`;
51
+
52
+ return `undo ${line}`;
53
+ }
54
+
55
+ /** Secrets the device prints as ciphers and the render writes as markers compare equal. */
56
+ function canon(line: string): string {
57
+ return line.replace(/(irreversible-cipher|cipher) \S+/g, "cipher <secret>").replace(/<secret:\w+>/g, "<secret>");
58
+ }
59
+
60
+ /** 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. */
61
+ function order(path: string[]): number {
62
+ const header = path[0] ?? "";
63
+ if (!header) return 1;
64
+
65
+ if (/^(ip vpn-instance|vlan |acl number|aaa|bfd)/.test(header)) return 0;
66
+
67
+ if (header.startsWith("interface Eth-Trunk")) return 2;
68
+
69
+ if (header.startsWith("interface Vlanif")) return 4;
70
+
71
+ if (header.startsWith("interface ")) return 3;
72
+
73
+ if (header.startsWith("ospf ")) return 5;
74
+
75
+ return 6;
76
+ }
77
+
78
+ export const dialect: Dialect = {
79
+ block: /^(interface |vlan \d+$|acl number |aaa$|user-interface |ospf |ip vpn-instance |bfd$)/,
80
+ indented: true,
81
+ terminator: "#",
82
+ ignore: (line) => DEVICE_FACTS.some((fact) => line.startsWith(fact)),
83
+ undo,
84
+ leave: "quit",
85
+ // The console refuses to drop its password while password login is still on.
86
+ first: /^authentication-mode /,
87
+ canon,
88
+ order,
89
+ };
90
+
91
+ /** The ACL that guards every management service. */
92
+ export const MANAGEMENT_ACL = 2000;
93
+
94
+ /** The OSPF process number. One process per device. */
95
+ const OSPF_PROCESS = 1;
96
+
97
+ /** The sFlow collectors are numbered from here, in the order the config lists them. */
98
+ const FIRST_COLLECTOR = 1;
99
+
100
+ /** The SNMPv3 group every user joins, with read access to everything. */
101
+ const SNMP_GROUP = "circuit";
102
+
103
+ const USER_LEVEL = { admin: 3, operator: 2, "read-only": 1 } as const;
104
+ const SNMP_AUTH = { sha1: "sha", sha256: "sha2-256" } as const;
105
+ const SNMP_PRIVACY = { aes128: "aes128", des: "des56" } as const;
106
+
107
+ type AnyPort = Port<string, string, string, string>;
108
+ type AnyInterface = Interface<string, string>;
109
+ type LagInterface = Extract<AnyInterface, { type: "lag" }>;
110
+
111
+ /** `99 999 to 1000 2000`, the platform's compression of a VLAN list. */
112
+ /** The VLAN IDs of a `vlan batch` line: `vlan batch 10 20 to 30`. */
113
+ export function batchIds(line: string): number[] {
114
+ const words = line.replace(/^vlan batch /, "").split(" ");
115
+ const ids: number[] = [];
116
+
117
+ for (let index = 0; index < words.length; index++) {
118
+ const first = Number(words[index]);
119
+
120
+ if (words[index + 1] === "to") {
121
+ const last = Number(words[index + 2]);
122
+ for (let id = first; id <= last; id++) ids.push(id);
123
+ index += 2;
124
+ } else {
125
+ ids.push(first);
126
+ }
127
+ }
128
+
129
+ return ids;
130
+ }
131
+
132
+ export function vlanBatch(ids: number[]): string {
133
+ const sorted = [...new Set(ids)].sort((a, b) => a - b);
134
+ const ranges: string[] = [];
135
+
136
+ for (let i = 0; i < sorted.length; i++) {
137
+ let end = i;
138
+ while (sorted[end + 1] === sorted[end]! + 1) end++;
139
+ ranges.push(i === end ? `${sorted[i]}` : `${sorted[i]} to ${sorted[end]}`);
140
+ i = end;
141
+ }
142
+
143
+ return ranges.join(" ");
144
+ }
145
+
146
+ /** Builds the configuration as blocks: a header line, its indented lines, then `#`. */
147
+ class Config {
148
+ private readonly lines: string[] = [];
149
+
150
+ line(text: string): void {
151
+ this.lines.push(text);
152
+ }
153
+
154
+ /** A header and its lines. A block with nothing in it is still written, so that its header exists. */
155
+ block(header: string, body: string[]): void {
156
+ this.lines.push(header, ...body.map((text) => ` ${text}`), "#");
157
+ }
158
+
159
+ separator(): void {
160
+ this.lines.push("#");
161
+ }
162
+
163
+ text(): string {
164
+ return this.lines.join("\n") + "\n";
165
+ }
166
+ }
167
+
168
+ export function render(device: Device): string {
169
+ const config = new Config();
170
+ const vlans = device.vlans ?? {};
171
+ const vlanId = (name: string) => vlans[name]!.id;
172
+ const interfaces = device.interfaces ?? {};
173
+ const lagNumbers = lagIds(device);
174
+
175
+ /** What the platform calls a port, a LAG or a VLAN interface. */
176
+ const nameOf = (portOrInterface: string): string => {
177
+ const iface = interfaces[portOrInterface];
178
+ if (iface?.type === "lag") return `Eth-Trunk${lagNumbers.get(portOrInterface)}`;
179
+
180
+ if (iface?.type === "vlan") return `Vlanif${vlanId(iface.vlan)}`;
181
+
182
+ return spell(device.platform, device.model, portOrInterface);
183
+ };
184
+
185
+ system(config, device);
186
+ vlanBlocks(config, device);
187
+ managementAcl(config, device);
188
+ users(config, device);
189
+ vrfs(config, device);
190
+
191
+ for (const [name, lag] of Object.entries(interfaces)) {
192
+ if (lag.type === "lag") config.block(`interface ${nameOf(name)}`, [...lagMode(lag), ...switching(lag, vlanId)]);
193
+ }
194
+
195
+ for (const [name, iface] of Object.entries(interfaces)) {
196
+ if (iface.type === "vlan") config.block(`interface ${nameOf(name)}`, vlanInterface(device, name, iface));
197
+ }
198
+
199
+ for (const port of portsOf(device.platform, device.model)) {
200
+ const settings = device.ports?.[port as keyof typeof device.ports];
201
+ config.block(`interface ${nameOf(port)}`, physicalPort(device, port, settings, vlanId, lagNumbers));
202
+ }
203
+
204
+ staticRoutes(config, device, nameOf);
205
+ ospf(config, device, nameOf);
206
+ if (wantsBfd(device)) config.block("bfd", []);
207
+ if (device.dhcp_relay && Object.keys(device.dhcp_relay).length) {
208
+ config.line("dhcp enable");
209
+ config.separator();
210
+ }
211
+
212
+ flowExport(config, device);
213
+ snmp(config, device);
214
+ ssh(config, device);
215
+ userInterfaces(config, device);
216
+ return config.text();
217
+ }
218
+
219
+ function system(config: Config, device: Device): void {
220
+ config.line(`sysname ${device.name}`);
221
+ config.separator();
222
+
223
+ const timezone = device.system?.timezone;
224
+ if (timezone) {
225
+ const minutes = offset(timezone);
226
+ const absolute = Math.abs(minutes);
227
+ const hours = String(Math.floor(absolute / 60)).padStart(2, "0");
228
+ const remainder = String(absolute % 60).padStart(2, "0");
229
+ // The platform names the zone with one word; the last part of the IANA name serves.
230
+ config.line(`clock timezone ${timezone.split("/").at(-1)} ${minutes < 0 ? "minus" : "add"} ${hours}:${remainder}:00`);
231
+ config.separator();
232
+ }
233
+
234
+ const stp = device.stp;
235
+ if (!stp) config.line("stp disable");
236
+ else {
237
+ // MSTP is the platform's default and is not written.
238
+ if (stp.mode && stp.mode !== "mstp") config.line(`stp mode ${stp.mode}`);
239
+ if (stp.priority !== undefined) config.line(`stp priority ${stp.priority}`);
240
+ }
241
+ // LLDP is off by default and turned on by the config.
242
+ if (device.lldp) config.line("lldp enable");
243
+ config.separator();
244
+
245
+ const ntp = device.system?.ntp;
246
+ if (!ntp?.serve) {
247
+ config.line("ntp server disable");
248
+ config.line("ntp ipv6 server disable");
249
+ }
250
+
251
+ for (const server of ntp?.servers ?? []) config.line(`ntp unicast-server ${server}`);
252
+ config.separator();
253
+
254
+ const ids = Object.values(device.vlans ?? {}).map((vlan) => vlan.id);
255
+ if (ids.length) {
256
+ config.line(`vlan batch ${vlanBatch(ids)}`);
257
+ config.separator();
258
+ }
259
+
260
+ if (!device.management?.telnet) {
261
+ config.line("telnet server disable");
262
+ config.line("telnet ipv6 server disable");
263
+ config.separator();
264
+ }
265
+ }
266
+
267
+ function vlanBlocks(config: Config, device: Device): void {
268
+ for (const [name, vlan] of Object.entries(device.vlans ?? {})) {
269
+ config.block(`vlan ${vlan.id}`, [`name ${name}`, ...(vlan.description ? [`description ${vlan.description}`] : [])]);
270
+ }
271
+ }
272
+
273
+ /** One ACL guards every management service: a permit per source, then an explicit deny. */
274
+ function managementAcl(config: Config, device: Device): void {
275
+ const allow = device.management?.allow ?? [];
276
+ if (!allow.length) return;
277
+
278
+ const rules = allow.map((cidr, index) => `rule ${(index + 1) * 5} permit source ${host(cidr)} ${wildcard(split(cidr).len)}`);
279
+ config.block(`acl number ${MANAGEMENT_ACL}`, ["description Management access", ...rules, `rule ${(allow.length + 1) * 5} deny`]);
280
+ }
281
+
282
+ function users(config: Config, device: Device): void {
283
+ const lines: string[] = [];
284
+
285
+ for (const [name, user] of Object.entries(device.users ?? {})) {
286
+ lines.push(`local-user ${name} password irreversible-cipher ${marker(user.password)}`);
287
+ lines.push(`local-user ${name} service-type ssh`);
288
+ lines.push(`local-user ${name} level ${USER_LEVEL[user.role]}`);
289
+ }
290
+
291
+ config.block("aaa", lines);
292
+ }
293
+
294
+ function vrfs(config: Config, device: Device): void {
295
+ for (const name of Object.keys(device.vrfs ?? {})) config.block(`ip vpn-instance ${name}`, ["ipv4-family"]);
296
+ }
297
+
298
+ function lagMode(lag: LagInterface): string[] {
299
+ const lines = [lag.mode === "static" ? "mode manual load-balance" : "mode lacp-static"];
300
+ if (lag.description) lines.unshift(`description ${lag.description}`);
301
+ return lines;
302
+ }
303
+
304
+ /** Layer 2 membership of a port or LAG. */
305
+ function switching(settings: AnyPort | LagInterface, vlanId: (name: string) => number): string[] {
306
+ const lines: string[] = [];
307
+ if (settings.access_vlan) lines.push(`port default vlan ${vlanId(settings.access_vlan)}`);
308
+ if (settings.trunk_vlans) {
309
+ lines.push("port link-type trunk");
310
+ if (settings.native_vlan) lines.push(`port trunk pvid vlan ${vlanId(settings.native_vlan)}`);
311
+ // VLAN 1 is allowed by default and never wanted. The native VLAN has to be allowed too, or its untagged frames are dropped.
312
+ const allowed = [...settings.trunk_vlans, ...(settings.native_vlan ? [settings.native_vlan] : [])].map(vlanId);
313
+ lines.push("undo port trunk allow-pass vlan 1");
314
+ lines.push(`port trunk allow-pass vlan ${vlanBatch(allowed)}`);
315
+ }
316
+
317
+ if (settings.stp === false) lines.push("stp disable");
318
+ else if (typeof settings.stp === "object") {
319
+ if (settings.stp.edge) lines.push("stp edged-port enable");
320
+ if (settings.stp.cost !== undefined) lines.push(`stp cost ${settings.stp.cost}`);
321
+ if (settings.stp.priority !== undefined) lines.push(`stp port priority ${settings.stp.priority}`);
322
+ }
323
+
324
+ const storm = settings.storm_control;
325
+ if (storm) {
326
+ const kinds = [
327
+ ["broadcast", storm.broadcast],
328
+ ["multicast", storm.multicast],
329
+ ["unknown-unicast", storm.unknown_unicast],
330
+ ] as const;
331
+
332
+ for (const [kind, threshold] of kinds) {
333
+ if (!threshold || !("percent" in threshold)) continue;
334
+
335
+ const percent = threshold.percent;
336
+ lines.push(`storm control ${kind} min-rate percent ${percent} max-rate percent ${percent}`);
337
+ }
338
+
339
+ lines.push("storm control action suppress", "storm control enable trap", "storm control enable log");
340
+ }
341
+
342
+ return lines;
343
+ }
344
+
345
+ function vrrpLines(group: Vrrp): string[] {
346
+ const lines = [`vrrp vrid ${group.id} virtual-ip ${group.address}`];
347
+ if (group.priority !== undefined) lines.push(`vrrp vrid ${group.id} priority ${group.priority}`);
348
+ // The platform preempts by default; the config only does when asked.
349
+ if (!group.preempt) lines.push(`vrrp vrid ${group.id} preempt disable`);
350
+ if (group.interval !== undefined && group.interval !== 1) lines.push(`vrrp vrid ${group.id} timer advertise ${group.interval}`);
351
+ return lines;
352
+ }
353
+
354
+ function vlanInterface(device: Device, name: string, iface: Extract<AnyInterface, { type: "vlan" }>): string[] {
355
+ const lines: string[] = [];
356
+ if (iface.description) lines.push(`description ${iface.description}`);
357
+ // Binding to a VRF clears the addresses, so it comes first.
358
+ if (iface.vrf) lines.push(`ip binding vpn-instance ${iface.vrf}`);
359
+
360
+ for (const entry of iface.addresses ?? []) {
361
+ const address = typeof entry === "string" ? entry : entry.address;
362
+ if (familyOf(address) === "ipv4") lines.push(`ip address ${host(address)} ${netmask(split(address).len)}`);
363
+ else lines.push(`ipv6 address ${address}`);
364
+ }
365
+
366
+ for (const group of iface.vrrp ?? []) lines.push(...vrrpLines(group));
367
+
368
+ const relays = Object.values(device.dhcp_relay ?? {}).filter((relay) => relay.interface === name);
369
+ if (relays.length) {
370
+ lines.push("dhcp select relay");
371
+ for (const server of relays.flatMap((relay) => relay.servers)) lines.push(`dhcp relay binding server ip ${server}`);
372
+ }
373
+
374
+ for (const area of Object.values(device.routing?.ospf?.areas ?? {})) {
375
+ const settings = area.interfaces[name as keyof typeof area.interfaces];
376
+ if (!settings) continue;
377
+
378
+ if (settings.cost !== undefined) lines.push(`ospf cost ${settings.cost}`);
379
+ if (settings.network === "point-to-point") lines.push("ospf network-type p2p");
380
+ if (settings.bfd) lines.push("ospf bfd enable");
381
+ }
382
+
383
+ return lines;
384
+ }
385
+
386
+ /** A port the config does not declare is shut down. A LAG member holds only its membership. */
387
+ function physicalPort(
388
+ device: Device,
389
+ port: string,
390
+ settings: AnyPort | undefined,
391
+ vlanId: (name: string) => number,
392
+ lagNumbers: Map<string, number>
393
+ ): string[] {
394
+ if (!settings) return ["shutdown"];
395
+
396
+ // A port joins a LAG only while it holds no other setting, so membership comes before its description.
397
+ if (settings.lag) {
398
+ const membership = `eth-trunk ${lagNumbers.get(settings.lag)}`;
399
+ return settings.description ? [membership, `description ${settings.description}`] : [membership];
400
+ }
401
+
402
+ const lines: string[] = [];
403
+ if (settings.description) lines.push(`description ${settings.description}`);
404
+ lines.push(...switching(settings, vlanId));
405
+ if (settings.lldp === false) lines.push("lldp disable");
406
+
407
+ const flow = device.flow_export;
408
+ const observed = flow?.protocol === "sflow" && (!flow.interfaces || flow.interfaces.includes(port));
409
+ if (observed) {
410
+ for (let index = 0; index < flow!.collectors.length; index++) lines.push(`sflow sampling collector ${FIRST_COLLECTOR + index}`);
411
+ lines.push(`sflow sampling rate ${flow!.sampling}`);
412
+ lines.push("sflow sampling inbound");
413
+ }
414
+
415
+ return lines;
416
+ }
417
+
418
+ function staticRoutes(config: Config, device: Device, nameOf: (name: string) => string): void {
419
+ for (const route of device.routing?.static ?? []) {
420
+ const { addr, len } = split(route.prefix);
421
+ const nextHop = route.blackhole ? "NULL0" : (route.via ?? nameOf(route.interface!));
422
+ const vrf = route.vrf ? ` vpn-instance ${route.vrf}` : "";
423
+ const preference = route.distance ? ` preference ${route.distance}` : "";
424
+ const description = route.description ? ` description ${route.description}` : "";
425
+ if (familyOf(addr) === "ipv4") config.line(`ip route-static${vrf} ${addr} ${netmask(len)} ${nextHop}${preference}${description}`);
426
+ else config.line(`ipv6 route-static${vrf} ${addr} ${len} ${nextHop}${preference}`);
427
+ }
428
+
429
+ config.separator();
430
+ }
431
+
432
+ /**
433
+ * OSPF for IPv4. An area announces the networks of its interfaces; a passive interface is silent. `unsupported()` refuses IPv6.
434
+ */
435
+ function ospf(config: Config, device: Device, nameOf: (name: string) => string): void {
436
+ const ospf = device.routing?.ospf;
437
+ if (!ospf) return;
438
+
439
+ const routerId = device.routing?.router_id;
440
+ const lines: string[] = [];
441
+ for (const source of ospf.redistribute ?? []) lines.push(`import-route ${source === "connected" ? "direct" : "static"}`);
442
+
443
+ const areaLines: string[] = [];
444
+
445
+ for (const [areaId, area] of Object.entries(ospf.areas)) {
446
+ areaLines.push(`area ${areaId}`);
447
+
448
+ for (const [iface, settings] of Object.entries(area.interfaces)) {
449
+ if (settings?.passive) lines.push(`silent-interface ${nameOf(iface)}`);
450
+ for (const prefix of ipv4Networks(device, iface)) areaLines.push(` network ${host(prefix)} ${wildcard(split(prefix).len)}`);
451
+ }
452
+ }
453
+
454
+ config.block(`ospf ${OSPF_PROCESS}${routerId ? ` router-id ${routerId}` : ""}`, [...lines, ...areaLines]);
455
+ }
456
+
457
+ function wantsBfd(device: Device): boolean {
458
+ const areas = Object.values(device.routing?.ospf?.areas ?? {});
459
+ return areas.some((area) => Object.values(area.interfaces).some((settings) => settings?.bfd));
460
+ }
461
+
462
+ /** sFlow from the management address, to each collector in order. */
463
+ function flowExport(config: Config, device: Device): void {
464
+ const flow = device.flow_export;
465
+ if (flow?.protocol !== "sflow") return;
466
+
467
+ config.line(`sflow agent ip ${device.connection.host}`);
468
+
469
+ flow.collectors.forEach((collector, index) => {
470
+ const number = FIRST_COLLECTOR + index;
471
+ config.line(`sflow collector ${number} ip ${collector.address} udp-port ${collector.port ?? 6343}`);
472
+ });
473
+
474
+ config.separator();
475
+ }
476
+
477
+ /** SNMP version 2c with one read community, version 3 with users in one read-only group, or both. */
478
+ function snmp(config: Config, device: Device): void {
479
+ const snmp = device.management?.snmp;
480
+ if (!snmp) return;
481
+
482
+ const versions = [snmp.community ? "v2c" : "", snmp.users ? "v3" : ""].filter(Boolean).join(" ");
483
+ const guarded = (device.management?.allow?.length ?? 0) > 0;
484
+
485
+ config.line("snmp-agent");
486
+ config.line(`snmp-agent sys-info version ${versions}`);
487
+ if (snmp.contact) config.line(`snmp-agent sys-info contact ${snmp.contact}`);
488
+ if (snmp.location) config.line(`snmp-agent sys-info location ${snmp.location}`);
489
+ if (snmp.community) config.line(`snmp-agent community read cipher ${marker(snmp.community)}${guarded ? ` acl ${MANAGEMENT_ACL}` : ""}`);
490
+ if (snmp.users) {
491
+ config.line("snmp-agent mib-view included all iso");
492
+ config.line(`snmp-agent group v3 ${SNMP_GROUP} privacy read-view all${guarded ? ` acl ${MANAGEMENT_ACL}` : ""}`);
493
+
494
+ for (const [name, user] of Object.entries(snmp.users)) {
495
+ config.line(`snmp-agent usm-user v3 ${name} group ${SNMP_GROUP}`);
496
+ config.line(`snmp-agent usm-user v3 ${name} authentication-mode ${SNMP_AUTH[user.auth]} cipher ${marker(user.auth_password)}`);
497
+ config.line(
498
+ `snmp-agent usm-user v3 ${name} privacy-mode ${SNMP_PRIVACY[user.privacy]} cipher ${marker(user.privacy_password)}`
499
+ );
500
+ }
501
+ }
502
+
503
+ config.separator();
504
+ }
505
+
506
+ function ssh(config: Config, device: Device): void {
507
+ const ssh = device.management?.ssh;
508
+ if (!ssh) return;
509
+
510
+ const guarded = (device.management?.allow?.length ?? 0) > 0;
511
+
512
+ config.line("stelnet server enable");
513
+
514
+ for (const user of Object.keys(device.users ?? {})) {
515
+ config.line(`ssh user ${user}`);
516
+ config.line(`ssh user ${user} authentication-type password`);
517
+ config.line(`ssh user ${user} service-type stelnet`);
518
+ }
519
+
520
+ if (guarded) config.line(`ssh server acl ${MANAGEMENT_ACL}`);
521
+ // Who decides whether a login is allowed: AAA, the same place for every service.
522
+ config.line("ssh authorization-type default aaa");
523
+ config.separator();
524
+
525
+ if (!ssh.weak_crypto) {
526
+ config.line("ssh server cipher aes256_ctr aes128_ctr");
527
+ config.line("ssh server hmac sha2_256_96 sha2_256");
528
+ config.line("ssh server key-exchange dh_group_exchange_sha256 ecdh_sha2_nistp256 ecdh_sha2_nistp384 ecdh_sha2_nistp521");
529
+ config.separator();
530
+ }
531
+ }
532
+
533
+ /** The console logs in with the device's users unless the config gives it a password of its own. Remote logins always use the users. */
534
+ function userInterfaces(config: Config, device: Device): void {
535
+ const console = device.management?.console;
536
+
537
+ const consoleLogin = console
538
+ ? ["authentication-mode password", `set authentication password cipher ${marker(console.password)}`]
539
+ : ["authentication-mode aaa"];
540
+
541
+ config.block("user-interface con 0", consoleLogin);
542
+ config.block("user-interface vty 0 4", ["authentication-mode aaa", `protocol inbound ${device.management?.telnet ? "all" : "ssh"}`]);
543
+ }
544
+
545
+ /** The platform offers an ECDSA host key it then signs wrongly, so RSA is asked for. */
546
+ const HOST_KEYS = ["ssh-rsa"];
547
+
548
+ export async function login(device: Device): Promise<Client> {
549
+ const user = device.users?.[device.connection.user];
550
+ if (!user) throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
551
+ return connect({
552
+ host: device.connection.host,
553
+ port: device.connection.port,
554
+ user: device.connection.user,
555
+ password: resolve(user.password),
556
+ hostKeys: HOST_KEYS,
557
+ });
558
+ }
559
+
560
+ /**
561
+ * An interactive shell at the user prompt, paging off.
562
+ *
563
+ * 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.
564
+ */
565
+ export async function openShell(client: Client): Promise<ClientChannel> {
566
+ const channel = await shell(client);
567
+ const banner = await readUntil(channel, (text) => /Change now\? ?\[Y\/N\]/i.test(text) || /<[^>]+>\s*$/.test(text), 15_000, 1500);
568
+ if (/Change now\?/i.test(banner)) await send(channel, "N", 10_000, 1500);
569
+ await send(channel, "screen-length 0 temporary", 10_000, 1500);
570
+ return channel;
571
+ }
572
+
573
+ /** What the device runs, credentials scrubbed. */
574
+ export async function read(device: Device): Promise<string> {
575
+ const client = await login(device);
576
+
577
+ try {
578
+ const channel = await openShell(client);
579
+ const dump = await send(channel, "display current-configuration", 120_000, 3000);
580
+ channel.end();
581
+
582
+ const lines = dump.replace(/\r/g, "").split("\n");
583
+ const start = lines.findIndex((line) => line.startsWith("!Software Version"));
584
+ if (start < 0) throw new Error(`${device.name} returned no configuration`);
585
+ // The prompt, and the stamp of the last change, are not configuration.
586
+ const body = lines.slice(start).filter((line) => !/^<.*>$/.test(line.trim()) && !line.startsWith("!Last configuration"));
587
+ return scrub(body.join("\n").trim() + "\n");
588
+ } finally {
589
+ client.end();
590
+ }
591
+ }
592
+
593
+ export function unsupported(device: Device): string[] {
594
+ const found: string[] = [];
595
+
596
+ for (const [name, port] of Object.entries(device.ports ?? {})) {
597
+ if (!port) continue;
598
+
599
+ for (const field of ["speed", "acl", "mtu", "addresses", "vrrp", "vrf"] as const) {
600
+ if (port[field] !== undefined) found.push(`ports.${name}.${field}, ports are switched; use a VLAN interface`);
601
+ }
602
+
603
+ if (port.lldp === true && !device.lldp) found.push(`ports.${name}.lldp true while LLDP is off on the device`);
604
+ const storm = Object.values(port.storm_control ?? {});
605
+ if (storm.some((threshold) => "pps" in threshold)) found.push(`ports.${name}.storm_control in pps, the platform takes percent`);
606
+ }
607
+
608
+ for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
609
+ if (iface.type !== "vlan" && iface.type !== "lag") found.push(`interfaces.${name}, a ${iface.type}`);
610
+ if (iface.type === "lag" && iface.addresses?.length) found.push(`interfaces.${name}.addresses, LAGs are switched`);
611
+ for (const group of iface.vrrp ?? []) if (familyOf(group.address) === "ipv6") found.push(`interfaces.${name}.vrrp for IPv6`);
612
+ }
613
+
614
+ const routing = device.routing;
615
+ if (routing?.bgp) found.push("routing.bgp");
616
+ if (routing?.rpki) found.push("routing.rpki");
617
+ if (routing?.ospf?.families?.includes("ipv6")) found.push("routing.ospf for IPv6");
618
+
619
+ for (const field of ["firewall", "dhcp", "acls", "policies", "prefix_sets", "hardware_offload", "certificates"] as const) {
620
+ if (device[field] !== undefined) found.push(field);
621
+ }
622
+
623
+ for (const [name, relay] of Object.entries(device.dhcp_relay ?? {})) {
624
+ if (device.interfaces?.[relay.interface]?.type !== "vlan")
625
+ found.push(`dhcp_relay.${name}.interface, relays run on VLAN interfaces`);
626
+ }
627
+
628
+ // The platform reserves this name.
629
+ if (device.users && "admin" in device.users) found.push("users.admin, the platform reserves the name");
630
+
631
+ const management = device.management ?? {};
632
+
633
+ for (const field of ["http", "https", "api", "api_tls", "ftp", "native", "privilege_password"] as const) {
634
+ if (management[field]) found.push(`management.${field}`);
635
+ }
636
+
637
+ if (management.ssh?.auth_timeout || management.ssh?.auth_retries) found.push("management.ssh.auth_timeout and auth_retries");
638
+
639
+ if (device.lldp && device.lldp !== true) found.push("lldp.interfaces, use lldp on the ports instead");
640
+
641
+ const system = device.system ?? {};
642
+
643
+ if (system.dns || system.logging || system.release_channel || system.banner) {
644
+ found.push("system.dns, logging, release_channel and banner");
645
+ }
646
+
647
+ const flow = device.flow_export;
648
+ if (flow && flow.protocol !== "sflow") found.push(`flow_export.protocol ${flow.protocol}, the platform exports sFlow`);
649
+ const sampling = flow?.protocol === "sflow" ? flow.sampling : undefined;
650
+ const samplingInRange = sampling !== undefined && Number.isInteger(sampling) && sampling >= 4096 && sampling <= 4_294_967_295;
651
+
652
+ if (flow?.protocol === "sflow" && !samplingInRange) {
653
+ found.push("flow_export.sampling, required and between 4096 and 4294967295: the platform samples, one packet in 4096 at most");
654
+ }
655
+
656
+ return found;
657
+ }
658
+
659
+ /** What the device prints when it refuses a line. */
660
+ const REFUSED = /^\s*Error|Unrecognized command|Incomplete command|Wrong parameter|Too many parameters/m;
661
+
662
+ /** A setting whose password the platform asks for interactively: the command, then the password, then the password again. */
663
+ const INTERACTIVE = /^(snmp-agent usm-user v3 \S+ (?:authentication|privacy)-mode \S+) cipher <secret:([A-Za-z0-9_./:-]+)>$/;
664
+
665
+ /** Send one line, answering a confirmation, and return what the device said. */
666
+ async function sendLine(channel: ClientChannel, line: string): Promise<string> {
667
+ const interactive = INTERACTIVE.exec(line.trim());
668
+ if (interactive) {
669
+ const [, command, reference] = interactive;
670
+ const password = valueOfReference(reference!);
671
+ let answer = await send(channel, command!, 15_000, 900);
672
+ answer += await send(channel, password, 15_000, 900);
673
+ answer += await send(channel, password, 15_000, 900);
674
+ return answer;
675
+ }
676
+
677
+ let answer = await send(channel, substitute(line), 20_000, 800);
678
+ if (/\[Y\/N\]/i.test(answer)) answer += await send(channel, "Y", 20_000, 800);
679
+ return answer;
680
+ }
681
+
682
+ export async function apply(device: Device, _network: readonly Device[], plan: Plan): Promise<void> {
683
+ const client = await login(device);
684
+ const channel = await openShell(client);
685
+
686
+ try {
687
+ await send(channel, "system-view", 10_000, 1500);
688
+
689
+ for (const step of plan.steps) {
690
+ for (const line of step.send) {
691
+ const answer = await sendLine(channel, line);
692
+ if (REFUSED.test(answer)) throw new Error(`${step.title}: ${line}\n device said: ${answer.trim().slice(0, 300)}`);
693
+ }
694
+ }
695
+
696
+ const committed = await send(channel, "commit", 60_000, 3000);
697
+ if (REFUSED.test(committed)) throw new Error(`commit refused: ${committed.trim().slice(0, 300)}`);
698
+ await send(channel, "quit", 10_000);
699
+ } catch (error) {
700
+ // Nothing is committed yet. Clearing the candidate leaves the device as it was.
701
+ await send(channel, "clear configuration candidate", 10_000).catch(() => undefined);
702
+ channel.end();
703
+ client.end();
704
+ throw error;
705
+ }
706
+
707
+ const proof = await login(device).catch(() => undefined);
708
+ if (!proof) {
709
+ channel.end();
710
+ client.end();
711
+ throw new Error(
712
+ "committed, but a fresh login failed. Not saved: a reboot restores the previous configuration. Recover over the open session or the console."
713
+ );
714
+ }
715
+
716
+ proof.end();
717
+
718
+ const saving = await send(channel, "save", 20_000, 2000);
719
+ if (/\[Y\/N\]/i.test(saving)) await send(channel, "Y", 60_000, 5000);
720
+ channel.end();
721
+ client.end();
722
+ console.log(" committed, login proved, saved");
723
+ }
724
+
725
+ export const vrp: DeviceAdapter = {
726
+ extension: ".cfg",
727
+ unsupported,
728
+ render,
729
+ read,
730
+ plan: (desired, current) => {
731
+ const groups = compare(desired, current, dialect);
732
+ vlanListChange(
733
+ groups,
734
+ "vlan batch ",
735
+ batchIds,
736
+ (ids) => [`vlan batch ${vlanBatch(ids)}`],
737
+ (ids) => [`vlan batch ${vlanBatch(ids)}`]
738
+ );
739
+ return { steps: steps(groups, dialect), problems: [] };
740
+ },
741
+ apply,
742
+ };