@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,862 +0,0 @@
|
|
|
1
|
-
// Junos, as on the QFX series with its Enhanced Layer 2 Software.
|
|
2
|
-
//
|
|
3
|
-
// The configuration is read and written as `set` statements, one per line, the form `show configuration | display set` prints. A statement the render does not hold is deleted, at the shortest path that holds nothing the render wants. Changes stage in a candidate and take effect only at `commit`. `commit confirmed` rolls itself back unless confirmed, so access is proved by a fresh login before the commit is confirmed.
|
|
4
|
-
|
|
5
|
-
import type { Client, ClientChannel } from "ssh2";
|
|
6
|
-
import { familyOf, host, isAddress, split } from "../../../core/addr";
|
|
7
|
-
import { resolve, substitute } from "../../../core/secrets";
|
|
8
|
-
import type { Device, Family } from "../../../schema";
|
|
9
|
-
import { type ConsoleDialect, consoleLogin, consoleOf, openConsole } from "../../../transport/console";
|
|
10
|
-
import { connect, exec, managementHost, readUntil, shell } from "../../../transport/ssh";
|
|
11
|
-
import { lagIds } from "../derive";
|
|
12
|
-
import type { ApplyOptions, DeviceAdapter, Plan, ReadOptions, Step } from "../types";
|
|
13
|
-
import { GENERATED, settingsOf } from "./bgp";
|
|
14
|
-
import { KEY_STATEMENT, Names, render, stormProfile, vrrpAddress } from "./render";
|
|
15
|
-
|
|
16
|
-
/** The release this adapter renders for, and was proved on. */
|
|
17
|
-
const SUPPORTED_MAJOR = 21;
|
|
18
|
-
|
|
19
|
-
/** Statements the device keeps for itself: the release it runs and the number it gives each user. */
|
|
20
|
-
const DEVICE_FACTS = [/^set version /, /^set system login user \S+ uid \d+$/];
|
|
21
|
-
|
|
22
|
-
/** Names the platform keeps for its own system accounts. A user under one fails the commit check. */
|
|
23
|
-
const SYSTEM_ACCOUNTS = new Set(["bind", "daemon", "kmem", "nobody", "operator", "sshd", "tty", "uucp"]);
|
|
24
|
-
|
|
25
|
-
/** A statement's words; a quoted value is one word with its quotes. */
|
|
26
|
-
export function wordsOf(statement: string): string[] {
|
|
27
|
-
return statement.match(/"(?:[^"\\]|\\.)*"|\S+/g) ?? [];
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** A word as the platform means it, without quotes or escapes, so a value quoted on one side and not on the other compares equal. */
|
|
31
|
-
const bare = (word: string) => (word.startsWith('"') ? word.slice(1, -1).replace(/\\(.)/g, "$1") : word);
|
|
32
|
-
|
|
33
|
-
/** A value the device keeps hashed or encrypted, and the render writes as a marker, as one word both sides share. Each keeps the number of words. */
|
|
34
|
-
const SECRETS: [RegExp, string][] = [
|
|
35
|
-
[/ (?:encrypted-password|plain-text-password-value) (?:"[^"]*"|\S+)/, " password <secret>"],
|
|
36
|
-
[/ (?:authentication-key|authentication-password) (?:"[^"]*"|\S+)/, " authentication-key <secret>"],
|
|
37
|
-
[/ (?:privacy-key|privacy-password) (?:"[^"]*"|\S+)/, " privacy-key <secret>"],
|
|
38
|
-
[/^set snmp community (?:"[^"]*"|\S+)/, "set snmp community <secret>"],
|
|
39
|
-
];
|
|
40
|
-
|
|
41
|
-
/** A statement in the form both sides compare in: its words, secrets as one marker, quotes dropped. */
|
|
42
|
-
function keyOf(statement: string): string[] {
|
|
43
|
-
let canonical = statement;
|
|
44
|
-
for (const [pattern, replacement] of SECRETS) canonical = canonical.replace(pattern, replacement);
|
|
45
|
-
|
|
46
|
-
return wordsOf(canonical).slice(1).map(bare);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** The device prints a password as a hash, an SNMP key as a reversible cipher, and a community as itself. None leave the read. */
|
|
50
|
-
export function scrub(text: string): string {
|
|
51
|
-
return text
|
|
52
|
-
.replace(/ ## SECRET-DATA$/gm, "")
|
|
53
|
-
.replace(/((?:encrypted-password|authentication-key|privacy-key) )(?:"[^"]*"|\S+)/g, "$1<redacted>")
|
|
54
|
-
.replace(/^(set snmp community )(?:"[^"]*"|\S+)/gm, "$1<redacted>")
|
|
55
|
-
.replace(/"\$\d\$[^"]*"/g, "<redacted>");
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** The configuration's statements from what `show configuration | display set` printed. */
|
|
59
|
-
export function clean(output: string): string {
|
|
60
|
-
const statements = output
|
|
61
|
-
.replace(/\r/g, "")
|
|
62
|
-
.split("\n")
|
|
63
|
-
.map((line) => line.trimEnd())
|
|
64
|
-
.filter((line) => /^(set|deactivate) /.test(line))
|
|
65
|
-
.filter((line) => !DEVICE_FACTS.some((fact) => fact.test(line)));
|
|
66
|
-
|
|
67
|
-
return scrub(statements.join("\n") + "\n");
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** Lists whose entries are named: a delete cannot stop at the list, `delete interfaces xe-0/0/1 unit`, only at an entry, `unit 0`. A leaf-list such as `vlan members` or `ciphers` can go whole. */
|
|
71
|
-
const NAMED_LISTS = new Set([
|
|
72
|
-
"address",
|
|
73
|
-
"area",
|
|
74
|
-
"as-path",
|
|
75
|
-
"class",
|
|
76
|
-
"collector",
|
|
77
|
-
"community",
|
|
78
|
-
"family",
|
|
79
|
-
"file",
|
|
80
|
-
"filter",
|
|
81
|
-
"group",
|
|
82
|
-
"host",
|
|
83
|
-
"interface",
|
|
84
|
-
"interfaces",
|
|
85
|
-
"neighbor",
|
|
86
|
-
"oid",
|
|
87
|
-
"policer",
|
|
88
|
-
"policy-statement",
|
|
89
|
-
"pool",
|
|
90
|
-
"prefix",
|
|
91
|
-
"prefix-list",
|
|
92
|
-
"qualified-next-hop",
|
|
93
|
-
"range",
|
|
94
|
-
"rib",
|
|
95
|
-
"route",
|
|
96
|
-
"security-level",
|
|
97
|
-
"security-model",
|
|
98
|
-
"security-name",
|
|
99
|
-
"server",
|
|
100
|
-
"server-group",
|
|
101
|
-
"session",
|
|
102
|
-
"storm-control-profiles",
|
|
103
|
-
"term",
|
|
104
|
-
"trap-group",
|
|
105
|
-
"unit",
|
|
106
|
-
"user",
|
|
107
|
-
"view",
|
|
108
|
-
"vlan",
|
|
109
|
-
"vrrp-group",
|
|
110
|
-
]);
|
|
111
|
-
|
|
112
|
-
/** Whether a delete may stop after `path[index]`. Below the top level a named list may not; `vlan` names a list except in `vlan members`. */
|
|
113
|
-
function canStopAt(path: string[], index: number): boolean {
|
|
114
|
-
if (index === 0 || !NAMED_LISTS.has(path[index]!)) return true;
|
|
115
|
-
|
|
116
|
-
return path[index] === "vlan" && path[index + 1] === "members";
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** The section a statement belongs to, for a reviewer: `interfaces xe-0/0/1`, `system login`, `snmp`. */
|
|
120
|
-
const sectionOf = (path: string[]) => path.slice(0, 2).join(" ");
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* The ordered block a statement sits in, and its item there: a policy's or a filter's term, or one policy of an import or export chain. The device keeps their order, and `set` adds at the end.
|
|
124
|
-
*/
|
|
125
|
-
function orderedBlockOf(path: string[]): { block: string[]; item: string } | undefined {
|
|
126
|
-
if (path[0] === "policy-options" && path[1] === "policy-statement" && path[3] === "term") {
|
|
127
|
-
return { block: path.slice(0, 3), item: path[4]! };
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
if (path[0] === "firewall" && path[1] === "family" && path[3] === "filter" && path[5] === "term") {
|
|
131
|
-
return { block: path.slice(0, 5), item: path[6]! };
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const chain = path.length - 2;
|
|
135
|
-
if (path[0] === "protocols" && (path[chain] === "import" || path[chain] === "export")) {
|
|
136
|
-
return { block: path.slice(0, chain + 1), item: path[chain + 1]! };
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
return undefined;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** Whether a path starts with another. */
|
|
143
|
-
const startsWith = (path: string[], prefix: string[]) => prefix.every((word, index) => path[index] === word);
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* What changes, by section.
|
|
147
|
-
*
|
|
148
|
-
* A statement the render holds and the device does not is set. One the device holds and the render does not is deleted at the shortest path that holds nothing the render wants, so a removed port goes as a whole and a changed value goes alone. A hidden value ends a path by its name alone; a hidden name in a list, such as a community, stays `<redacted>` and the session names each entry when it sends the plan. A statement the device holds deactivated is activated when the render wants it.
|
|
149
|
-
*
|
|
150
|
-
* A policy, a filter or an import or export chain is ordered, so when anything in one changes it is deleted and set again whole, in the render's order. A path the render marks as generated, such as a prefix list `refresh` fills, keeps what the device holds.
|
|
151
|
-
*/
|
|
152
|
-
export function plan(desired: string, current: string, options: Pick<ApplyOptions, "secrets">): Plan {
|
|
153
|
-
const lines = (text: string, verb: string) => text.split("\n").filter((line) => line.startsWith(`${verb} `));
|
|
154
|
-
|
|
155
|
-
const wanted = lines(desired, "set");
|
|
156
|
-
const deactivated = lines(current, "deactivate");
|
|
157
|
-
const generated = desired
|
|
158
|
-
.split("\n")
|
|
159
|
-
.filter((line) => line.startsWith(GENERATED))
|
|
160
|
-
.map((line) => wordsOf(line.slice(GENERATED.length)).map(bare));
|
|
161
|
-
|
|
162
|
-
const printed = lines(current, "set").filter((line) => !DEVICE_FACTS.some((fact) => fact.test(line)));
|
|
163
|
-
const isGenerated = (line: string) => generated.some((path) => keyOf(line).length > path.length && startsWith(keyOf(line), path));
|
|
164
|
-
const existing = printed.filter((line) => !isGenerated(line));
|
|
165
|
-
|
|
166
|
-
const join = (path: string[]) => path.join("\u0000");
|
|
167
|
-
const wantedPaths = wanted.map(keyOf);
|
|
168
|
-
const wantedKeys = new Set(wantedPaths.map(join));
|
|
169
|
-
const existingKeys = new Set(existing.map((line) => join(keyOf(line))));
|
|
170
|
-
|
|
171
|
-
const wantedUnder = (path: string[]) => wantedPaths.filter((other) => startsWith(other, path));
|
|
172
|
-
|
|
173
|
-
const sections = new Map<string, { show: string[]; activate: string[]; remove: string[]; add: string[] }>();
|
|
174
|
-
const sectionFor = (path: string[]) => {
|
|
175
|
-
const name = sectionOf(path);
|
|
176
|
-
if (!sections.has(name)) sections.set(name, { show: [], activate: [], remove: [], add: [] });
|
|
177
|
-
return sections.get(name)!;
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
// Sections in the order the render lists them, then the ones only the device has.
|
|
181
|
-
for (const path of wantedPaths) sectionFor(path);
|
|
182
|
-
|
|
183
|
-
// Ordered blocks the render holds whose items or order differ from the device's are rewritten whole.
|
|
184
|
-
const rewritten: string[][] = [];
|
|
185
|
-
const blocks = new Map<string, string[]>();
|
|
186
|
-
for (const path of wantedPaths) {
|
|
187
|
-
const ordered = orderedBlockOf(path);
|
|
188
|
-
if (ordered) blocks.set(join(ordered.block), ordered.block);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
for (const block of blocks.values()) {
|
|
192
|
-
const inBlock = (line: string) => startsWith(keyOf(line), block);
|
|
193
|
-
const order = (all: string[]) => [...new Set(all.filter(inBlock).map((line) => orderedBlockOf(keyOf(line))!.item))];
|
|
194
|
-
const keys = (all: string[]) =>
|
|
195
|
-
all
|
|
196
|
-
.filter(inBlock)
|
|
197
|
-
.map((line) => join(keyOf(line)))
|
|
198
|
-
.sort()
|
|
199
|
-
.join("\n");
|
|
200
|
-
|
|
201
|
-
const wantedHere = wanted.filter(inBlock);
|
|
202
|
-
const existingHere = existing.filter(inBlock);
|
|
203
|
-
if (join(order(wanted)) === join(order(existing)) && keys(wanted) === keys(existing)) continue;
|
|
204
|
-
|
|
205
|
-
rewritten.push(block);
|
|
206
|
-
const section = sectionFor(block);
|
|
207
|
-
|
|
208
|
-
if (existingHere.length)
|
|
209
|
-
section.remove.push(
|
|
210
|
-
`delete ${wordsOf(existingHere[0]!)
|
|
211
|
-
.slice(1, 1 + block.length)
|
|
212
|
-
.join(" ")}`
|
|
213
|
-
);
|
|
214
|
-
section.show.push(...existingHere.map((line) => `- ${line}`), ...wantedHere.map((line) => `+ ${line}`));
|
|
215
|
-
section.add.push(...wantedHere);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const isRewritten = (line: string) => rewritten.some((block) => startsWith(keyOf(line), block));
|
|
219
|
-
|
|
220
|
-
const removed: string[][] = [];
|
|
221
|
-
|
|
222
|
-
for (const line of existing) {
|
|
223
|
-
if (isRewritten(line)) continue;
|
|
224
|
-
|
|
225
|
-
const path = keyOf(line);
|
|
226
|
-
if (wantedKeys.has(join(path))) continue;
|
|
227
|
-
// The render extends this statement, so setting the longer one replaces it.
|
|
228
|
-
if (wantedUnder(path).length) continue;
|
|
229
|
-
|
|
230
|
-
let length = 1;
|
|
231
|
-
while (wantedUnder(path.slice(0, length)).length) length++;
|
|
232
|
-
while (length < path.length && !canStopAt(path, length - 1)) length++;
|
|
233
|
-
|
|
234
|
-
const hidden = path.indexOf("<secret>");
|
|
235
|
-
if (hidden >= 0 && hidden < length && !NAMED_LISTS.has(path[hidden - 1]!)) length = hidden;
|
|
236
|
-
|
|
237
|
-
removed.push(wordsOf(line).slice(1, 1 + length));
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// A path inside another removed path goes with it.
|
|
241
|
-
const isInside = (path: string[], other: string[]) =>
|
|
242
|
-
other.length < path.length && other.every((word, index) => bare(path[index]!) === bare(word));
|
|
243
|
-
const removals = removed.filter(
|
|
244
|
-
(path, index) =>
|
|
245
|
-
!removed.some((other) => isInside(path, other)) && removed.findIndex((other) => join(other) === join(path)) === index
|
|
246
|
-
);
|
|
247
|
-
|
|
248
|
-
const isRemoved = (line: string) => removals.some((path) => startsWith(keyOf(line), path.map(bare)));
|
|
249
|
-
|
|
250
|
-
for (const path of removals) {
|
|
251
|
-
const section = sectionFor(path.map(bare));
|
|
252
|
-
section.remove.push(`delete ${path.join(" ")}`);
|
|
253
|
-
|
|
254
|
-
const under = existing.filter((line) => startsWith(keyOf(line), path.map(bare)));
|
|
255
|
-
section.show.push(...under.map((line) => `- ${line}`));
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
for (const line of deactivated) {
|
|
259
|
-
const path = wordsOf(line).slice(1);
|
|
260
|
-
if (!wantedUnder(path.map(bare)).length) continue;
|
|
261
|
-
|
|
262
|
-
const section = sectionFor(path.map(bare));
|
|
263
|
-
section.activate.push(`activate ${path.join(" ")}`);
|
|
264
|
-
section.show.push(`+ activate ${path.join(" ")}`);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// A statement the device extends with one it keeps is there already: the device prints only the longest.
|
|
268
|
-
const extended = (path: string[]) => printed.some((line) => !isRemoved(line) && startsWith(keyOf(line), path));
|
|
269
|
-
|
|
270
|
-
for (const line of wanted) {
|
|
271
|
-
if (isRewritten(line)) continue;
|
|
272
|
-
|
|
273
|
-
const path = keyOf(line);
|
|
274
|
-
const isNew = !existingKeys.has(join(path)) && !extended(path);
|
|
275
|
-
const rotated = options.secrets && line.includes("<secret:");
|
|
276
|
-
if (!isNew && !rotated) continue;
|
|
277
|
-
|
|
278
|
-
const section = sectionFor(path);
|
|
279
|
-
section.add.push(line);
|
|
280
|
-
section.show.push(`+ ${line}`);
|
|
281
|
-
|
|
282
|
-
// A community is named by its value, so a new value is a second community. The device's go first.
|
|
283
|
-
const clear = "delete snmp community <redacted>";
|
|
284
|
-
if (rotated && line.startsWith("set snmp community ") && !section.remove.includes(clear)) section.remove.push(clear);
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
const steps: Step[] = [...sections]
|
|
288
|
-
.filter(([, section]) => section.show.length)
|
|
289
|
-
.map(([title, section]) => ({ title, show: section.show, send: [...section.activate, ...section.remove, ...section.add] }));
|
|
290
|
-
|
|
291
|
-
return { steps, problems: [] };
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
export async function login(device: Device): Promise<Client> {
|
|
295
|
-
const user = device.users?.[device.connection.user];
|
|
296
|
-
if (!user) throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
|
|
297
|
-
|
|
298
|
-
return connect({
|
|
299
|
-
host: managementHost(device),
|
|
300
|
-
port: device.connection.port,
|
|
301
|
-
user: device.connection.user,
|
|
302
|
-
password: resolve(user.password),
|
|
303
|
-
});
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/** Refuse a device on a release this adapter was not proved on, before anything is read or sent. */
|
|
307
|
-
async function checkVersion(ask: (command: string) => Promise<string>, device: Device): Promise<void> {
|
|
308
|
-
const version = await ask("show version | match Junos:");
|
|
309
|
-
const release = /Junos: (\d+)\.(\S+)/.exec(version);
|
|
310
|
-
|
|
311
|
-
if (!release) throw new Error(`${device.name} did not say which Junos it runs: ${version.slice(0, 200)}`);
|
|
312
|
-
if (Number(release[1]) !== SUPPORTED_MAJOR) {
|
|
313
|
-
throw new Error(`${device.name} runs Junos ${release[1]}.${release[2]}; this adapter renders for Junos ${SUPPORTED_MAJOR}`);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
export async function read(device: Device, _network: readonly Device[] = [], options: ReadOptions = {}): Promise<string> {
|
|
318
|
-
if (options.console) {
|
|
319
|
-
return onConsole(device, async (channel) => {
|
|
320
|
-
await checkVersion((command) => run(channel, command), device);
|
|
321
|
-
const output = await run(channel, "show configuration | display set | no-more", 180_000);
|
|
322
|
-
if (!/^set /m.test(output)) throw new Error(`${device.name} returned no configuration`);
|
|
323
|
-
|
|
324
|
-
return clean(output);
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
const client = await login(device);
|
|
329
|
-
|
|
330
|
-
try {
|
|
331
|
-
await checkVersion((command) => exec(client, command), device);
|
|
332
|
-
const output = await exec(client, "show configuration | display set | no-more", 180_000);
|
|
333
|
-
if (!/^set /m.test(output)) throw new Error(`${device.name} returned no configuration`);
|
|
334
|
-
|
|
335
|
-
return clean(output);
|
|
336
|
-
} finally {
|
|
337
|
-
client.end();
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/** A prompt at the end of what the device printed: `user@host> `, `user@host# `, or a shell's `% `. */
|
|
342
|
-
const PROMPT = /[>#%] $/;
|
|
343
|
-
|
|
344
|
-
/** Send one line and wait for the prompt to come back. */
|
|
345
|
-
async function run(channel: ClientChannel, line: string, timeout = 60_000): Promise<string> {
|
|
346
|
-
channel.write(`${line}\n`);
|
|
347
|
-
const output = await readUntil(channel, (text) => PROMPT.test(text.replace(/\r/g, "")), timeout, 300);
|
|
348
|
-
return output.replace(/\r/g, "");
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/** The prompt in configuration mode, under its `[edit]` line. An echoed statement cannot end with it. */
|
|
352
|
-
const EDIT_PROMPT = /\[edit[^\]\n]*\]\n[^\n]*# $/;
|
|
353
|
-
|
|
354
|
-
/** Send one line in configuration mode and wait for its prompt to come back. */
|
|
355
|
-
async function edit(channel: ClientChannel, line: string, timeout = 60_000): Promise<string> {
|
|
356
|
-
channel.write(`${line}\n`);
|
|
357
|
-
const output = await readUntil(channel, (text) => EDIT_PROMPT.test(text.replace(/\r/g, "")), timeout, 300);
|
|
358
|
-
return output.replace(/\r/g, "");
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
/** How the console asks for a login. Root lands in a shell, `%`, every other user in the CLI, `>`; a session left in configuration mode, `#`, leaves it first. Not yet proved on a device. */
|
|
362
|
-
export const JUNOS_CONSOLE: ConsoleDialect = {
|
|
363
|
-
// On a line of its own: the banner after a login says `Last login: ...`, and a chunk can end right after it.
|
|
364
|
-
login: /(?:^|\n)login: $/,
|
|
365
|
-
password: /Password:\s*$/,
|
|
366
|
-
prompt: /[>%] $/,
|
|
367
|
-
logout: "exit",
|
|
368
|
-
others: [[/# $/, "exit configuration-mode\n"]],
|
|
369
|
-
};
|
|
370
|
-
|
|
371
|
-
/** Log in on the console, open the CLI, and run `work` in it with a way to log in afresh. Logs out after. */
|
|
372
|
-
async function onConsole<T>(device: Device, work: (channel: ClientChannel, relogin: () => Promise<void>) => Promise<T>): Promise<T> {
|
|
373
|
-
const { console, user, password } = consoleOf(device);
|
|
374
|
-
const opened = await openConsole(console);
|
|
375
|
-
|
|
376
|
-
// The CLI is opened on a prompt asked for after the login, since the login's own prompt is already read.
|
|
377
|
-
const login = async () => {
|
|
378
|
-
await consoleLogin(opened.channel, JUNOS_CONSOLE, user, password, console.passwordOnly);
|
|
379
|
-
const ready = openCli(opened.channel);
|
|
380
|
-
opened.channel.write("\n");
|
|
381
|
-
await ready;
|
|
382
|
-
};
|
|
383
|
-
|
|
384
|
-
try {
|
|
385
|
-
await login();
|
|
386
|
-
const result = await work(opened.channel, login);
|
|
387
|
-
opened.channel.write(`${JUNOS_CONSOLE.logout}\n`);
|
|
388
|
-
return result;
|
|
389
|
-
} finally {
|
|
390
|
-
opened.close();
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
/** An interactive CLI with paging off. Root lands in a shell and starts the CLI from there. */
|
|
395
|
-
export async function openCli(channel: ClientChannel): Promise<void> {
|
|
396
|
-
const banner = await readUntil(channel, (text) => PROMPT.test(text.replace(/\r/g, "")), 20_000, 500);
|
|
397
|
-
if (/% $/.test(banner.replace(/\r/g, ""))) await run(channel, "cli");
|
|
398
|
-
|
|
399
|
-
await run(channel, "set cli screen-length 0");
|
|
400
|
-
await run(channel, "set cli screen-width 0");
|
|
401
|
-
await run(channel, "set cli complete-on-space off");
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
/**
|
|
405
|
-
* A delete through a name the read redacted, `delete snmp community <redacted>`, as one delete for each entry the device holds there. The names stay in the session.
|
|
406
|
-
*/
|
|
407
|
-
async function named(channel: ClientChannel, line: string): Promise<string[]> {
|
|
408
|
-
const words = wordsOf(line).slice(1);
|
|
409
|
-
const at = words.indexOf("<redacted>");
|
|
410
|
-
if (at < 0) return [line];
|
|
411
|
-
|
|
412
|
-
// `show` cannot stop at a list either, so the list's parent is shown and its entries picked out.
|
|
413
|
-
const shown = await edit(channel, `show ${words.slice(0, at - 1).join(" ")} | display set`);
|
|
414
|
-
const inList = (statement: string[]) =>
|
|
415
|
-
statement[0] === "set" && words.slice(0, at).every((word, index) => statement[index + 1] === word);
|
|
416
|
-
const names = new Set(
|
|
417
|
-
shown
|
|
418
|
-
.split("\n")
|
|
419
|
-
.map(wordsOf)
|
|
420
|
-
.filter(inList)
|
|
421
|
-
.map((statement) => statement[at + 1]!)
|
|
422
|
-
);
|
|
423
|
-
|
|
424
|
-
return [...names].map((name) => `delete ${[...words.slice(0, at), name, ...words.slice(at + 1)].join(" ")}`);
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
/** What the device says when it refuses a statement in a load: `terminal:3:(14) syntax error: ...`. A warning, such as a statement not found, is not a refusal. */
|
|
428
|
-
const REFUSED = /^terminal:\d+:\(\d+\) (?!warning)|^error: /m;
|
|
429
|
-
|
|
430
|
-
/** How a commit that could cut access is guarded: it rolls back after `minutes` unless `prove` shows access survived. */
|
|
431
|
-
export type Guard = { minutes: number; prove: () => Promise<void> };
|
|
432
|
-
|
|
433
|
-
/**
|
|
434
|
-
* Load the plan into an exclusive candidate and check it. With a guard, commit it to roll back after `minutes`, prove access, then confirm; without one, commit it outright.
|
|
435
|
-
*
|
|
436
|
-
* Deletions go before the statements that are set, so a path deleted and set again in one plan ends up set.
|
|
437
|
-
*/
|
|
438
|
-
export async function configure(channel: ClientChannel, plan: Plan, guard?: Guard, paced = false): Promise<void> {
|
|
439
|
-
const sent = plan.steps.flatMap((step) => step.send);
|
|
440
|
-
|
|
441
|
-
const entered = await run(channel, "configure exclusive");
|
|
442
|
-
if (!/Entering configuration mode/.test(entered) || /^error: /m.test(entered)) {
|
|
443
|
-
throw new Error(`the candidate could not be locked: ${entered.trim().slice(0, 300)}`);
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
const deletions: string[] = [];
|
|
447
|
-
for (const line of sent.filter((line) => line.startsWith("delete "))) deletions.push(...(await named(channel, line)));
|
|
448
|
-
|
|
449
|
-
const ordered = [
|
|
450
|
-
...sent.filter((line) => line.startsWith("activate ")),
|
|
451
|
-
...deletions,
|
|
452
|
-
...sent.filter((line) => line.startsWith("set ")),
|
|
453
|
-
];
|
|
454
|
-
|
|
455
|
-
const discard = async (reason: string) => {
|
|
456
|
-
await edit(channel, "rollback 0").catch(() => undefined);
|
|
457
|
-
await run(channel, "exit configuration-mode").catch(() => undefined);
|
|
458
|
-
return new Error(reason);
|
|
459
|
-
};
|
|
460
|
-
|
|
461
|
-
channel.write("load set terminal\n");
|
|
462
|
-
await readUntil(channel, (text) => text.includes("[Type ^D at a new line to end input]"), 20_000, 300);
|
|
463
|
-
|
|
464
|
-
// A serial console has no flow control: statements sent at once overrun it and arrive garbled. There, each waits for the echo of the one before.
|
|
465
|
-
for (const line of ordered) {
|
|
466
|
-
const statement = substitute(line);
|
|
467
|
-
channel.write(`${statement}\n`);
|
|
468
|
-
if (paced) await readUntil(channel, (text) => text.replace(/\r/g, "").includes(statement.slice(-24)), 20_000, 50);
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
const loaded = await edit(channel, "\u0004", 300_000);
|
|
472
|
-
const refused = loaded.split("\n").filter((line) => REFUSED.test(line));
|
|
473
|
-
if (refused.length) throw await discard(`the device refused the plan, nothing was applied:\n ${refused.join("\n ")}`);
|
|
474
|
-
|
|
475
|
-
const checked = await edit(channel, "commit check", 600_000);
|
|
476
|
-
if (!/configuration check succeeds/.test(checked)) {
|
|
477
|
-
throw await discard(`commit check failed, nothing was applied:\n ${checked.trim().split("\n").slice(1, 20).join("\n ")}`);
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
if (!guard) {
|
|
481
|
-
const committed = await edit(channel, "commit comment circuit", 600_000);
|
|
482
|
-
if (!/commit complete/.test(committed))
|
|
483
|
-
throw await discard(`commit failed, nothing was applied:
|
|
484
|
-
${committed.trim().slice(0, 300)}`);
|
|
485
|
-
|
|
486
|
-
await run(channel, "exit configuration-mode");
|
|
487
|
-
return;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
const { minutes, prove } = guard;
|
|
491
|
-
const committed = await edit(channel, `commit confirmed ${minutes} comment circuit`, 600_000);
|
|
492
|
-
if (!/commit complete/.test(committed)) {
|
|
493
|
-
throw await discard(`commit failed, nothing was applied:\n ${committed.trim().split("\n").slice(1, 20).join("\n ")}`);
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
console.log(` committed; it rolls back in ${minutes} minutes unless access is proved`);
|
|
497
|
-
|
|
498
|
-
try {
|
|
499
|
-
await prove();
|
|
500
|
-
} catch (error) {
|
|
501
|
-
// The session still open restores the previous configuration at once; if it cannot, the device does when the time runs out.
|
|
502
|
-
await edit(channel, "rollback 1", 60_000).catch(() => undefined);
|
|
503
|
-
await edit(channel, "commit comment circuit-restore", 600_000).catch(() => undefined);
|
|
504
|
-
await run(channel, "exit configuration-mode").catch(() => undefined);
|
|
505
|
-
throw new Error(
|
|
506
|
-
`a fresh login failed (${(error as Error).message}); the previous configuration is restored, at the latest in ${minutes} minutes`
|
|
507
|
-
);
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
const confirmed = await edit(channel, "commit comment circuit-confirmed", 600_000);
|
|
511
|
-
if (!/commit complete/.test(confirmed)) {
|
|
512
|
-
throw new Error(
|
|
513
|
-
`access was proved but the confirmation failed; the device rolls back in ${minutes} minutes:\n ${confirmed.trim().slice(0, 300)}`
|
|
514
|
-
);
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
await run(channel, "exit configuration-mode");
|
|
518
|
-
console.log(" logged in again, commit confirmed");
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
export async function apply(device: Device, _network: readonly Device[], plan: Plan, options: ApplyOptions): Promise<void> {
|
|
522
|
-
if (options.console) {
|
|
523
|
-
await onConsole(device, async (channel, relogin) => {
|
|
524
|
-
await checkVersion((command) => run(channel, command), device);
|
|
525
|
-
|
|
526
|
-
// A fresh login on the console ends the session that holds the candidate, so the proof logs in again and takes the candidate back to confirm the commit.
|
|
527
|
-
await configure(
|
|
528
|
-
channel,
|
|
529
|
-
plan,
|
|
530
|
-
{
|
|
531
|
-
minutes: options.rollback,
|
|
532
|
-
prove: async () => {
|
|
533
|
-
await run(channel, "exit configuration-mode");
|
|
534
|
-
await relogin();
|
|
535
|
-
await run(channel, "configure exclusive");
|
|
536
|
-
},
|
|
537
|
-
},
|
|
538
|
-
true
|
|
539
|
-
);
|
|
540
|
-
});
|
|
541
|
-
return;
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
const client = await login(device);
|
|
545
|
-
|
|
546
|
-
try {
|
|
547
|
-
await checkVersion((command) => exec(client, command), device);
|
|
548
|
-
|
|
549
|
-
const channel = await shell(client);
|
|
550
|
-
await openCli(channel);
|
|
551
|
-
|
|
552
|
-
await configure(channel, plan, {
|
|
553
|
-
minutes: options.rollback,
|
|
554
|
-
prove: async () => {
|
|
555
|
-
const proof = await login(device);
|
|
556
|
-
proof.end();
|
|
557
|
-
},
|
|
558
|
-
});
|
|
559
|
-
|
|
560
|
-
channel.end();
|
|
561
|
-
} finally {
|
|
562
|
-
client.end();
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
export function unsupported(device: Device): string[] {
|
|
567
|
-
const found: string[] = [];
|
|
568
|
-
|
|
569
|
-
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
570
|
-
if (iface.type === "bridge") found.push(`interfaces.${name}, a bridge, which only FortiOS builds for now`);
|
|
571
|
-
if (iface.type === "vlan" && iface.mac) found.push(`interfaces.${name}.mac, which only RouterOS sets for now`);
|
|
572
|
-
}
|
|
573
|
-
const names = new Names(device);
|
|
574
|
-
const users = device.users ?? {};
|
|
575
|
-
|
|
576
|
-
const root = (users as Record<string, (typeof users)[keyof typeof users]>).root;
|
|
577
|
-
if (!root) found.push("users without root, the platform needs a root password");
|
|
578
|
-
if (root && root.role !== "admin") found.push("users.root.role, root is always admin");
|
|
579
|
-
if (root?.description) found.push("users.root.description");
|
|
580
|
-
if (device.connection.user === "root") found.push("connection.user root, which logs in to a shell rather than the CLI");
|
|
581
|
-
|
|
582
|
-
for (const [name, user] of Object.entries(users)) {
|
|
583
|
-
if (SYSTEM_ACCOUNTS.has(name)) found.push(`users.${name}, a name the platform keeps for a system account`);
|
|
584
|
-
|
|
585
|
-
for (const key of user.ssh_keys ?? []) {
|
|
586
|
-
const kind = key.split(" ")[0]!;
|
|
587
|
-
if (!KEY_STATEMENT[kind]) found.push(`users.${name}.ssh_keys, a key of type ${kind}`);
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
const management = device.management ?? {};
|
|
592
|
-
|
|
593
|
-
for (const field of ["https", "api_tls", "privilege_password"] as const) {
|
|
594
|
-
if (management[field]) found.push(`management.${field}`);
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
if (management.console) found.push("management.console, the console uses the device's users");
|
|
598
|
-
if (management.interfaces) found.push("management.interfaces, which only FortiOS builds for now");
|
|
599
|
-
if (management.native?.interfaces?.length) found.push("management.native.interfaces, NETCONF runs over IP only");
|
|
600
|
-
if (management.ssh?.auth_timeout || management.ssh?.auth_retries) found.push("management.ssh.auth_timeout and auth_retries");
|
|
601
|
-
|
|
602
|
-
for (const service of ["telnet", "ftp", "snmp"] as const) {
|
|
603
|
-
if (management[service]?.port) found.push(`management.${service}.port`);
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
const system = device.system ?? {};
|
|
607
|
-
|
|
608
|
-
if (system.release_channel) found.push("system.release_channel");
|
|
609
|
-
if (system.transceiver_monitoring !== undefined) found.push("system.transceiver_monitoring, it is always on");
|
|
610
|
-
if (system.dns?.serve) found.push("system.dns.serve");
|
|
611
|
-
if (system.ip?.syn_cookies) found.push("system.ip.syn_cookies");
|
|
612
|
-
if (system.logging?.local?.lines_per_file !== undefined) found.push("system.logging.local.lines_per_file, files rotate by size");
|
|
613
|
-
|
|
614
|
-
// Time is answered only to its servers, which the filter names by address.
|
|
615
|
-
if (system.ntp && !system.ntp.serve && system.ntp.servers.some((server) => !isAddress(server))) {
|
|
616
|
-
found.push("system.ntp.servers by name while not serving, the filter keeping others out needs addresses");
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
for (const [name, port] of Object.entries(device.ports ?? {})) {
|
|
620
|
-
if (!port) continue;
|
|
621
|
-
|
|
622
|
-
const physical = names.port(name);
|
|
623
|
-
|
|
624
|
-
if (physical.startsWith("em")) {
|
|
625
|
-
for (const field of [
|
|
626
|
-
"access_vlan",
|
|
627
|
-
"trunk_vlans",
|
|
628
|
-
"native_vlan",
|
|
629
|
-
"lag",
|
|
630
|
-
"stp",
|
|
631
|
-
"storm_control",
|
|
632
|
-
"speed",
|
|
633
|
-
"vrrp",
|
|
634
|
-
"vrf",
|
|
635
|
-
"mtu",
|
|
636
|
-
"acl",
|
|
637
|
-
"ipv6_ra",
|
|
638
|
-
] as const) {
|
|
639
|
-
if (port[field] !== undefined) found.push(`ports.${name}.${field}, a management port only routes`);
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
if (port.speed && !(port.speed === "1g" && physical.startsWith("ge-")))
|
|
644
|
-
found.push(`ports.${name}.speed ${port.speed}, only a 1G optic in a 10G cage`);
|
|
645
|
-
|
|
646
|
-
if (port.hardware_offload !== undefined) found.push(`ports.${name}.hardware_offload`);
|
|
647
|
-
|
|
648
|
-
// A port filter cannot match the VLAN a frame is switched in: `learn-vlan-id` is refused on an ingress port.
|
|
649
|
-
const acl = port.acl ? device.acls?.[port.acl] : undefined;
|
|
650
|
-
if (acl?.some((rule) => rule.match?.vlan)) found.push(`acls.${port.acl} matching a VLAN, on port ${name}`);
|
|
651
|
-
|
|
652
|
-
found.push(...routedGaps(`ports.${name}`, port));
|
|
653
|
-
found.push(...switchedGaps(`ports.${name}`, port, device));
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
const loopbackTables = new Set<string>();
|
|
657
|
-
|
|
658
|
-
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
659
|
-
if (iface.type === "vxlan" || iface.type === "wireguard") {
|
|
660
|
-
found.push(`interfaces.${name}, a ${iface.type}`);
|
|
661
|
-
continue;
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
if (iface.hardware_offload !== undefined) found.push(`interfaces.${name}.hardware_offload`);
|
|
665
|
-
if (iface.type === "loopback" && iface.ipv6_ra) found.push(`interfaces.${name}.ipv6_ra, on a loopback`);
|
|
666
|
-
if (iface.type === "loopback" && iface.mtu !== undefined) found.push(`interfaces.${name}.mtu, on a loopback`);
|
|
667
|
-
if (iface.type === "gre" && iface.vrrp?.length) found.push(`interfaces.${name}.vrrp, on a tunnel`);
|
|
668
|
-
|
|
669
|
-
// A VLAN interface sends no larger packets than the ports carrying its VLAN take; the device warns and carries on.
|
|
670
|
-
if (iface.type === "vlan" && iface.mtu !== undefined) {
|
|
671
|
-
const smallest = Math.min(...carriers(device, iface.vlan));
|
|
672
|
-
if (iface.mtu > smallest) found.push(`interfaces.${name}.mtu ${iface.mtu}, above the ${smallest} its VLAN's ports carry`);
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
found.push(...routedGaps(`interfaces.${name}`, iface));
|
|
676
|
-
|
|
677
|
-
if (iface.type === "lag") found.push(...switchedGaps(`interfaces.${name}`, iface, device));
|
|
678
|
-
|
|
679
|
-
if (iface.type === "loopback") {
|
|
680
|
-
const table = iface.vrf ?? "";
|
|
681
|
-
if (loopbackTables.has(table)) found.push(`interfaces.${name}, a second loopback in one routing table`);
|
|
682
|
-
loopbackTables.add(table);
|
|
683
|
-
if (iface.vrrp?.length) found.push(`interfaces.${name}.vrrp, on a loopback`);
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
const ids = [...lagIds(device).values()];
|
|
688
|
-
if (ids.length !== new Set(ids).size) found.push("interfaces, two LAGs with one number");
|
|
689
|
-
|
|
690
|
-
if (device.stp?.priority !== undefined && device.stp.priority % 4096 !== 0) found.push("stp.priority, in steps of 4096");
|
|
691
|
-
|
|
692
|
-
const routing = device.routing;
|
|
693
|
-
found.push(...bgpGaps(device));
|
|
694
|
-
|
|
695
|
-
const tables = new Map<string, number>();
|
|
696
|
-
|
|
697
|
-
for (const route of routing?.static ?? []) {
|
|
698
|
-
if (route.description) found.push(`routing.static ${route.prefix} description`);
|
|
699
|
-
|
|
700
|
-
const table = `${route.vrf ?? ""} ${split(route.prefix).addr}/${split(route.prefix).len}`;
|
|
701
|
-
tables.set(table, (tables.get(table) ?? 0) + 1);
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
for (const route of routing?.static ?? []) {
|
|
705
|
-
const table = `${route.vrf ?? ""} ${split(route.prefix).addr}/${split(route.prefix).len}`;
|
|
706
|
-
if (route.blackhole && tables.get(table)! > 1)
|
|
707
|
-
found.push(`routing.static ${route.prefix}, a blackhole beside another route to the same prefix`);
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
for (const area of Object.values(routing?.ospf?.areas ?? {})) {
|
|
711
|
-
for (const iface of Object.keys(area.interfaces)) {
|
|
712
|
-
const settings = device.interfaces?.[iface] ?? device.ports?.[iface as keyof typeof device.ports];
|
|
713
|
-
if (settings?.vrf) found.push(`routing.ospf on ${iface}, which routes in a VRF`);
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
for (const field of ["firewall", "hardware_offload", "certificates"] as const) {
|
|
718
|
-
if (device[field] !== undefined) found.push(field);
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
const relayed = new Set(Object.values(device.dhcp_relay ?? {}).map((relay) => relay.interface));
|
|
722
|
-
|
|
723
|
-
for (const [name, server] of Object.entries(device.dhcp ?? {})) {
|
|
724
|
-
// The device refuses a relay and a server on one interface: `Interface irb.10 already configured`.
|
|
725
|
-
if (relayed.has(server.interface)) found.push(`dhcp.${name}.interface, which a DHCP relay also uses`);
|
|
726
|
-
|
|
727
|
-
const settings = device.interfaces?.[server.interface] ?? device.ports?.[server.interface as keyof typeof device.ports];
|
|
728
|
-
if (settings?.vrf) found.push(`dhcp.${name}.interface, which routes in a VRF`);
|
|
729
|
-
if (familyOf(host(server.network)) === "ipv6") found.push(`dhcp.${name}.network, IPv6`);
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
for (const [name, relay] of Object.entries(device.dhcp_relay ?? {})) {
|
|
733
|
-
if (relay.servers.some((server) => familyOf(server) === "ipv6")) found.push(`dhcp_relay.${name}.servers, IPv6`);
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
const flow = device.flow_export;
|
|
737
|
-
if (flow && flow.protocol !== "sflow") found.push(`flow_export.protocol ${flow.protocol}, the platform exports sFlow`);
|
|
738
|
-
if (flow?.protocol === "sflow" && flow.sampling === undefined) found.push("flow_export.sampling, the platform samples");
|
|
739
|
-
|
|
740
|
-
for (const name of flow?.interfaces ?? []) {
|
|
741
|
-
if (names.members(name).some((port) => port.startsWith("em"))) found.push(`flow_export.interfaces ${name}, a management port`);
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
return found;
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
/** The IP MTU of every port and LAG that carries a VLAN; 1500 where none is given. */
|
|
748
|
-
function carriers(device: Device, vlan: string): number[] {
|
|
749
|
-
const sizes = [1500];
|
|
750
|
-
const carrying = [...Object.values(device.ports ?? {}), ...Object.values(device.interfaces ?? {})];
|
|
751
|
-
|
|
752
|
-
for (const settings of carrying) {
|
|
753
|
-
if (!settings || !("access_vlan" in settings || "trunk_vlans" in settings)) continue;
|
|
754
|
-
|
|
755
|
-
const members = [settings.access_vlan, settings.native_vlan, ...(settings.trunk_vlans ?? [])];
|
|
756
|
-
if (members.includes(vlan)) sizes.push(settings.mtu ?? 1500);
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
return sizes.length > 1 ? sizes.slice(1) : sizes;
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
/** BGP settings Junos 21 has no statement for, and policy steps it cannot take. */
|
|
763
|
-
function bgpGaps(device: Device): string[] {
|
|
764
|
-
const found: string[] = [];
|
|
765
|
-
const bgp = device.routing?.bgp;
|
|
766
|
-
|
|
767
|
-
for (const [name, neighbor] of Object.entries(bgp?.neighbors ?? {})) {
|
|
768
|
-
const settings = settingsOf(device, neighbor);
|
|
769
|
-
if (settings.role) found.push(`routing.bgp.neighbors.${name}.role, Junos 21 has no BGP roles`);
|
|
770
|
-
// IPv4 routes over an IPv6 session need a local IPv4 address the config has no field for.
|
|
771
|
-
if (familyOf(neighbor.address) === "ipv6" && settings.families?.includes("ipv4")) {
|
|
772
|
-
found.push(`routing.bgp.neighbors.${name}.families ipv4 over an IPv6 session`);
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
if (settings.keepalive !== undefined)
|
|
776
|
-
found.push(`routing.bgp.neighbors.${name}.keepalive, the platform keeps it at a third of hold_time`);
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
for (const server of device.routing?.rpki ?? []) {
|
|
780
|
-
if (server.description) found.push(`routing.rpki ${server.address} description`);
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
for (const [name, rules] of Object.entries(device.policies ?? {})) {
|
|
784
|
-
rules.forEach((rule, index) => {
|
|
785
|
-
const where = `policies.${name}[${index}]`;
|
|
786
|
-
if (rule.set?.preferred_source) found.push(`${where}.set.preferred_source`);
|
|
787
|
-
if (rule.match?.prefix && rule.match.prefix_length) found.push(`${where}.match.prefix with prefix_length`);
|
|
788
|
-
});
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
return found;
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
/** Layer 3 settings the platform cannot express on a port or an interface. */
|
|
795
|
-
function routedGaps(
|
|
796
|
-
where: string,
|
|
797
|
-
settings: { addresses?: readonly (string | { address: string; peer: string })[]; vrrp?: readonly { id: number; address: string }[] }
|
|
798
|
-
): string[] {
|
|
799
|
-
const found: string[] = [];
|
|
800
|
-
const addresses = (settings.addresses ?? []).map((entry) => (typeof entry === "string" ? entry : entry.address));
|
|
801
|
-
|
|
802
|
-
if (settings.addresses?.some((entry) => typeof entry !== "string")) found.push(`${where}.addresses with a peer`);
|
|
803
|
-
|
|
804
|
-
for (const group of settings.vrrp ?? []) {
|
|
805
|
-
if (familyOf(group.address) === "ipv6") found.push(`${where}.vrrp ${group.id}, IPv6`);
|
|
806
|
-
else if (!vrrpAddress(addresses, group))
|
|
807
|
-
found.push(`${where}.vrrp ${group.id}, no address of the interface holds ${group.address}`);
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
return found;
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
/** Storm control in a form a profile holds: one threshold in percent for every kind. */
|
|
814
|
-
function switchedGaps(where: string, settings: Parameters<typeof stormProfile>[0], device: Device): string[] {
|
|
815
|
-
const found: string[] = [];
|
|
816
|
-
const storm = Object.values(settings.storm_control ?? {});
|
|
817
|
-
|
|
818
|
-
if (storm.some((threshold) => "pps" in threshold)) found.push(`${where}.storm_control in pps, the platform takes percent`);
|
|
819
|
-
else if (new Set(storm.map((threshold) => ("percent" in threshold ? threshold.percent : 0))).size > 1) {
|
|
820
|
-
found.push(`${where}.storm_control, one threshold for every kind`);
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
if (typeof settings.stp === "object" && !device.stp) found.push(`${where}.stp while spanning tree is off on the device`);
|
|
824
|
-
|
|
825
|
-
return found;
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
/** The plan that replaces a prefix list's entries with what a registry returned. */
|
|
829
|
-
export function fillPlan(set: string, prefixes: string[]): Plan {
|
|
830
|
-
const list = `policy-options prefix-list ${set}`;
|
|
831
|
-
const send = [`delete ${list}`, `set ${list}`, ...prefixes.map((prefix) => `set ${list} ${prefix}`)];
|
|
832
|
-
return { steps: [{ title: list, show: [], send }], problems: [] };
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
/** Replace a prefix list `refresh` fetched. It cannot cut access, so it is committed without a rollback. */
|
|
836
|
-
export async function fill(device: Device, _network: readonly Device[], set: string, _family: Family, prefixes: string[]): Promise<void> {
|
|
837
|
-
const client = await login(device);
|
|
838
|
-
|
|
839
|
-
try {
|
|
840
|
-
await checkVersion((command) => exec(client, command), device);
|
|
841
|
-
|
|
842
|
-
const channel = await shell(client);
|
|
843
|
-
await openCli(channel);
|
|
844
|
-
await configure(channel, fillPlan(set, prefixes));
|
|
845
|
-
channel.end();
|
|
846
|
-
|
|
847
|
-
console.log(` ${set}: ${prefixes.length} prefixes on the device`);
|
|
848
|
-
} finally {
|
|
849
|
-
client.end();
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
export const junos: DeviceAdapter = {
|
|
854
|
-
extension: ".conf",
|
|
855
|
-
console: true,
|
|
856
|
-
unsupported,
|
|
857
|
-
render,
|
|
858
|
-
read,
|
|
859
|
-
plan,
|
|
860
|
-
apply,
|
|
861
|
-
fill,
|
|
862
|
-
};
|