@takodotid/circuit 0.27.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +527 -161
- package/dist/types/adapters/devices/fortios/index.d.ts +3 -4
- package/dist/types/adapters/devices/junos/index.d.ts +5 -2
- package/dist/types/adapters/devices/raisecom-ros/index.d.ts +10 -5
- package/dist/types/adapters/devices/routeros/index.d.ts +16 -3
- package/dist/types/adapters/devices/vrp/index.d.ts +6 -3
- package/dist/types/schema/device.d.ts +4 -4
- package/dist/types/transport/console.d.ts +50 -2
- package/package.json +1 -1
- package/src/adapters/devices/fortios/index.ts +16 -84
- package/src/adapters/devices/junos/index.ts +68 -7
- package/src/adapters/devices/raisecom-ros/index.ts +72 -16
- package/src/adapters/devices/routeros/TRAPS.md +1 -0
- package/src/adapters/devices/routeros/index.ts +105 -35
- package/src/adapters/devices/vrp/index.ts +91 -26
- package/src/schema/device.ts +4 -4
- package/src/transport/console.ts +306 -24
|
@@ -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
|
-
|
|
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
|
|
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
|
-
/**
|
|
16
|
-
export declare
|
|
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
|
|
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(
|
|
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,
|
|
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
|
|
35
|
-
/** The serial port on
|
|
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
|
@@ -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
|
-
/**
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
[
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
244
|
-
const
|
|
245
|
-
|
|
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(
|
|
307
|
-
const version = await
|
|
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
|
-
|
|
310
|
+
/** A session, and how to prove access in a fresh login: a new SSH session, or logging out and in again on the console. */
|
|
311
|
+
export type Session = { channel: ClientChannel; close: () => void; fresh: () => Promise<Session> };
|
|
310
312
|
|
|
311
|
-
/**
|
|
312
|
-
export
|
|
313
|
+
/** How the console asks for a login. Not yet proved on a device: the RAX721's console was never cabled to a jump host. */
|
|
314
|
+
export const RAISECOM_CONSOLE: ConsoleDialect = {
|
|
315
|
+
login: /login:\s*$/i,
|
|
316
|
+
password: /password:\s*$/i,
|
|
317
|
+
prompt: /[#>]\s*$/,
|
|
318
|
+
logout: "exit",
|
|
319
|
+
others: [[/\(config[^)\n]*\)#\s*$/, "end\n"]],
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
/** An interactive shell, paging off, over SSH or on the console. A session that does not answer as ROS is refused, so nothing meant for it can reach another shell. */
|
|
323
|
+
export async function open(device: Device, options: ReadOptions = {}): Promise<Session> {
|
|
324
|
+
const session = options.console ? await consoleShell(device) : await sshShell(device);
|
|
325
|
+
|
|
326
|
+
const version = await send(session.channel, "show version", 15_000, 1500);
|
|
327
|
+
if (!version.includes("ROS")) {
|
|
328
|
+
session.close();
|
|
329
|
+
throw new Error(`${device.name} did not answer as ROS`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
await send(session.channel, "terminal page-break disable", 15_000, 1000);
|
|
333
|
+
return session;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function sshShell(device: Device): Promise<Session> {
|
|
313
337
|
const user = device.users?.[device.connection.user];
|
|
314
338
|
if (!user) throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
|
|
315
339
|
|
|
@@ -327,19 +351,51 @@ export async function open(device: Device): Promise<Session> {
|
|
|
327
351
|
};
|
|
328
352
|
|
|
329
353
|
const banner = await readUntil(channel, (text) => /[#>]\s*$/.test(text.trimEnd()), 30_000);
|
|
330
|
-
|
|
331
|
-
if (!/[#>]\s*$/.test(banner.trimEnd()) || !version.includes("ROS")) {
|
|
354
|
+
if (!/[#>]\s*$/.test(banner.trimEnd())) {
|
|
332
355
|
close();
|
|
333
356
|
throw new Error(`${device.name} did not answer as ROS`);
|
|
334
357
|
}
|
|
335
358
|
|
|
336
|
-
|
|
337
|
-
|
|
359
|
+
return {
|
|
360
|
+
channel,
|
|
361
|
+
close,
|
|
362
|
+
fresh: async () => {
|
|
363
|
+
close();
|
|
364
|
+
return open(device);
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function consoleShell(device: Device): Promise<Session> {
|
|
370
|
+
const { console, user, password } = consoleOf(device);
|
|
371
|
+
const opened = await openConsole(console);
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
await consoleLogin(opened.channel, RAISECOM_CONSOLE, user, password);
|
|
375
|
+
} catch (error) {
|
|
376
|
+
opened.close();
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const session: Session = {
|
|
381
|
+
channel: opened.channel,
|
|
382
|
+
close: () => {
|
|
383
|
+
opened.channel.write(`${RAISECOM_CONSOLE.logout}\n`);
|
|
384
|
+
opened.close();
|
|
385
|
+
},
|
|
386
|
+
fresh: async () => {
|
|
387
|
+
await consoleLogin(opened.channel, RAISECOM_CONSOLE, user, password);
|
|
388
|
+
await send(opened.channel, "terminal page-break disable", 15_000, 1000);
|
|
389
|
+
return session;
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
return session;
|
|
338
394
|
}
|
|
339
395
|
|
|
340
396
|
/** What the device runs, credentials scrubbed. */
|
|
341
|
-
export async function read(device: Device): Promise<string> {
|
|
342
|
-
const { channel, close } = await open(device);
|
|
397
|
+
export async function read(device: Device, _network: readonly Device[] = [], options: ReadOptions = {}): Promise<string> {
|
|
398
|
+
const { channel, close } = await open(device, options);
|
|
343
399
|
|
|
344
400
|
try {
|
|
345
401
|
// Without paging off and a long wait for silence, the dump stops halfway with no error.
|
|
@@ -493,8 +549,9 @@ const CONFIRM = /^(portswitch|switchport trunk allowed vlan|switchport mode)/;
|
|
|
493
549
|
/** A service letting go of an access-list, so the list can change. */
|
|
494
550
|
const RELEASE = /^no (\S+ access-list \d+)$/;
|
|
495
551
|
|
|
496
|
-
export async function apply(device: Device, _network: readonly Device[], plan: Plan): Promise<void> {
|
|
497
|
-
const
|
|
552
|
+
export async function apply(device: Device, _network: readonly Device[], plan: Plan, options: ApplyOptions): Promise<void> {
|
|
553
|
+
const session = await open(device, options);
|
|
554
|
+
const { channel } = session;
|
|
498
555
|
// Bindings released for an access-list edit. A failure binds them again before anything else, so the list keeps guarding management.
|
|
499
556
|
const released: string[] = [];
|
|
500
557
|
|
|
@@ -521,12 +578,11 @@ export async function apply(device: Device, _network: readonly Device[], plan: P
|
|
|
521
578
|
await send(channel, "config", 10_000).catch(() => undefined);
|
|
522
579
|
for (const binding of released) await send(channel, binding, 10_000).catch(() => undefined);
|
|
523
580
|
if (released.length) console.log(` bound again: ${released.join(", ")}`);
|
|
581
|
+
session.close();
|
|
524
582
|
throw error;
|
|
525
|
-
} finally {
|
|
526
|
-
close();
|
|
527
583
|
}
|
|
528
584
|
|
|
529
|
-
const proof = await
|
|
585
|
+
const proof = await session.fresh().catch(() => undefined);
|
|
530
586
|
if (!proof)
|
|
531
587
|
throw new Error(
|
|
532
588
|
"applied, but a fresh login failed. Not written: a reboot restores the previous configuration. Recover from the console."
|
|
@@ -627,4 +683,4 @@ function plan(desired: string, current: string): ReturnType<DeviceAdapter["plan"
|
|
|
627
683
|
return { steps: planned, problems: [] };
|
|
628
684
|
}
|
|
629
685
|
|
|
630
|
-
export const raisecom: DeviceAdapter = { extension: ".txt", unsupported, render, read, plan, apply };
|
|
686
|
+
export const raisecom: DeviceAdapter = { extension: ".txt", console: true, unsupported, render, read, plan, apply };
|
|
@@ -41,3 +41,4 @@ Behaviour that looks like something else. Each one has been hit on a real device
|
|
|
41
41
|
- `bgp-path-len` counts every AS in the path, prepends included, and takes the same strict comparisons as `dst-len`.
|
|
42
42
|
- `:parse` checks the command, not the text inside `rule=`. A routing filter rule is only checked when it is added.
|
|
43
43
|
- `/routing/filter/filter-wizard` adds the rule it builds; it is not a dry run. `set blackhole yes` was proved this way on 7.23, in an unused chain removed right after.
|
|
44
|
+
- On the serial console, the prompt can come twice after a login. The console echoes a long command wrapped at 80 columns, and a `print` that fills the screen stops at `-- [Q quit|D dump|down]`, where `D` prints the rest.
|