@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.
@@ -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
- export type Session = { channel: ClientChannel; close: () => void };
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
- /** An interactive shell, paging off. A session that does not answer as ROS is refused, so nothing meant for it can reach another shell. */
312
- export async function open(device: Device): Promise<Session> {
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
- const version = await send(channel, "show version", 15_000, 1500);
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
- await send(channel, "terminal page-break disable", 15_000, 1000);
337
- return { channel, close };
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 { channel, close } = await open(device);
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 open(device).catch(() => undefined);
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(client: Client, device: Device): Promise<void> {
32
- const version = (await exec(client, ":put [/system resource get version]")).trim();
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 client = await login(device);
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(client, device);
130
+ await checkVersion(session, device);
62
131
 
63
- const config = await exec(client, "/export", 300_000);
64
- const users = await exec(client, "/user export");
65
- const certificates = (await exec(client, LIST_CERTIFICATES)).trim();
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
- client.end();
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(client: Client, command: string): Promise<string | undefined> {
149
- const compiled = (await exec(client, `:put [:parse "${escapeForString(command)}"]`)).trim();
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(client: Client, device: Device, name: string): Promise<void> {
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(client, `${name}.crt`, certificate.certificate);
160
- await exec(client, `/certificate import file-name=${name}.crt name=${name} passphrase=""`);
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(client, `${name}.key`, privateKey);
168
- await exec(client, `/certificate import file-name=${name}.key name=${name} passphrase=""`);
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 exec(client, `/file remove [find name="${name}.crt" or name="${name}.key"]`);
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 client = await login(device);
176
- await checkVersion(client, device);
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(client, substitute(command));
254
+ const reason = await refusal(session, substitute(command));
184
255
  if (reason) {
185
- client.end();
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 exec(client, `/system backup save name=${backup} dont-encrypt=yes`, 120_000);
205
- await exec(client, `/system scheduler remove [find name="${ROLLBACK_SCHEDULER}"]`);
206
- await exec(client, armRollback);
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(client, device, certificateName);
282
+ await installCertificate(session, device, certificateName);
212
283
  continue;
213
284
  }
214
285
 
215
- const answer = (await exec(client, substitute(command), 120_000)).trim();
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 exec(client, `/system backup load name=${backup} password=""`, 30_000).catch(() => undefined);
223
- client.end();
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
- client.end();
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 exec(proof, `/system scheduler remove [find name="${ROLLBACK_SCHEDULER}"]`);
235
- await exec(proof, `/file remove [find name="${backup}.backup"]`);
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
- const lines = dump.replace(/\r/g, "").split("\n");
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
- export async function apply(device: Device, _network: readonly Device[], plan: Plan): Promise<void> {
697
- const client = await login(device);
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
- const proof = await login(device).catch(() => undefined);
722
- if (!proof) {
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,
@@ -31,11 +31,11 @@ export type Jump = {
31
31
  cloudflared?: true;
32
32
  };
33
33
 
34
- /** A serial console, reached through a jump host. */
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: Jump;
38
- /** The serial port on the jump host. A path under `/dev/serial/by-path/` stays the same when two adapters swap names. */
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;