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