@takodotid/circuit 0.27.0 → 0.28.1
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/dist/cli/index.js +542 -166
- package/dist/types/adapters/devices/fortios/index.d.ts +3 -4
- package/dist/types/adapters/devices/junos/index.d.ts +6 -3
- package/dist/types/adapters/devices/raisecom-ros/index.d.ts +10 -5
- package/dist/types/adapters/devices/routeros/index.d.ts +16 -3
- package/dist/types/adapters/devices/vrp/index.d.ts +6 -3
- package/dist/types/schema/device.d.ts +4 -4
- package/dist/types/transport/console.d.ts +50 -2
- package/package.json +1 -1
- package/src/adapters/devices/fortios/TRAPS.md +1 -0
- package/src/adapters/devices/fortios/index.ts +16 -84
- package/src/adapters/devices/junos/TRAPS.md +3 -0
- package/src/adapters/devices/junos/index.ts +81 -9
- package/src/adapters/devices/raisecom-ros/index.ts +72 -16
- package/src/adapters/devices/routeros/TRAPS.md +1 -0
- package/src/adapters/devices/routeros/index.ts +105 -35
- package/src/adapters/devices/vrp/index.ts +91 -26
- package/src/schema/device.ts +4 -4
- package/src/transport/console.ts +319 -26
|
@@ -7,11 +7,12 @@ import { familyOf, host, netmask, split, wildcard } from "../../../core/addr";
|
|
|
7
7
|
import { marker, resolve, substitute } from "../../../core/secrets";
|
|
8
8
|
import { offset } from "../../../core/time";
|
|
9
9
|
import type { Device, Port, SnmpUser, Switched, Vrrp } from "../../../schema";
|
|
10
|
+
import { type ConsoleDialect, consoleLogin, consoleOf, openConsole } from "../../../transport/console";
|
|
10
11
|
import { connect, readUntil, send, shell } from "../../../transport/ssh";
|
|
11
12
|
import { portsOf, spell } from "../catalog";
|
|
12
13
|
import { ipv4Networks, lagIds, switchUnsupported } from "../derive";
|
|
13
14
|
import { compare, type Dialect, entries, type Group, scrub, steps, vlanListChange } from "../lines";
|
|
14
|
-
import type { DeviceAdapter, Plan, Step } from "../types";
|
|
15
|
+
import type { ApplyOptions, DeviceAdapter, Plan, ReadOptions, Step } from "../types";
|
|
15
16
|
|
|
16
17
|
/** Lines the device prints whatever is configured, or that are part of moving around the CLI. */
|
|
17
18
|
const DEVICE_FACTS = [
|
|
@@ -306,10 +307,33 @@ function physicalPort(settings: AnyPort | undefined, vlanId: (name: string) => n
|
|
|
306
307
|
/** The firmware offers only an `ssh-rsa` host key, so that one is allowed for this platform alone. Key exchange, cipher and MAC still negotiate modern ones. */
|
|
307
308
|
const HOST_KEYS = ["ssh-rsa"];
|
|
308
309
|
|
|
309
|
-
|
|
310
|
+
/** A session, and how to prove access in a fresh login: a new SSH session, or logging out and in again on the console. */
|
|
311
|
+
export type Session = { channel: ClientChannel; close: () => void; fresh: () => Promise<Session> };
|
|
310
312
|
|
|
311
|
-
/**
|
|
312
|
-
export
|
|
313
|
+
/** How the console asks for a login. Not yet proved on a device: the RAX721's console was never cabled to a jump host. */
|
|
314
|
+
export const RAISECOM_CONSOLE: ConsoleDialect = {
|
|
315
|
+
login: /login:\s*$/i,
|
|
316
|
+
password: /password:\s*$/i,
|
|
317
|
+
prompt: /[#>]\s*$/,
|
|
318
|
+
logout: "exit",
|
|
319
|
+
others: [[/\(config[^)\n]*\)#\s*$/, "end\n"]],
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
/** An interactive shell, paging off, over SSH or on the console. A session that does not answer as ROS is refused, so nothing meant for it can reach another shell. */
|
|
323
|
+
export async function open(device: Device, options: ReadOptions = {}): Promise<Session> {
|
|
324
|
+
const session = options.console ? await consoleShell(device) : await sshShell(device);
|
|
325
|
+
|
|
326
|
+
const version = await send(session.channel, "show version", 15_000, 1500);
|
|
327
|
+
if (!version.includes("ROS")) {
|
|
328
|
+
session.close();
|
|
329
|
+
throw new Error(`${device.name} did not answer as ROS`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
await send(session.channel, "terminal page-break disable", 15_000, 1000);
|
|
333
|
+
return session;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function sshShell(device: Device): Promise<Session> {
|
|
313
337
|
const user = device.users?.[device.connection.user];
|
|
314
338
|
if (!user) throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
|
|
315
339
|
|
|
@@ -327,19 +351,51 @@ export async function open(device: Device): Promise<Session> {
|
|
|
327
351
|
};
|
|
328
352
|
|
|
329
353
|
const banner = await readUntil(channel, (text) => /[#>]\s*$/.test(text.trimEnd()), 30_000);
|
|
330
|
-
|
|
331
|
-
if (!/[#>]\s*$/.test(banner.trimEnd()) || !version.includes("ROS")) {
|
|
354
|
+
if (!/[#>]\s*$/.test(banner.trimEnd())) {
|
|
332
355
|
close();
|
|
333
356
|
throw new Error(`${device.name} did not answer as ROS`);
|
|
334
357
|
}
|
|
335
358
|
|
|
336
|
-
|
|
337
|
-
|
|
359
|
+
return {
|
|
360
|
+
channel,
|
|
361
|
+
close,
|
|
362
|
+
fresh: async () => {
|
|
363
|
+
close();
|
|
364
|
+
return open(device);
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function consoleShell(device: Device): Promise<Session> {
|
|
370
|
+
const { console, user, password } = consoleOf(device);
|
|
371
|
+
const opened = await openConsole(console);
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
await consoleLogin(opened.channel, RAISECOM_CONSOLE, user, password);
|
|
375
|
+
} catch (error) {
|
|
376
|
+
opened.close();
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const session: Session = {
|
|
381
|
+
channel: opened.channel,
|
|
382
|
+
close: () => {
|
|
383
|
+
opened.channel.write(`${RAISECOM_CONSOLE.logout}\n`);
|
|
384
|
+
opened.close();
|
|
385
|
+
},
|
|
386
|
+
fresh: async () => {
|
|
387
|
+
await consoleLogin(opened.channel, RAISECOM_CONSOLE, user, password);
|
|
388
|
+
await send(opened.channel, "terminal page-break disable", 15_000, 1000);
|
|
389
|
+
return session;
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
return session;
|
|
338
394
|
}
|
|
339
395
|
|
|
340
396
|
/** What the device runs, credentials scrubbed. */
|
|
341
|
-
export async function read(device: Device): Promise<string> {
|
|
342
|
-
const { channel, close } = await open(device);
|
|
397
|
+
export async function read(device: Device, _network: readonly Device[] = [], options: ReadOptions = {}): Promise<string> {
|
|
398
|
+
const { channel, close } = await open(device, options);
|
|
343
399
|
|
|
344
400
|
try {
|
|
345
401
|
// Without paging off and a long wait for silence, the dump stops halfway with no error.
|
|
@@ -493,8 +549,9 @@ const CONFIRM = /^(portswitch|switchport trunk allowed vlan|switchport mode)/;
|
|
|
493
549
|
/** A service letting go of an access-list, so the list can change. */
|
|
494
550
|
const RELEASE = /^no (\S+ access-list \d+)$/;
|
|
495
551
|
|
|
496
|
-
export async function apply(device: Device, _network: readonly Device[], plan: Plan): Promise<void> {
|
|
497
|
-
const
|
|
552
|
+
export async function apply(device: Device, _network: readonly Device[], plan: Plan, options: ApplyOptions): Promise<void> {
|
|
553
|
+
const session = await open(device, options);
|
|
554
|
+
const { channel } = session;
|
|
498
555
|
// Bindings released for an access-list edit. A failure binds them again before anything else, so the list keeps guarding management.
|
|
499
556
|
const released: string[] = [];
|
|
500
557
|
|
|
@@ -521,12 +578,11 @@ export async function apply(device: Device, _network: readonly Device[], plan: P
|
|
|
521
578
|
await send(channel, "config", 10_000).catch(() => undefined);
|
|
522
579
|
for (const binding of released) await send(channel, binding, 10_000).catch(() => undefined);
|
|
523
580
|
if (released.length) console.log(` bound again: ${released.join(", ")}`);
|
|
581
|
+
session.close();
|
|
524
582
|
throw error;
|
|
525
|
-
} finally {
|
|
526
|
-
close();
|
|
527
583
|
}
|
|
528
584
|
|
|
529
|
-
const proof = await
|
|
585
|
+
const proof = await session.fresh().catch(() => undefined);
|
|
530
586
|
if (!proof)
|
|
531
587
|
throw new Error(
|
|
532
588
|
"applied, but a fresh login failed. Not written: a reboot restores the previous configuration. Recover from the console."
|
|
@@ -627,4 +683,4 @@ function plan(desired: string, current: string): ReturnType<DeviceAdapter["plan"
|
|
|
627
683
|
return { steps: planned, problems: [] };
|
|
628
684
|
}
|
|
629
685
|
|
|
630
|
-
export const raisecom: DeviceAdapter = { extension: ".txt", unsupported, render, read, plan, apply };
|
|
686
|
+
export const raisecom: DeviceAdapter = { extension: ".txt", console: true, unsupported, render, read, plan, apply };
|
|
@@ -41,3 +41,4 @@ Behaviour that looks like something else. Each one has been hit on a real device
|
|
|
41
41
|
- `bgp-path-len` counts every AS in the path, prepends included, and takes the same strict comparisons as `dst-len`.
|
|
42
42
|
- `:parse` checks the command, not the text inside `rule=`. A routing filter rule is only checked when it is added.
|
|
43
43
|
- `/routing/filter/filter-wizard` adds the rule it builds; it is not a dry run. `set blackhole yes` was proved this way on 7.23, in an unused chain removed right after.
|
|
44
|
+
- On the serial console, the prompt can come twice after a login. The console echoes a long command wrapped at 80 columns, and a `print` that fills the screen stops at `-- [Q quit|D dump|down]`, where `D` prints the rest.
|
|
@@ -6,8 +6,9 @@ import type { Client } from "ssh2";
|
|
|
6
6
|
import { familyOf } from "../../../core/addr";
|
|
7
7
|
import { resolve, substitute } from "../../../core/secrets";
|
|
8
8
|
import type { Device, Family } from "../../../schema";
|
|
9
|
+
import { type ConsoleDialect, consoleLogin, consoleOf, ConsoleShell, openConsole } from "../../../transport/console";
|
|
9
10
|
import { connect, exec, upload } from "../../../transport/ssh";
|
|
10
|
-
import type { ApplyOptions, DeviceAdapter, Plan } from "../types";
|
|
11
|
+
import type { ApplyOptions, DeviceAdapter, Plan, ReadOptions } from "../types";
|
|
11
12
|
import { INSTALL_CERTIFICATE, plan } from "./plan";
|
|
12
13
|
import { spell } from "../catalog";
|
|
13
14
|
import { mediumOf, render } from "./render";
|
|
@@ -27,9 +28,77 @@ export async function login(device: Device): Promise<Client> {
|
|
|
27
28
|
});
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
/** How the RouterOS console asks for a login. The flags after the user turn off colours and terminal detection, without which the console redraws every line. */
|
|
32
|
+
export const ROUTEROS_CONSOLE: ConsoleDialect = {
|
|
33
|
+
login: /Login: $/,
|
|
34
|
+
password: /Password: $/,
|
|
35
|
+
prompt: /\] > $/,
|
|
36
|
+
logout: "/quit",
|
|
37
|
+
others: [
|
|
38
|
+
[/-- \[Q quit\|D dump\|(?:C-z pause\|)?down\]$/, "D"],
|
|
39
|
+
[/\[Y\/n\]:? ?$/, "n"],
|
|
40
|
+
],
|
|
41
|
+
user: (user) => `${user}+cwt`,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Where commands run, one at a time, each returning what it printed: an SSH session, or the console. */
|
|
45
|
+
type Session = {
|
|
46
|
+
run(command: string, timeout?: number): Promise<string>;
|
|
47
|
+
/** Write a file to the device. Only SSH can. */
|
|
48
|
+
upload?(path: string, content: string): Promise<void>;
|
|
49
|
+
/** A fresh login, which proves access survived: this session ends and a new SSH session opens, or the console logs out and in again. */
|
|
50
|
+
fresh(): Promise<Session>;
|
|
51
|
+
end(): void;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function sshSession(client: Client, device: Device): Session {
|
|
55
|
+
return {
|
|
56
|
+
run: (command, timeout) => exec(client, command, timeout),
|
|
57
|
+
upload: (path, content) => upload(client, path, content),
|
|
58
|
+
fresh: async () => {
|
|
59
|
+
client.end();
|
|
60
|
+
return sshSession(await login(device), device);
|
|
61
|
+
},
|
|
62
|
+
end: () => client.end(),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function consoleSession(device: Device): Promise<Session> {
|
|
67
|
+
const { console, user, password } = consoleOf(device);
|
|
68
|
+
const opened = await openConsole(console);
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
await consoleLogin(opened.channel, ROUTEROS_CONSOLE, user, password);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
opened.close();
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const shell = new ConsoleShell(opened.channel, ROUTEROS_CONSOLE);
|
|
78
|
+
|
|
79
|
+
const session: Session = {
|
|
80
|
+
run: (command, timeout) => shell.run(command, timeout),
|
|
81
|
+
fresh: async () => {
|
|
82
|
+
await consoleLogin(opened.channel, ROUTEROS_CONSOLE, user, password);
|
|
83
|
+
return session;
|
|
84
|
+
},
|
|
85
|
+
end: () => {
|
|
86
|
+
shell.logout();
|
|
87
|
+
opened.close();
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
return session;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Log in over SSH, or on the console when asked. */
|
|
95
|
+
async function open(device: Device, options: ReadOptions = {}): Promise<Session> {
|
|
96
|
+
return options.console ? consoleSession(device) : sshSession(await login(device), device);
|
|
97
|
+
}
|
|
98
|
+
|
|
30
99
|
/** Refuse a device on a version this adapter does not render for, before anything is read or sent. */
|
|
31
|
-
export async function checkVersion(
|
|
32
|
-
const version = (await
|
|
100
|
+
export async function checkVersion(session: Session, device: Device): Promise<void> {
|
|
101
|
+
const version = (await session.run(":put [/system resource get version]")).trim();
|
|
33
102
|
const major = Number(version.split(".")[0]);
|
|
34
103
|
|
|
35
104
|
if (major !== SUPPORTED_MAJOR) {
|
|
@@ -54,15 +123,15 @@ const LIST_CERTIFICATES = [
|
|
|
54
123
|
/**
|
|
55
124
|
* What the device runs: its export, its users, and its certificates. Secrets are not part of an export.
|
|
56
125
|
*/
|
|
57
|
-
export async function read(device: Device): Promise<string> {
|
|
58
|
-
const
|
|
126
|
+
export async function read(device: Device, _network: readonly Device[] = [], options: ReadOptions = {}): Promise<string> {
|
|
127
|
+
const session = await open(device, options);
|
|
59
128
|
|
|
60
129
|
try {
|
|
61
|
-
await checkVersion(
|
|
130
|
+
await checkVersion(session, device);
|
|
62
131
|
|
|
63
|
-
const config = await
|
|
64
|
-
const users = await
|
|
65
|
-
const certificates = (await
|
|
132
|
+
const config = await session.run("/export", 300_000);
|
|
133
|
+
const users = await session.run("/user export");
|
|
134
|
+
const certificates = (await session.run(LIST_CERTIFICATES)).trim();
|
|
66
135
|
|
|
67
136
|
const listed = certificates.split("\n").every((line) => line.trim().startsWith("add fingerprint="));
|
|
68
137
|
if (certificates && !listed) {
|
|
@@ -75,7 +144,7 @@ export async function read(device: Device): Promise<string> {
|
|
|
75
144
|
const text = [config.trim(), userMenu, certificateMenu].filter(Boolean).join("\n");
|
|
76
145
|
return withoutTimestamp(text) + "\n";
|
|
77
146
|
} finally {
|
|
78
|
-
|
|
147
|
+
session.end();
|
|
79
148
|
}
|
|
80
149
|
}
|
|
81
150
|
|
|
@@ -145,44 +214,46 @@ const ROLLBACK_SCHEDULER = "apply-rollback";
|
|
|
145
214
|
const escapeForString = (text: string) => text.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$");
|
|
146
215
|
|
|
147
216
|
/** Compile a command on the device without running it. Returns why it was refused, or undefined. */
|
|
148
|
-
async function refusal(
|
|
149
|
-
const compiled = (await
|
|
217
|
+
async function refusal(session: Session, command: string): Promise<string | undefined> {
|
|
218
|
+
const compiled = (await session.run(`:put [:parse "${escapeForString(command)}"]`)).trim();
|
|
150
219
|
const refused = !compiled.startsWith("(") || /syntax error|bad parameter|no such|expected/.test(compiled);
|
|
151
220
|
return refused ? compiled.slice(0, 200) : undefined;
|
|
152
221
|
}
|
|
153
222
|
|
|
154
223
|
/** Upload a certificate and its key, import both under one name, and delete the files. */
|
|
155
|
-
async function installCertificate(
|
|
224
|
+
async function installCertificate(session: Session, device: Device, name: string): Promise<void> {
|
|
156
225
|
const certificate = device.certificates?.[name];
|
|
157
226
|
if (!certificate) throw new Error(`certificate ${name} is not declared`);
|
|
227
|
+
if (!session.upload)
|
|
228
|
+
throw new Error(`certificate ${name} is installed from a file, which only SSH can write; apply it without --console`);
|
|
158
229
|
|
|
159
|
-
await upload(
|
|
160
|
-
await
|
|
230
|
+
await session.upload(`${name}.crt`, certificate.certificate);
|
|
231
|
+
await session.run(`/certificate import file-name=${name}.crt name=${name} passphrase=""`);
|
|
161
232
|
|
|
162
233
|
if (certificate.private_key) {
|
|
163
234
|
// A key from a file is PEM as it is; one from a variable is PEM encoded as one line of base64.
|
|
164
235
|
const value = resolve(certificate.private_key);
|
|
165
236
|
const privateKey = value.includes("-----BEGIN") ? `${value}\n` : Buffer.from(value, "base64").toString("utf8");
|
|
166
237
|
|
|
167
|
-
await upload(
|
|
168
|
-
await
|
|
238
|
+
await session.upload(`${name}.key`, privateKey);
|
|
239
|
+
await session.run(`/certificate import file-name=${name}.key name=${name} passphrase=""`);
|
|
169
240
|
}
|
|
170
241
|
|
|
171
|
-
await
|
|
242
|
+
await session.run(`/file remove [find name="${name}.crt" or name="${name}.key"]`);
|
|
172
243
|
}
|
|
173
244
|
|
|
174
245
|
export async function apply(device: Device, _network: readonly Device[], changes: Plan, options: ApplyOptions): Promise<void> {
|
|
175
|
-
const
|
|
176
|
-
await checkVersion(
|
|
246
|
+
const session = await open(device, options);
|
|
247
|
+
await checkVersion(session, device);
|
|
177
248
|
|
|
178
249
|
const commands = changes.steps.flatMap((step) => step.send.map((command) => ({ title: step.title, command })));
|
|
179
250
|
|
|
180
251
|
for (const { title, command } of commands) {
|
|
181
252
|
if (command.startsWith(INSTALL_CERTIFICATE)) continue;
|
|
182
253
|
|
|
183
|
-
const reason = await refusal(
|
|
254
|
+
const reason = await refusal(session, substitute(command));
|
|
184
255
|
if (reason) {
|
|
185
|
-
|
|
256
|
+
session.end();
|
|
186
257
|
throw new Error(`${title}: the device will not parse ${command.slice(0, 200)}\n ${reason}`);
|
|
187
258
|
}
|
|
188
259
|
}
|
|
@@ -201,38 +272,36 @@ export async function apply(device: Device, _network: readonly Device[], changes
|
|
|
201
272
|
try {
|
|
202
273
|
console.log(` saving ${backup}, arming a ${options.rollback} minute rollback`);
|
|
203
274
|
|
|
204
|
-
await
|
|
205
|
-
await
|
|
206
|
-
await
|
|
275
|
+
await session.run(`/system backup save name=${backup} dont-encrypt=yes`, 120_000);
|
|
276
|
+
await session.run(`/system scheduler remove [find name="${ROLLBACK_SCHEDULER}"]`);
|
|
277
|
+
await session.run(armRollback);
|
|
207
278
|
|
|
208
279
|
for (const { title, command } of commands) {
|
|
209
280
|
if (command.startsWith(INSTALL_CERTIFICATE)) {
|
|
210
281
|
const certificateName = command.slice(INSTALL_CERTIFICATE.length).trim();
|
|
211
|
-
await installCertificate(
|
|
282
|
+
await installCertificate(session, device, certificateName);
|
|
212
283
|
continue;
|
|
213
284
|
}
|
|
214
285
|
|
|
215
|
-
const answer = (await
|
|
286
|
+
const answer = (await session.run(substitute(command), 120_000)).trim();
|
|
216
287
|
if (answer) throw new Error(`${title}: ${command.slice(0, 160)}\n device said: ${answer.slice(0, 300)}`);
|
|
217
288
|
}
|
|
218
289
|
} catch (error) {
|
|
219
290
|
console.log(` FAILED: ${(error as Error).message}`);
|
|
220
291
|
console.log(` restoring ${backup} now; the rollback stays armed in case this does not get through`);
|
|
221
292
|
|
|
222
|
-
await
|
|
223
|
-
|
|
293
|
+
await session.run(`/system backup load name=${backup} password=""`, 30_000).catch(() => undefined);
|
|
294
|
+
session.end();
|
|
224
295
|
|
|
225
296
|
throw new Error("apply failed and the backup is being restored");
|
|
226
297
|
}
|
|
227
298
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
// A fresh session is the proof that access survived. Only then is the rollback removed, with the backup, which holds every credential.
|
|
231
|
-
const proof = await login(device).catch(() => undefined);
|
|
299
|
+
// A fresh login is the proof that access survived. Only then is the rollback removed, with the backup, which holds every credential.
|
|
300
|
+
const proof = await session.fresh().catch(() => undefined);
|
|
232
301
|
if (!proof) throw new Error(`could not log in again; the device restores ${backup} within ${options.rollback} minutes`);
|
|
233
302
|
|
|
234
|
-
await
|
|
235
|
-
await
|
|
303
|
+
await proof.run(`/system scheduler remove [find name="${ROLLBACK_SCHEDULER}"]`);
|
|
304
|
+
await proof.run(`/file remove [find name="${backup}.backup"]`);
|
|
236
305
|
proof.end();
|
|
237
306
|
console.log(" logged in again, rollback disarmed");
|
|
238
307
|
}
|
|
@@ -262,6 +331,7 @@ export async function fill(device: Device, _network: readonly Device[], set: str
|
|
|
262
331
|
|
|
263
332
|
export const routeros: DeviceAdapter = {
|
|
264
333
|
extension: ".rsc",
|
|
334
|
+
console: true,
|
|
265
335
|
unsupported,
|
|
266
336
|
render,
|
|
267
337
|
read,
|
|
@@ -7,11 +7,12 @@ import { familyOf, host, netmask, split, wildcard } from "../../../core/addr";
|
|
|
7
7
|
import { marker, resolve, substitute, valueOfReference } from "../../../core/secrets";
|
|
8
8
|
import { offset } from "../../../core/time";
|
|
9
9
|
import type { Device, Interface, Port, Vrrp } from "../../../schema";
|
|
10
|
+
import { type ConsoleDialect, consoleLogin, consoleOf, openConsole } from "../../../transport/console";
|
|
10
11
|
import { connect, readUntil, send, shell } from "../../../transport/ssh";
|
|
11
12
|
import { portsOf, spell } from "../catalog";
|
|
12
13
|
import { ipv4Networks, lagIds, switchUnsupported } from "../derive";
|
|
13
14
|
import { compare, type Dialect, scrub, steps, vlanListChange } from "../lines";
|
|
14
|
-
import type { DeviceAdapter, Plan } from "../types";
|
|
15
|
+
import type { ApplyOptions, DeviceAdapter, Plan, ReadOptions } from "../types";
|
|
15
16
|
|
|
16
17
|
/** Lines the device prints whatever is configured, or that describe the hardware rather than its configuration. */
|
|
17
18
|
const DEVICE_FACTS = [
|
|
@@ -570,8 +571,46 @@ export async function openShell(client: Client): Promise<ClientChannel> {
|
|
|
570
571
|
return channel;
|
|
571
572
|
}
|
|
572
573
|
|
|
574
|
+
/** How the console asks for a login: the same question to change the password, always answered no, and a system view left with `return`. Not yet proved on a device. */
|
|
575
|
+
export const VRP_CONSOLE: ConsoleDialect = {
|
|
576
|
+
login: /Username:\s*$/i,
|
|
577
|
+
password: /Password:\s*$/i,
|
|
578
|
+
prompt: /<[^>\n]+>\s*$/,
|
|
579
|
+
logout: "quit",
|
|
580
|
+
others: [
|
|
581
|
+
[/Change now\? ?\[Y\/N\]:?\s*$/i, "N\n"],
|
|
582
|
+
[/\[~?\*?[^\]\n]+\]\s*$/, "return\n"],
|
|
583
|
+
],
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
/** Log in on the console with paging off, run `work` with a way to log in afresh, and log out. */
|
|
587
|
+
async function onConsole<T>(device: Device, work: (channel: ClientChannel, relogin: () => Promise<void>) => Promise<T>): Promise<T> {
|
|
588
|
+
const { console, user, password } = consoleOf(device);
|
|
589
|
+
const opened = await openConsole(console);
|
|
590
|
+
|
|
591
|
+
const login = async () => {
|
|
592
|
+
await consoleLogin(opened.channel, VRP_CONSOLE, user, password);
|
|
593
|
+
await send(opened.channel, "screen-length 0 temporary", 10_000, 1500);
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
try {
|
|
597
|
+
await login();
|
|
598
|
+
const result = await work(opened.channel, login);
|
|
599
|
+
opened.channel.write(`${VRP_CONSOLE.logout}\n`);
|
|
600
|
+
return result;
|
|
601
|
+
} finally {
|
|
602
|
+
opened.close();
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
573
606
|
/** What the device runs, credentials scrubbed. */
|
|
574
|
-
export async function read(device: Device): Promise<string> {
|
|
607
|
+
export async function read(device: Device, _network: readonly Device[] = [], options: ReadOptions = {}): Promise<string> {
|
|
608
|
+
if (options.console) {
|
|
609
|
+
return onConsole(device, async (channel) =>
|
|
610
|
+
configurationOf(device, await send(channel, "display current-configuration", 120_000, 3000))
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
575
614
|
const client = await login(device);
|
|
576
615
|
|
|
577
616
|
try {
|
|
@@ -579,17 +618,22 @@ export async function read(device: Device): Promise<string> {
|
|
|
579
618
|
const dump = await send(channel, "display current-configuration", 120_000, 3000);
|
|
580
619
|
channel.end();
|
|
581
620
|
|
|
582
|
-
|
|
583
|
-
const start = lines.findIndex((line) => line.startsWith("!Software Version"));
|
|
584
|
-
if (start < 0) throw new Error(`${device.name} returned no configuration`);
|
|
585
|
-
// The prompt, and the stamp of the last change, are not configuration.
|
|
586
|
-
const body = lines.slice(start).filter((line) => !/^<.*>$/.test(line.trim()) && !line.startsWith("!Last configuration"));
|
|
587
|
-
return scrub(body.join("\n").trim() + "\n");
|
|
621
|
+
return configurationOf(device, dump);
|
|
588
622
|
} finally {
|
|
589
623
|
client.end();
|
|
590
624
|
}
|
|
591
625
|
}
|
|
592
626
|
|
|
627
|
+
/** The configuration in a dump, without the prompt or the stamp of the last change. */
|
|
628
|
+
function configurationOf(device: Device, dump: string): string {
|
|
629
|
+
const lines = dump.replace(/\r/g, "").split("\n");
|
|
630
|
+
const start = lines.findIndex((line) => line.startsWith("!Software Version"));
|
|
631
|
+
if (start < 0) throw new Error(`${device.name} returned no configuration`);
|
|
632
|
+
|
|
633
|
+
const body = lines.slice(start).filter((line) => !/^<.*>$/.test(line.trim()) && !line.startsWith("!Last configuration"));
|
|
634
|
+
return scrub(body.join("\n").trim() + "\n");
|
|
635
|
+
}
|
|
636
|
+
|
|
593
637
|
export function unsupported(device: Device): string[] {
|
|
594
638
|
const found: string[] = [];
|
|
595
639
|
|
|
@@ -693,10 +737,8 @@ async function sendLine(channel: ClientChannel, line: string): Promise<string> {
|
|
|
693
737
|
return answer;
|
|
694
738
|
}
|
|
695
739
|
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
const channel = await openShell(client);
|
|
699
|
-
|
|
740
|
+
/** Send a plan into the candidate and commit it. A refused line clears the candidate, which leaves the device as it was. */
|
|
741
|
+
async function commitPlan(channel: ClientChannel, plan: Plan): Promise<void> {
|
|
700
742
|
try {
|
|
701
743
|
await send(channel, "system-view", 10_000, 1500);
|
|
702
744
|
|
|
@@ -711,28 +753,50 @@ export async function apply(device: Device, _network: readonly Device[], plan: P
|
|
|
711
753
|
if (REFUSED.test(committed)) throw new Error(`commit refused: ${committed.trim().slice(0, 300)}`);
|
|
712
754
|
await send(channel, "quit", 10_000);
|
|
713
755
|
} catch (error) {
|
|
714
|
-
// Nothing is committed yet. Clearing the candidate leaves the device as it was.
|
|
715
756
|
await send(channel, "clear configuration candidate", 10_000).catch(() => undefined);
|
|
716
|
-
channel.end();
|
|
717
|
-
client.end();
|
|
718
757
|
throw error;
|
|
719
758
|
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** Save what runs, so a reboot keeps it. */
|
|
762
|
+
async function save(channel: ClientChannel): Promise<void> {
|
|
763
|
+
const saving = await send(channel, "save", 20_000, 2000);
|
|
764
|
+
if (/\[Y\/N\]/i.test(saving)) await send(channel, "Y", 60_000, 5000);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const NOT_SAVED = "Not saved: a reboot restores the previous configuration.";
|
|
768
|
+
|
|
769
|
+
export async function apply(device: Device, _network: readonly Device[], plan: Plan, options: ApplyOptions): Promise<void> {
|
|
770
|
+
// On the console, a fresh login on the same console is the proof, and the save is sent in it.
|
|
771
|
+
if (options.console) {
|
|
772
|
+
await onConsole(device, async (channel, relogin) => {
|
|
773
|
+
await commitPlan(channel, plan);
|
|
774
|
+
await relogin().catch((error: Error) => {
|
|
775
|
+
throw new Error(`committed, but a fresh login on the console failed: ${error.message}. ${NOT_SAVED}`);
|
|
776
|
+
});
|
|
777
|
+
await save(channel);
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
console.log(" committed, login proved, saved");
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const client = await login(device);
|
|
785
|
+
const channel = await openShell(client);
|
|
786
|
+
|
|
787
|
+
try {
|
|
788
|
+
await commitPlan(channel, plan);
|
|
789
|
+
|
|
790
|
+
const proof = await login(device).catch(() => undefined);
|
|
791
|
+
if (!proof) throw new Error(`committed, but a fresh login failed. ${NOT_SAVED} Recover over the open session or the console.`);
|
|
792
|
+
proof.end();
|
|
720
793
|
|
|
721
|
-
|
|
722
|
-
|
|
794
|
+
await save(channel);
|
|
795
|
+
} finally {
|
|
723
796
|
channel.end();
|
|
724
797
|
client.end();
|
|
725
|
-
throw new Error(
|
|
726
|
-
"committed, but a fresh login failed. Not saved: a reboot restores the previous configuration. Recover over the open session or the console."
|
|
727
|
-
);
|
|
728
798
|
}
|
|
729
799
|
|
|
730
|
-
proof.end();
|
|
731
|
-
|
|
732
|
-
const saving = await send(channel, "save", 20_000, 2000);
|
|
733
|
-
if (/\[Y\/N\]/i.test(saving)) await send(channel, "Y", 60_000, 5000);
|
|
734
|
-
channel.end();
|
|
735
|
-
client.end();
|
|
736
800
|
console.log(" committed, login proved, saved");
|
|
737
801
|
}
|
|
738
802
|
|
|
@@ -779,6 +843,7 @@ function plan(desired: string, current: string): Plan {
|
|
|
779
843
|
|
|
780
844
|
export const vrp: DeviceAdapter = {
|
|
781
845
|
extension: ".cfg",
|
|
846
|
+
console: true,
|
|
782
847
|
unsupported,
|
|
783
848
|
render,
|
|
784
849
|
read,
|
package/src/schema/device.ts
CHANGED
|
@@ -31,11 +31,11 @@ export type Jump = {
|
|
|
31
31
|
cloudflared?: true;
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
-
/** A serial console,
|
|
34
|
+
/** A serial console, on a jump host or on the computer Circuit runs on. */
|
|
35
35
|
export type Console = {
|
|
36
|
-
/** The jump host the console cable is plugged into. */
|
|
37
|
-
jump
|
|
38
|
-
/** The serial port on
|
|
36
|
+
/** The jump host the console cable is plugged into. When absent, the cable is plugged into the computer Circuit runs on. */
|
|
37
|
+
jump?: Jump;
|
|
38
|
+
/** The serial port: on Linux or macOS a path under `/dev`, where a path under `/dev/serial/by-path/` stays the same when two adapters swap names; on Windows a COM port, such as `COM3`. */
|
|
39
39
|
port: string;
|
|
40
40
|
/** Line speed in bits per second, such as 9600 or 115200. */
|
|
41
41
|
baud: number;
|