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