@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.
@@ -1,5 +1,6 @@
1
1
  import type { Client } from "ssh2";
2
2
  import type { Device, Family } from "../../../schema";
3
+ import { type ConsoleDialect } from "../../../transport/console";
3
4
  import type { ApplyOptions, DeviceAdapter, Plan, ReadOptions } from "../types";
4
5
  /** What a session reads and writes: an SSH shell, or anything else that streams the CLI. */
5
6
  export type Channel = {
@@ -26,10 +27,8 @@ export declare class Cli {
26
27
  private collect;
27
28
  }
28
29
  export declare function login(device: Device): Promise<Client>;
29
- /**
30
- * Log in on a serial console, whatever state the last session left it in: a pager is quit, a `config` level left, a session someone left open logged out, so every session starts from a fresh login. The CLI ends a line at a carriage return.
31
- */
32
- export declare function consoleLogin(channel: Channel, user: string, password: string): Promise<Cli>;
30
+ /** How the FortiOS console asks for a login, and what moves it on from where the last session left it. */
31
+ export declare const FORTIOS_CONSOLE: ConsoleDialect;
33
32
  /** Refuse a device on a release this adapter was not proved on, before anything is read or sent. */
34
33
  export declare function checkVersion(cli: Cli, device: Device): Promise<void>;
35
34
  /** Secrets the device prints: a password or an SNMPv3 key as a cipher, a certificate's key encrypted, and the SNMP community in clear. None leave the read. */
@@ -1,6 +1,7 @@
1
1
  import type { Client, ClientChannel } from "ssh2";
2
2
  import type { Device, Family } from "../../../schema";
3
- import type { ApplyOptions, DeviceAdapter, Plan } from "../types";
3
+ import { type ConsoleDialect } from "../../../transport/console";
4
+ import type { ApplyOptions, DeviceAdapter, Plan, ReadOptions } from "../types";
4
5
  /** A statement's words; a quoted value is one word with its quotes. */
5
6
  export declare function wordsOf(statement: string): string[];
6
7
  /** The device prints a password as a hash, an SNMP key as a reversible cipher, and a community as itself. None leave the read. */
@@ -16,7 +17,9 @@ export declare function clean(output: string): string;
16
17
  */
17
18
  export declare function plan(desired: string, current: string, options: Pick<ApplyOptions, "secrets">): Plan;
18
19
  export declare function login(device: Device): Promise<Client>;
19
- export declare function read(device: Device): Promise<string>;
20
+ export declare function read(device: Device, _network?: readonly Device[], options?: ReadOptions): Promise<string>;
21
+ /** How the console asks for a login. Root lands in a shell, `%`, every other user in the CLI, `>`; a session left in configuration mode, `#`, leaves it first. Not yet proved on a device. */
22
+ export declare const JUNOS_CONSOLE: ConsoleDialect;
20
23
  /** An interactive CLI with paging off. Root lands in a shell and starts the CLI from there. */
21
24
  export declare function openCli(channel: ClientChannel): Promise<void>;
22
25
  /** How a commit that could cut access is guarded: it rolls back after `minutes` unless `prove` shows access survived. */
@@ -29,7 +32,7 @@ export type Guard = {
29
32
  *
30
33
  * Deletions go before the statements that are set, so a path deleted and set again in one plan ends up set.
31
34
  */
32
- export declare function configure(channel: ClientChannel, plan: Plan, guard?: Guard): Promise<void>;
35
+ export declare function configure(channel: ClientChannel, plan: Plan, guard?: Guard, paced?: boolean): Promise<void>;
33
36
  export declare function apply(device: Device, _network: readonly Device[], plan: Plan, options: ApplyOptions): Promise<void>;
34
37
  export declare function unsupported(device: Device): string[];
35
38
  /** The plan that replaces a prefix list's entries with what a registry returned. */
@@ -1,23 +1,28 @@
1
1
  import type { ClientChannel } from "ssh2";
2
2
  import type { Device } from "../../../schema";
3
+ import { type ConsoleDialect } from "../../../transport/console";
3
4
  import { type Dialect } from "../lines";
4
- import type { DeviceAdapter, Plan } from "../types";
5
+ import type { ApplyOptions, DeviceAdapter, Plan, ReadOptions } from "../types";
5
6
  export declare const dialect: Dialect;
6
7
  /** The access-list that guards every management service. */
7
8
  export declare const MANAGEMENT_ACL = 1000;
8
9
  /** `99,200-201,999`, the platform's compression of a VLAN list. */
9
10
  export declare function vlanList(ids: number[]): string;
10
11
  export declare function render(device: Device): string;
12
+ /** A session, and how to prove access in a fresh login: a new SSH session, or logging out and in again on the console. */
11
13
  export type Session = {
12
14
  channel: ClientChannel;
13
15
  close: () => void;
16
+ fresh: () => Promise<Session>;
14
17
  };
15
- /** An interactive shell, paging off. A session that does not answer as ROS is refused, so nothing meant for it can reach another shell. */
16
- export declare function open(device: Device): Promise<Session>;
18
+ /** How the console asks for a login. Not yet proved on a device: the RAX721's console was never cabled to a jump host. */
19
+ export declare const RAISECOM_CONSOLE: ConsoleDialect;
20
+ /** 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. */
21
+ export declare function open(device: Device, options?: ReadOptions): Promise<Session>;
17
22
  /** What the device runs, credentials scrubbed. */
18
- export declare function read(device: Device): Promise<string>;
23
+ export declare function read(device: Device, _network?: readonly Device[], options?: ReadOptions): Promise<string>;
19
24
  export declare function unsupported(device: Device): string[];
20
- export declare function apply(device: Device, _network: readonly Device[], plan: Plan): Promise<void>;
25
+ export declare function apply(device: Device, _network: readonly Device[], plan: Plan, options: ApplyOptions): Promise<void>;
21
26
  /** The VLAN IDs of a `create vlan 10,20-30 active` line. */
22
27
  export declare function createdIds(line: string): number[];
23
28
  export declare const raisecom: DeviceAdapter;
@@ -1,17 +1,30 @@
1
1
  import type { Client } from "ssh2";
2
2
  import type { Device, Family } from "../../../schema";
3
- import type { ApplyOptions, DeviceAdapter, Plan } from "../types";
3
+ import { type ConsoleDialect } from "../../../transport/console";
4
+ import type { ApplyOptions, DeviceAdapter, Plan, ReadOptions } from "../types";
4
5
  /** The major version this adapter renders for. Version 6 has a different BGP and filter language altogether. */
5
6
  export declare const SUPPORTED_MAJOR = 7;
6
7
  export declare function login(device: Device): Promise<Client>;
8
+ /** 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. */
9
+ export declare const ROUTEROS_CONSOLE: ConsoleDialect;
10
+ /** Where commands run, one at a time, each returning what it printed: an SSH session, or the console. */
11
+ type Session = {
12
+ run(command: string, timeout?: number): Promise<string>;
13
+ /** Write a file to the device. Only SSH can. */
14
+ upload?(path: string, content: string): Promise<void>;
15
+ /** A fresh login, which proves access survived: this session ends and a new SSH session opens, or the console logs out and in again. */
16
+ fresh(): Promise<Session>;
17
+ end(): void;
18
+ };
7
19
  /** Refuse a device on a version this adapter does not render for, before anything is read or sent. */
8
- export declare function checkVersion(client: Client, device: Device): Promise<void>;
20
+ export declare function checkVersion(session: Session, device: Device): Promise<void>;
9
21
  /**
10
22
  * What the device runs: its export, its users, and its certificates. Secrets are not part of an export.
11
23
  */
12
- export declare function read(device: Device): Promise<string>;
24
+ export declare function read(device: Device, _network?: readonly Device[], options?: ReadOptions): Promise<string>;
13
25
  export declare function unsupported(device: Device): string[];
14
26
  export declare function apply(device: Device, _network: readonly Device[], changes: Plan, options: ApplyOptions): Promise<void>;
15
27
  /** Replace a list's entries in batches, so each command stays a sane length. */
16
28
  export declare function fill(device: Device, _network: readonly Device[], set: string, family: Family, prefixes: string[]): Promise<void>;
17
29
  export declare const routeros: DeviceAdapter;
30
+ export {};
@@ -1,7 +1,8 @@
1
1
  import type { Client, ClientChannel } from "ssh2";
2
2
  import type { Device } from "../../../schema";
3
+ import { type ConsoleDialect } from "../../../transport/console";
3
4
  import { type Dialect } from "../lines";
4
- import type { DeviceAdapter, Plan } from "../types";
5
+ import type { ApplyOptions, DeviceAdapter, Plan, ReadOptions } from "../types";
5
6
  export declare const dialect: Dialect;
6
7
  /** The ACL that guards every management service. */
7
8
  export declare const MANAGEMENT_ACL = 2000;
@@ -17,8 +18,10 @@ export declare function login(device: Device): Promise<Client>;
17
18
  * Every login asks whether to change the password. Answering yes replaces it at once and every stored credential with it, so the answer is always no. Anything sent before the question is answered is taken as the answer.
18
19
  */
19
20
  export declare function openShell(client: Client): Promise<ClientChannel>;
21
+ /** 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. */
22
+ export declare const VRP_CONSOLE: ConsoleDialect;
20
23
  /** What the device runs, credentials scrubbed. */
21
- export declare function read(device: Device): Promise<string>;
24
+ export declare function read(device: Device, _network?: readonly Device[], options?: ReadOptions): Promise<string>;
22
25
  export declare function unsupported(device: Device): string[];
23
- export declare function apply(device: Device, _network: readonly Device[], plan: Plan): Promise<void>;
26
+ export declare function apply(device: Device, _network: readonly Device[], plan: Plan, options: ApplyOptions): Promise<void>;
24
27
  export declare const vrp: DeviceAdapter;
@@ -28,11 +28,11 @@ export type Jump = {
28
28
  /** The jump host is reached through a Cloudflare Tunnel, so Circuit connects with `cloudflared access ssh`, which must be installed. */
29
29
  cloudflared?: true;
30
30
  };
31
- /** A serial console, reached through a jump host. */
31
+ /** A serial console, on a jump host or on the computer Circuit runs on. */
32
32
  export type Console = {
33
- /** The jump host the console cable is plugged into. */
34
- jump: Jump;
35
- /** The serial port on the jump host. A path under `/dev/serial/by-path/` stays the same when two adapters swap names. */
33
+ /** The jump host the console cable is plugged into. When absent, the cable is plugged into the computer Circuit runs on. */
34
+ jump?: Jump;
35
+ /** 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`. */
36
36
  port: string;
37
37
  /** Line speed in bits per second, such as 9600 or 115200. */
38
38
  baud: number;
@@ -1,9 +1,57 @@
1
1
  import { type ClientChannel } from "ssh2";
2
- import type { Console } from "../schema";
2
+ import type { Console, Device } from "../schema";
3
3
  /** An open console: the stream to the device's CLI, and how to let it go. */
4
4
  export type ConsoleSession = {
5
5
  channel: ClientChannel;
6
6
  close: () => void;
7
7
  };
8
- /** Open a device's serial console through its jump host. Fails when another session holds the port. */
8
+ /** Open a device's serial console, through its jump host or on this computer. Fails when another session holds the port. */
9
9
  export declare function openConsole(console: Console): Promise<ConsoleSession>;
10
+ /** A device's console and its login, for `--console`. */
11
+ export declare function consoleOf(device: Device): {
12
+ console: Console;
13
+ user: string;
14
+ password: string;
15
+ };
16
+ /** How a platform's console asks for a login and shows its prompt, and what moves it on from anywhere else. */
17
+ export type ConsoleDialect = {
18
+ /** The login prompt. */
19
+ login: RegExp;
20
+ /** The password prompt. */
21
+ password: RegExp;
22
+ /** The CLI's prompt once logged in. */
23
+ prompt: RegExp;
24
+ /** What logs out from the prompt. */
25
+ logout: string;
26
+ /** Other places a console can be left, each with what moves it toward a login prompt: a pager, a config level, a question. Checked before the prompt. */
27
+ others?: readonly (readonly [RegExp, string])[];
28
+ /** The name typed at the login prompt, when the platform adds to it. */
29
+ user?: (user: string) => string;
30
+ };
31
+ /** The channel a console speaks on: an SSH stream from the relay, or anything shaped like one. */
32
+ type Channel = Pick<ClientChannel, "write"> & {
33
+ on(event: "data", listener: (chunk: Buffer) => void): unknown;
34
+ removeListener(event: "data", listener: (chunk: Buffer) => void): unknown;
35
+ };
36
+ /** Write, then wait until what the console printed since ends with one of the patterns. Returns which, and what was printed. */
37
+ export declare function expect(channel: Channel, write: string, patterns: readonly RegExp[], timeout?: number): Promise<{
38
+ index: number;
39
+ text: string;
40
+ }>;
41
+ /**
42
+ * Log in on a console, whatever state the last session left it in: what `others` names is moved on, a session left open is logged out, so every session starts from a fresh login. Returns once the CLI's prompt shows.
43
+ */
44
+ export declare function consoleLogin(channel: Channel, dialect: ConsoleDialect, user: string, password: string): Promise<void>;
45
+ /** The text after a command's echo. A terminal wraps a long echo where it likes, so the echo is matched character by character, line breaks it added skipped. */
46
+ export declare function afterEcho(text: string, command: string): string;
47
+ /** A CLI on a console that runs one command at a time and returns what it printed, as an SSH exec would: no echo, no prompt. */
48
+ export declare class ConsoleShell {
49
+ private readonly channel;
50
+ private readonly dialect;
51
+ constructor(channel: Channel, dialect: ConsoleDialect);
52
+ /** Run a command and wait for the prompt. A pager in `others` is answered as it shows. */
53
+ run(command: string, timeout?: number): Promise<string>;
54
+ /** Log out, so no session is left open on the console. */
55
+ logout(): void;
56
+ }
57
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takodotid/circuit",
3
- "version": "0.27.0",
3
+ "version": "0.28.1",
4
4
  "description": "Declarative, vendor-neutral network configuration: describe each device in TypeScript, and Circuit makes the device match. Alpha.",
5
5
  "keywords": [
6
6
  "network",
@@ -39,3 +39,4 @@ Behaviour that looks like something else. Each one has been hit on a FortiGate 3
39
39
  - `execute backup config flash <comment>` keeps a revision on the device, and `execute restore config flash <id>` restores one.
40
40
  - The ports are `port1` to `port4` at 40G and `port5` to `port32` at 10G, then `mgmt1` and `mgmt2`. The factory dedicates both management ports to management and gives them addresses `192.168.1.99` and `192.168.2.99`.
41
41
  - On the serial console a line ends at a carriage return. A session left open stays logged in, and one left inside `config` stays there, so the next login must first leave and log out. The console takes 9600 bits per second: reading the sections Circuit manages takes about two minutes.
42
+ - A data port can stay `netdev status: down`, with `rx_carrier: 0` in `diagnose hardware deviceinfo nic`, while the far end reads link-ok and frames reach the port's switch chip. Fixing the speed does not change it, in a LAG or out of one; a reboot does. `link_status: Up` there says nothing: a port with no cable shows it too.
@@ -6,7 +6,7 @@ import type { Client } from "ssh2";
6
6
  import { familyOf, host, split } from "../../../core/addr";
7
7
  import { MARKER, resolve, valueOfReference } from "../../../core/secrets";
8
8
  import type { AclRule, Device, Family, FilterRule } from "../../../schema";
9
- import { openConsole } from "../../../transport/console";
9
+ import { type ConsoleDialect, consoleLogin, consoleOf, openConsole } from "../../../transport/console";
10
10
  import { connect, shell } from "../../../transport/ssh";
11
11
  import { portsOf, spell } from "../catalog";
12
12
  import type { ApplyOptions, DeviceAdapter, Plan, ReadOptions } from "../types";
@@ -148,74 +148,18 @@ export async function login(device: Device): Promise<Client> {
148
148
  });
149
149
  }
150
150
 
151
- /** What a console shows when it waits for something, in the order they are checked. */
152
- const CONSOLE_STATES = [
153
- ["login", /login: $/],
154
- ["password", /Password: $/],
155
- ["banner", BANNER],
156
- ["pager", /--More-- $/],
157
- ["context", /\([^)\n]*\) [#$] $/],
158
- ["prompt", /[#$] $/],
159
- ] as const;
160
-
161
- type ConsoleState = (typeof CONSOLE_STATES)[number][0];
162
-
163
- /** Write to a console, then wait until it shows one of its states. */
164
- function prompted(channel: Channel, write: string, timeout = 30_000): Promise<ConsoleState> {
165
- return new Promise((done, fail) => {
166
- let text = "";
167
-
168
- const stop = () => {
169
- clearTimeout(limit);
170
- channel.removeListener("data", onData);
171
- };
172
-
173
- const onData = (chunk: Buffer) => {
174
- text += chunk.toString("utf8").replace(/\r/g, "");
175
-
176
- const state = CONSOLE_STATES.find(([, pattern]) => pattern.test(text));
177
- if (!state) return;
178
-
179
- stop();
180
- done(state[0]);
181
- };
182
-
183
- const limit = setTimeout(() => {
184
- stop();
185
- fail(new Error(`the console did not answer: ${text.slice(-200)}`));
186
- }, timeout);
187
-
188
- channel.on("data", onData);
189
- channel.write(write);
190
- });
191
- }
192
-
193
- /**
194
- * Log in on a serial console, whatever state the last session left it in: a pager is quit, a `config` level left, a session someone left open logged out, so every session starts from a fresh login. The CLI ends a line at a carriage return.
195
- */
196
- export async function consoleLogin(channel: Channel, user: string, password: string): Promise<Cli> {
197
- // What to send in each state to get closer to a login prompt.
198
- const toward: Record<Exclude<ConsoleState, "login">, string> = {
199
- password: "\r",
200
- banner: "a",
201
- pager: "q",
202
- context: "end\r",
203
- prompt: "exit\r",
204
- };
205
-
206
- let state = await prompted(channel, "\r");
207
- for (let step = 0; state !== "login"; step++) {
208
- if (step >= 10) throw new Error(`the console never reached a login prompt; it is at ${state}`);
209
- state = await prompted(channel, toward[state]);
210
- }
211
-
212
- if ((await prompted(channel, `${user}\r`)) !== "password") throw new Error("the console did not ask for a password");
213
-
214
- const answer = await prompted(channel, `${password}\r`, 60_000);
215
- if (answer !== "prompt") throw new Error(`the console refused the login of ${user}`);
216
-
217
- return new Cli(channel, "\r");
218
- }
151
+ /** How the FortiOS console asks for a login, and what moves it on from where the last session left it. */
152
+ export const FORTIOS_CONSOLE: ConsoleDialect = {
153
+ login: /login: $/,
154
+ password: /Password: $/,
155
+ prompt: /[#$] $/,
156
+ logout: "exit",
157
+ others: [
158
+ [BANNER, "a"],
159
+ [/--More-- $/, "q"],
160
+ [/\([^)\n]*\) [#$] $/, "end\n"],
161
+ ],
162
+ };
219
163
 
220
164
  /** Log out of a console, so no session is left open on it. */
221
165
  async function consoleLogout(cli: Cli): Promise<void> {
@@ -223,27 +167,15 @@ async function consoleLogout(cli: Cli): Promise<void> {
223
167
  await cli.run("exit", 10_000).catch(() => undefined);
224
168
  }
225
169
 
226
- /** The device's console and its login. */
227
- function consoleOf(device: Device) {
228
- const console = device.connection.console;
229
- if (!console) throw new Error(`${device.name}: no connection.console`);
230
-
231
- const user = device.users?.[device.connection.user];
232
- if (!user) throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
233
-
234
- return { console, user: device.connection.user as string, password: resolve(user.password) };
235
- }
236
-
237
170
  /** Log in on the console, open the CLI, and run `work` in it. `work` is also given a way to log in afresh on the same console. */
238
171
  async function consoleSession<T>(device: Device, work: (cli: Cli, relogin: () => Promise<void>) => Promise<T>): Promise<T> {
239
172
  const { console, user, password } = consoleOf(device);
240
173
  const opened = await openConsole(console);
241
174
 
242
175
  try {
243
- const cli = await consoleLogin(opened.channel, user, password);
244
- const relogin = async () => {
245
- await consoleLogin(opened.channel, user, password);
246
- };
176
+ await consoleLogin(opened.channel, FORTIOS_CONSOLE, user, password);
177
+ const cli = new Cli(opened.channel);
178
+ const relogin = () => consoleLogin(opened.channel, FORTIOS_CONSOLE, user, password);
247
179
 
248
180
  const result = await work(cli, relogin);
249
181
  await consoleLogout(cli);
@@ -29,3 +29,6 @@ Behaviour that looks like something else. Each one has been hit on a QFX5100-48S
29
29
  - IPv4 routes over an IPv6 session need a local IPv4 address under the family: `Local IPv4 address must be configured`.
30
30
  - A DHCP server and a DHCP relay cannot share an interface: `Interface irb.10 already configured`.
31
31
  - A port's MTU counts the Ethernet header. `mtu 9018` carries 9000-byte packets with a VLAN tag, and a VLAN interface's 9000 then commits without a warning.
32
+ - The serial console has no flow control. Statements pasted into `load set terminal` at once overrun it: some arrive garbled, the load reports syntax errors, and the `^D` that ends it can be lost, leaving the console in the load. Circuit sends each statement after the echo of the one before.
33
+ - After a login the console prints `Last login: ...`, which ends in `login: ` like the login prompt; only a `login: ` on a line of its own is the prompt.
34
+ - A factory-default switch runs Auto Image Upgrade, which prints on the console every few seconds until `delete chassis auto-image-upgrade` is committed.
@@ -6,9 +6,10 @@ import type { Client, ClientChannel } from "ssh2";
6
6
  import { familyOf, host, isAddress, split } 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, openConsole } from "../../../transport/console";
9
10
  import { connect, exec, readUntil, shell } from "../../../transport/ssh";
10
11
  import { lagIds } from "../derive";
11
- import type { ApplyOptions, DeviceAdapter, Plan, Step } from "../types";
12
+ import type { ApplyOptions, DeviceAdapter, Plan, ReadOptions, Step } from "../types";
12
13
  import { GENERATED, settingsOf } from "./bgp";
13
14
  import { KEY_STATEMENT, Names, render, stormProfile, vrrpAddress } from "./render";
14
15
 
@@ -303,8 +304,8 @@ export async function login(device: Device): Promise<Client> {
303
304
  }
304
305
 
305
306
  /** 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:");
307
+ async function checkVersion(ask: (command: string) => Promise<string>, device: Device): Promise<void> {
308
+ const version = await ask("show version | match Junos:");
308
309
  const release = /Junos: (\d+)\.(\S+)/.exec(version);
309
310
 
310
311
  if (!release) throw new Error(`${device.name} did not say which Junos it runs: ${version.slice(0, 200)}`);
@@ -313,11 +314,21 @@ async function checkVersion(client: Client, device: Device): Promise<void> {
313
314
  }
314
315
  }
315
316
 
316
- export async function read(device: Device): Promise<string> {
317
+ export async function read(device: Device, _network: readonly Device[] = [], options: ReadOptions = {}): Promise<string> {
318
+ if (options.console) {
319
+ return onConsole(device, async (channel) => {
320
+ await checkVersion((command) => run(channel, command), device);
321
+ const output = await run(channel, "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
+ });
326
+ }
327
+
317
328
  const client = await login(device);
318
329
 
319
330
  try {
320
- await checkVersion(client, device);
331
+ await checkVersion((command) => exec(client, command), device);
321
332
  const output = await exec(client, "show configuration | display set | no-more", 180_000);
322
333
  if (!/^set /m.test(output)) throw new Error(`${device.name} returned no configuration`);
323
334
 
@@ -347,6 +358,39 @@ async function edit(channel: ClientChannel, line: string, timeout = 60_000): Pro
347
358
  return output.replace(/\r/g, "");
348
359
  }
349
360
 
361
+ /** How the console asks for a login. Root lands in a shell, `%`, every other user in the CLI, `>`; a session left in configuration mode, `#`, leaves it first. Not yet proved on a device. */
362
+ export const JUNOS_CONSOLE: ConsoleDialect = {
363
+ // On a line of its own: the banner after a login says `Last login: ...`, and a chunk can end right after it.
364
+ login: /(?:^|\n)login: $/,
365
+ password: /Password:\s*$/,
366
+ prompt: /[>%] $/,
367
+ logout: "exit",
368
+ others: [[/# $/, "exit configuration-mode\n"]],
369
+ };
370
+
371
+ /** Log in on the console, open the CLI, and run `work` in it with a way to log in afresh. Logs out after. */
372
+ async function onConsole<T>(device: Device, work: (channel: ClientChannel, relogin: () => Promise<void>) => Promise<T>): Promise<T> {
373
+ const { console, user, password } = consoleOf(device);
374
+ const opened = await openConsole(console);
375
+
376
+ // The CLI is opened on a prompt asked for after the login, since the login's own prompt is already read.
377
+ const login = async () => {
378
+ await consoleLogin(opened.channel, JUNOS_CONSOLE, user, password);
379
+ const ready = openCli(opened.channel);
380
+ opened.channel.write("\n");
381
+ await ready;
382
+ };
383
+
384
+ try {
385
+ await login();
386
+ const result = await work(opened.channel, login);
387
+ opened.channel.write(`${JUNOS_CONSOLE.logout}\n`);
388
+ return result;
389
+ } finally {
390
+ opened.close();
391
+ }
392
+ }
393
+
350
394
  /** An interactive CLI with paging off. Root lands in a shell and starts the CLI from there. */
351
395
  export async function openCli(channel: ClientChannel): Promise<void> {
352
396
  const banner = await readUntil(channel, (text) => PROMPT.test(text.replace(/\r/g, "")), 20_000, 500);
@@ -391,7 +435,7 @@ export type Guard = { minutes: number; prove: () => Promise<void> };
391
435
  *
392
436
  * Deletions go before the statements that are set, so a path deleted and set again in one plan ends up set.
393
437
  */
394
- export async function configure(channel: ClientChannel, plan: Plan, guard?: Guard): Promise<void> {
438
+ export async function configure(channel: ClientChannel, plan: Plan, guard?: Guard, paced = false): Promise<void> {
395
439
  const sent = plan.steps.flatMap((step) => step.send);
396
440
 
397
441
  const entered = await run(channel, "configure exclusive");
@@ -417,7 +461,12 @@ export async function configure(channel: ClientChannel, plan: Plan, guard?: Guar
417
461
  channel.write("load set terminal\n");
418
462
  await readUntil(channel, (text) => text.includes("[Type ^D at a new line to end input]"), 20_000, 300);
419
463
 
420
- for (const line of ordered) channel.write(`${substitute(line)}\n`);
464
+ // A serial console has no flow control: statements sent at once overrun it and arrive garbled. There, each waits for the echo of the one before.
465
+ for (const line of ordered) {
466
+ const statement = substitute(line);
467
+ channel.write(`${statement}\n`);
468
+ if (paced) await readUntil(channel, (text) => text.replace(/\r/g, "").includes(statement.slice(-24)), 20_000, 50);
469
+ }
421
470
 
422
471
  const loaded = await edit(channel, "\u0004", 300_000);
423
472
  const refused = loaded.split("\n").filter((line) => REFUSED.test(line));
@@ -470,10 +519,32 @@ export async function configure(channel: ClientChannel, plan: Plan, guard?: Guar
470
519
  }
471
520
 
472
521
  export async function apply(device: Device, _network: readonly Device[], plan: Plan, options: ApplyOptions): Promise<void> {
522
+ if (options.console) {
523
+ await onConsole(device, async (channel, relogin) => {
524
+ await checkVersion((command) => run(channel, command), device);
525
+
526
+ // A fresh login on the console ends the session that holds the candidate, so the proof logs in again and takes the candidate back to confirm the commit.
527
+ await configure(
528
+ channel,
529
+ plan,
530
+ {
531
+ minutes: options.rollback,
532
+ prove: async () => {
533
+ await run(channel, "exit configuration-mode");
534
+ await relogin();
535
+ await run(channel, "configure exclusive");
536
+ },
537
+ },
538
+ true
539
+ );
540
+ });
541
+ return;
542
+ }
543
+
473
544
  const client = await login(device);
474
545
 
475
546
  try {
476
- await checkVersion(client, device);
547
+ await checkVersion((command) => exec(client, command), device);
477
548
 
478
549
  const channel = await shell(client);
479
550
  await openCli(channel);
@@ -760,7 +831,7 @@ export async function fill(device: Device, _network: readonly Device[], set: str
760
831
  const client = await login(device);
761
832
 
762
833
  try {
763
- await checkVersion(client, device);
834
+ await checkVersion((command) => exec(client, command), device);
764
835
 
765
836
  const channel = await shell(client);
766
837
  await openCli(channel);
@@ -775,6 +846,7 @@ export async function fill(device: Device, _network: readonly Device[], set: str
775
846
 
776
847
  export const junos: DeviceAdapter = {
777
848
  extension: ".conf",
849
+ console: true,
778
850
  unsupported,
779
851
  render,
780
852
  read,