@takodotid/circuit 0.27.0 → 0.28.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/dist/cli/index.js +527 -161
- package/dist/types/adapters/devices/fortios/index.d.ts +3 -4
- package/dist/types/adapters/devices/junos/index.d.ts +5 -2
- 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/index.ts +16 -84
- package/src/adapters/devices/junos/index.ts +68 -7
- 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 +306 -24
|
@@ -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;
|