@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.
@@ -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. */
@@ -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.0",
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",
@@ -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);
@@ -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,38 @@ 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
+ login: /login: $/,
364
+ password: /Password:\s*$/,
365
+ prompt: /[>%] $/,
366
+ logout: "exit",
367
+ others: [[/# $/, "exit configuration-mode\n"]],
368
+ };
369
+
370
+ /** Log in on the console, open the CLI, and run `work` in it with a way to log in afresh. Logs out after. */
371
+ async function onConsole<T>(device: Device, work: (channel: ClientChannel, relogin: () => Promise<void>) => Promise<T>): Promise<T> {
372
+ const { console, user, password } = consoleOf(device);
373
+ const opened = await openConsole(console);
374
+
375
+ // The CLI is opened on a prompt asked for after the login, since the login's own prompt is already read.
376
+ const login = async () => {
377
+ await consoleLogin(opened.channel, JUNOS_CONSOLE, user, password);
378
+ const ready = openCli(opened.channel);
379
+ opened.channel.write("\n");
380
+ await ready;
381
+ };
382
+
383
+ try {
384
+ await login();
385
+ const result = await work(opened.channel, login);
386
+ opened.channel.write(`${JUNOS_CONSOLE.logout}\n`);
387
+ return result;
388
+ } finally {
389
+ opened.close();
390
+ }
391
+ }
392
+
350
393
  /** An interactive CLI with paging off. Root lands in a shell and starts the CLI from there. */
351
394
  export async function openCli(channel: ClientChannel): Promise<void> {
352
395
  const banner = await readUntil(channel, (text) => PROMPT.test(text.replace(/\r/g, "")), 20_000, 500);
@@ -470,10 +513,27 @@ export async function configure(channel: ClientChannel, plan: Plan, guard?: Guar
470
513
  }
471
514
 
472
515
  export async function apply(device: Device, _network: readonly Device[], plan: Plan, options: ApplyOptions): Promise<void> {
516
+ if (options.console) {
517
+ await onConsole(device, async (channel, relogin) => {
518
+ await checkVersion((command) => run(channel, command), device);
519
+
520
+ // 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.
521
+ await configure(channel, plan, {
522
+ minutes: options.rollback,
523
+ prove: async () => {
524
+ await run(channel, "exit configuration-mode");
525
+ await relogin();
526
+ await run(channel, "configure exclusive");
527
+ },
528
+ });
529
+ });
530
+ return;
531
+ }
532
+
473
533
  const client = await login(device);
474
534
 
475
535
  try {
476
- await checkVersion(client, device);
536
+ await checkVersion((command) => exec(client, command), device);
477
537
 
478
538
  const channel = await shell(client);
479
539
  await openCli(channel);
@@ -760,7 +820,7 @@ export async function fill(device: Device, _network: readonly Device[], set: str
760
820
  const client = await login(device);
761
821
 
762
822
  try {
763
- await checkVersion(client, device);
823
+ await checkVersion((command) => exec(client, command), device);
764
824
 
765
825
  const channel = await shell(client);
766
826
  await openCli(channel);
@@ -775,6 +835,7 @@ export async function fill(device: Device, _network: readonly Device[], set: str
775
835
 
776
836
  export const junos: DeviceAdapter = {
777
837
  extension: ".conf",
838
+ console: true,
778
839
  unsupported,
779
840
  render,
780
841
  read,
@@ -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.