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