@takodotid/circuit 0.26.2 → 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 +694 -90
- package/dist/types/adapters/devices/fortios/index.d.ts +5 -2
- package/dist/types/adapters/devices/fortios/plan.d.ts +2 -0
- 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/types.d.ts +7 -1
- package/dist/types/adapters/devices/vrp/index.d.ts +6 -3
- package/dist/types/schema/device.d.ts +25 -1
- package/dist/types/transport/console.d.ts +57 -0
- package/package.json +1 -1
- package/src/adapters/devices/fortios/TRAPS.md +2 -1
- package/src/adapters/devices/fortios/index.ts +69 -13
- package/src/adapters/devices/fortios/plan.ts +40 -1
- 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/types.ts +8 -1
- package/src/adapters/devices/vrp/index.ts +91 -26
- package/src/cli/index.ts +14 -4
- package/src/schema/device.ts +27 -1
- package/src/transport/console.ts +409 -0
package/src/schema/device.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IP } from "./common";
|
|
1
|
+
import type { IP, Secret } from "./common";
|
|
2
2
|
import type { AclRule, Firewall } from "./firewall";
|
|
3
3
|
import type { Interface, Port, Vlan } from "./interfaces";
|
|
4
4
|
import type { PrefixSet, RoutePolicy } from "./policy";
|
|
@@ -13,6 +13,32 @@ export type Connection<U extends string> = {
|
|
|
13
13
|
user: U;
|
|
14
14
|
/** SSH port. 22 when absent. */
|
|
15
15
|
port?: number;
|
|
16
|
+
/** The device's serial console, for `--console`: the way in before the device has a management address, or when its management network is down. */
|
|
17
|
+
console?: Console;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** A computer with SSH that holds the serial consoles of devices, such as a laptop beside the rack. It runs Python 3. */
|
|
21
|
+
export type Jump = {
|
|
22
|
+
/** Its address or name. */
|
|
23
|
+
host: string;
|
|
24
|
+
/** The user Circuit logs in as. It must be allowed to open the serial ports. */
|
|
25
|
+
user: string;
|
|
26
|
+
/** The user's password. */
|
|
27
|
+
password: Secret;
|
|
28
|
+
/** SSH port. 22 when absent. */
|
|
29
|
+
port?: number;
|
|
30
|
+
/** The jump host is reached through a Cloudflare Tunnel, so Circuit connects with `cloudflared access ssh`, which must be installed. */
|
|
31
|
+
cloudflared?: true;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** A serial console, on a jump host or on the computer Circuit runs on. */
|
|
35
|
+
export type Console = {
|
|
36
|
+
/** The jump host the console cable is plugged into. When absent, the cable is plugged into the computer Circuit runs on. */
|
|
37
|
+
jump?: Jump;
|
|
38
|
+
/** The serial port: on Linux or macOS a path under `/dev`, where a path under `/dev/serial/by-path/` stays the same when two adapters swap names; on Windows a COM port, such as `COM3`. */
|
|
39
|
+
port: string;
|
|
40
|
+
/** Line speed in bits per second, such as 9600 or 115200. */
|
|
41
|
+
baud: number;
|
|
16
42
|
};
|
|
17
43
|
|
|
18
44
|
/** A separate routing table, with the interfaces that route in it. */
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
// Serial consoles, on a jump host or on this computer. A small relay joins the serial port to a stream: started over SSH on the jump host, or here as a child process. The device's CLI then streams through it as through an SSH shell.
|
|
2
|
+
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { EventEmitter } from "node:events";
|
|
5
|
+
import { Duplex } from "node:stream";
|
|
6
|
+
import { Client, type ClientChannel } from "ssh2";
|
|
7
|
+
import { resolve } from "../core/secrets";
|
|
8
|
+
import type { Console, Device, Jump } from "../schema";
|
|
9
|
+
import { exec } from "./ssh";
|
|
10
|
+
|
|
11
|
+
/** The relay, in Python's standard library only: the serial port in raw mode, locked against a second session, joined to standard input and output. */
|
|
12
|
+
const RELAY = `import fcntl, os, select, sys, termios
|
|
13
|
+
|
|
14
|
+
port, baud = sys.argv[1], int(sys.argv[2])
|
|
15
|
+
fd = os.open(port, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
19
|
+
except OSError:
|
|
20
|
+
sys.stderr.write("busy: another session holds " + port + "\\n")
|
|
21
|
+
sys.exit(3)
|
|
22
|
+
|
|
23
|
+
attrs = termios.tcgetattr(fd)
|
|
24
|
+
attrs[0] = attrs[1] = attrs[3] = 0
|
|
25
|
+
attrs[2] = termios.CS8 | termios.CREAD | termios.CLOCAL
|
|
26
|
+
attrs[4] = attrs[5] = getattr(termios, "B%d" % baud)
|
|
27
|
+
termios.tcsetattr(fd, termios.TCSANOW, attrs)
|
|
28
|
+
termios.tcflush(fd, termios.TCIOFLUSH)
|
|
29
|
+
|
|
30
|
+
sys.stderr.write("ready\\n")
|
|
31
|
+
sys.stderr.flush()
|
|
32
|
+
|
|
33
|
+
while True:
|
|
34
|
+
ready, _, _ = select.select([0, fd], [], [])
|
|
35
|
+
|
|
36
|
+
if 0 in ready:
|
|
37
|
+
data = os.read(0, 4096)
|
|
38
|
+
if not data:
|
|
39
|
+
break
|
|
40
|
+
while data:
|
|
41
|
+
try:
|
|
42
|
+
data = data[os.write(fd, data):]
|
|
43
|
+
except BlockingIOError:
|
|
44
|
+
select.select([], [fd], [])
|
|
45
|
+
|
|
46
|
+
if fd in ready:
|
|
47
|
+
try:
|
|
48
|
+
data = os.read(fd, 4096)
|
|
49
|
+
except BlockingIOError:
|
|
50
|
+
continue
|
|
51
|
+
if data:
|
|
52
|
+
os.write(1, data)
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
const RELAY_PATH = "/tmp/circuit-console.py";
|
|
56
|
+
|
|
57
|
+
/** A serial port the relay is started with: a path under /dev, which reaches a shell, or a Windows COM port. */
|
|
58
|
+
const PORT = /^\/dev\/[\w./:+-]+$/;
|
|
59
|
+
const WINDOWS_PORT = /^COM\d+$/i;
|
|
60
|
+
|
|
61
|
+
/** The relay for a console on this computer under Windows, in PowerShell, which has no termios but has .NET's serial port. */
|
|
62
|
+
const WINDOWS_RELAY = `
|
|
63
|
+
$port = New-Object System.IO.Ports.SerialPort $env:CIRCUIT_PORT, ([int]$env:CIRCUIT_BAUD), 'None', 8, 'One'
|
|
64
|
+
try { $port.Open() } catch [System.UnauthorizedAccessException] { [Console]::Error.WriteLine("busy: another program holds " + $env:CIRCUIT_PORT); exit 3 } catch { [Console]::Error.WriteLine("cannot open " + $env:CIRCUIT_PORT + ": " + $_.Exception.Message); exit 2 }
|
|
65
|
+
[Console]::Error.WriteLine("ready")
|
|
66
|
+
$in = [Console]::OpenStandardInput()
|
|
67
|
+
$out = [Console]::OpenStandardOutput()
|
|
68
|
+
$buffer = New-Object byte[] 4096
|
|
69
|
+
$reading = $in.ReadAsync($buffer, 0, $buffer.Length)
|
|
70
|
+
while ($true) {
|
|
71
|
+
if ($reading.IsCompleted) {
|
|
72
|
+
if ($reading.Result -le 0) { break }
|
|
73
|
+
$port.Write($buffer, 0, $reading.Result)
|
|
74
|
+
$reading = $in.ReadAsync($buffer, 0, $buffer.Length)
|
|
75
|
+
}
|
|
76
|
+
$waiting = $port.BytesToRead
|
|
77
|
+
if ($waiting -gt 0) {
|
|
78
|
+
$data = New-Object byte[] $waiting
|
|
79
|
+
$got = $port.Read($data, 0, $waiting)
|
|
80
|
+
$out.Write($data, 0, $got)
|
|
81
|
+
$out.Flush()
|
|
82
|
+
} else {
|
|
83
|
+
Start-Sleep -Milliseconds 10
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
$port.Close()
|
|
87
|
+
`;
|
|
88
|
+
|
|
89
|
+
/** A session to a jump host, through `cloudflared access ssh` when it sits behind a Cloudflare Tunnel. */
|
|
90
|
+
function connectJump(jump: Jump): Promise<Client> {
|
|
91
|
+
return new Promise((done, fail) => {
|
|
92
|
+
const client = new Client();
|
|
93
|
+
const login = { username: jump.user, password: resolve(jump.password), readyTimeout: 60_000 };
|
|
94
|
+
|
|
95
|
+
client.on("ready", () => done(client)).on("error", fail);
|
|
96
|
+
|
|
97
|
+
if (!jump.cloudflared) {
|
|
98
|
+
client.connect({ ...login, host: jump.host, port: jump.port ?? 22 });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const tunnel = spawn("cloudflared", ["access", "ssh", "--hostname", jump.host], { stdio: ["pipe", "pipe", "ignore"] });
|
|
103
|
+
tunnel.on("error", (error) => fail(new Error(`cloudflared did not start: ${error.message}`)));
|
|
104
|
+
client.on("close", () => tunnel.kill());
|
|
105
|
+
|
|
106
|
+
client.connect({ ...login, sock: Duplex.from({ readable: tunnel.stdout, writable: tunnel.stdin }) });
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** An open console: the stream to the device's CLI, and how to let it go. */
|
|
111
|
+
export type ConsoleSession = { channel: ClientChannel; close: () => void };
|
|
112
|
+
|
|
113
|
+
/** Open a device's serial console, through its jump host or on this computer. Fails when another session holds the port. */
|
|
114
|
+
export async function openConsole(console: Console): Promise<ConsoleSession> {
|
|
115
|
+
const windows = !console.jump && process.platform === "win32";
|
|
116
|
+
if (windows ? !WINDOWS_PORT.test(console.port) : !PORT.test(console.port)) {
|
|
117
|
+
throw new Error(`console port ${console.port} is not ${windows ? "a COM port" : "a path under /dev"}`);
|
|
118
|
+
}
|
|
119
|
+
if (!Number.isInteger(console.baud) || console.baud <= 0) throw new Error(`console baud ${console.baud} is not a line speed`);
|
|
120
|
+
|
|
121
|
+
// The relay of a session that just ended can hold the port a few seconds more, until its host sees it gone.
|
|
122
|
+
for (let attempt = 1; ; attempt++) {
|
|
123
|
+
try {
|
|
124
|
+
return await (console.jump ? openOnJump(console, console.jump) : openHere(console));
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (attempt >= 5 || !/busy:/.test((error as Error).message)) throw error;
|
|
127
|
+
await new Promise((done) => setTimeout(done, 5_000));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function openOnJump(console: Console, jump: Jump): Promise<ConsoleSession> {
|
|
133
|
+
const client = await connectJump(jump);
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const encoded = Buffer.from(RELAY).toString("base64");
|
|
137
|
+
await exec(client, `sh -c 'printf %s ${encoded} | base64 -d > ${RELAY_PATH}'`, 30_000);
|
|
138
|
+
|
|
139
|
+
const channel = await new Promise<ClientChannel>((done, fail) => {
|
|
140
|
+
client.exec(`python3 ${RELAY_PATH} ${console.port} ${console.baud}`, (error, stream) => (error ? fail(error) : done(stream)));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await relayReady(channel);
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
channel: asTerminal(channel),
|
|
147
|
+
close: () => {
|
|
148
|
+
channel.end();
|
|
149
|
+
client.end();
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
} catch (error) {
|
|
153
|
+
client.end();
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The relay as a child process: Python on Linux and macOS, PowerShell on Windows. */
|
|
159
|
+
async function openHere(console: Console): Promise<ConsoleSession> {
|
|
160
|
+
const child =
|
|
161
|
+
process.platform === "win32"
|
|
162
|
+
? spawn("powershell", ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_RELAY], {
|
|
163
|
+
env: { ...process.env, CIRCUIT_PORT: console.port, CIRCUIT_BAUD: String(console.baud) },
|
|
164
|
+
})
|
|
165
|
+
: spawn("python3", ["-c", RELAY, console.port, String(console.baud)]);
|
|
166
|
+
|
|
167
|
+
// The child's streams, in the shape an SSH channel has.
|
|
168
|
+
const channel = Object.assign(new EventEmitter(), {
|
|
169
|
+
stderr: child.stderr,
|
|
170
|
+
write: (data: string | Buffer) => child.stdin.write(data),
|
|
171
|
+
end: () => child.stdin.end(),
|
|
172
|
+
});
|
|
173
|
+
child.stdout.on("data", (chunk: Buffer) => channel.emit("data", chunk));
|
|
174
|
+
child.on("close", () => channel.emit("close"));
|
|
175
|
+
child.on("error", (error) => channel.emit("close", error));
|
|
176
|
+
|
|
177
|
+
const asChannel = channel as unknown as ClientChannel;
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
await relayReady(asChannel);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
child.kill();
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
channel: asTerminal(asChannel),
|
|
188
|
+
close: () => {
|
|
189
|
+
channel.end();
|
|
190
|
+
setTimeout(() => child.kill(), 2_000).unref();
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Wait until the relay says it holds the port, or why it cannot. */
|
|
196
|
+
function relayReady(channel: ClientChannel): Promise<void> {
|
|
197
|
+
return new Promise((done, fail) => {
|
|
198
|
+
let said = "";
|
|
199
|
+
const limit = setTimeout(() => fail(new Error(`the console relay did not start: ${said.trim()}`)), 30_000);
|
|
200
|
+
|
|
201
|
+
channel.stderr.on("data", (chunk: Buffer) => {
|
|
202
|
+
said += chunk.toString("utf8");
|
|
203
|
+
if (said.includes("ready")) {
|
|
204
|
+
clearTimeout(limit);
|
|
205
|
+
done();
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
channel.on("close", () => {
|
|
209
|
+
clearTimeout(limit);
|
|
210
|
+
fail(new Error(`the console relay stopped: ${said.trim() || "no reason given"}`));
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** A terminal ends a line with a carriage return. Adapters write lines as they do over SSH, and the console gets what a keyboard would send. */
|
|
216
|
+
function asTerminal(channel: ClientChannel): ClientChannel {
|
|
217
|
+
const write = channel.write.bind(channel);
|
|
218
|
+
channel.write = ((data: unknown, ...rest: unknown[]) =>
|
|
219
|
+
write(typeof data === "string" ? data.replace(/\r?\n/g, "\r") : data, ...(rest as []))) as typeof channel.write;
|
|
220
|
+
return channel;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** A device's console and its login, for `--console`. */
|
|
224
|
+
export function consoleOf(device: Device) {
|
|
225
|
+
const console = device.connection.console;
|
|
226
|
+
if (!console) throw new Error(`${device.name}: no connection.console`);
|
|
227
|
+
|
|
228
|
+
const user = device.users?.[device.connection.user];
|
|
229
|
+
if (!user) throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
|
|
230
|
+
|
|
231
|
+
return { console, user: device.connection.user as string, password: resolve(user.password) };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** How a platform's console asks for a login and shows its prompt, and what moves it on from anywhere else. */
|
|
235
|
+
export type ConsoleDialect = {
|
|
236
|
+
/** The login prompt. */
|
|
237
|
+
login: RegExp;
|
|
238
|
+
/** The password prompt. */
|
|
239
|
+
password: RegExp;
|
|
240
|
+
/** The CLI's prompt once logged in. */
|
|
241
|
+
prompt: RegExp;
|
|
242
|
+
/** What logs out from the prompt. */
|
|
243
|
+
logout: string;
|
|
244
|
+
/** 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. */
|
|
245
|
+
others?: readonly (readonly [RegExp, string])[];
|
|
246
|
+
/** The name typed at the login prompt, when the platform adds to it. */
|
|
247
|
+
user?: (user: string) => string;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
/** Terminal escape sequences: cursor moves, screen clears, character sets and modes. */
|
|
251
|
+
const ESCAPES = /\u001b(?:\[[0-9;?]*[A-Za-z]|[()][A-Z0-9]|[=>78c])/g;
|
|
252
|
+
|
|
253
|
+
/** The channel a console speaks on: an SSH stream from the relay, or anything shaped like one. */
|
|
254
|
+
type Channel = Pick<ClientChannel, "write"> & {
|
|
255
|
+
on(event: "data", listener: (chunk: Buffer) => void): unknown;
|
|
256
|
+
removeListener(event: "data", listener: (chunk: Buffer) => void): unknown;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/** Write, then wait until what the console printed since ends with one of the patterns. Returns which, and what was printed. */
|
|
260
|
+
export function expect(channel: Channel, write: string, patterns: readonly RegExp[], timeout = 30_000) {
|
|
261
|
+
return new Promise<{ index: number; text: string }>((done, fail) => {
|
|
262
|
+
let text = "";
|
|
263
|
+
|
|
264
|
+
const stop = () => {
|
|
265
|
+
clearTimeout(limit);
|
|
266
|
+
channel.removeListener("data", onData);
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const onData = (chunk: Buffer) => {
|
|
270
|
+
// A terminal's escape sequences, which clear the screen or move the cursor, say nothing a reader needs.
|
|
271
|
+
text += chunk.toString("utf8").replace(/\r/g, "").replace(ESCAPES, "");
|
|
272
|
+
|
|
273
|
+
const index = patterns.findIndex((pattern) => pattern.test(text));
|
|
274
|
+
if (index < 0) return;
|
|
275
|
+
|
|
276
|
+
stop();
|
|
277
|
+
done({ index, text });
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const limit = setTimeout(() => {
|
|
281
|
+
stop();
|
|
282
|
+
fail(new Error(`the console did not answer: ${text.slice(-200)}`));
|
|
283
|
+
}, timeout);
|
|
284
|
+
|
|
285
|
+
channel.on("data", onData);
|
|
286
|
+
if (write) channel.write(write);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* 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.
|
|
292
|
+
*/
|
|
293
|
+
export async function consoleLogin(channel: Channel, dialect: ConsoleDialect, user: string, password: string): Promise<void> {
|
|
294
|
+
const others = dialect.others ?? [];
|
|
295
|
+
const states = [dialect.login, dialect.password, ...others.map(([pattern]) => pattern), dialect.prompt];
|
|
296
|
+
|
|
297
|
+
// What to send in each state to get closer to a login prompt: Enter at a password prompt fails the login and asks again.
|
|
298
|
+
const toward = (index: number) => {
|
|
299
|
+
if (index === 1) return "\n";
|
|
300
|
+
if (index < 2 + others.length) return others[index - 2]![1];
|
|
301
|
+
return `${dialect.logout}\n`;
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
let state = (await expect(channel, "\n", states)).index;
|
|
305
|
+
for (let step = 0; state !== 0; step++) {
|
|
306
|
+
if (step >= 10) throw new Error("the console never reached a login prompt");
|
|
307
|
+
state = (await expect(channel, toward(state), states)).index;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const name = dialect.user ? dialect.user(user) : user;
|
|
311
|
+
if ((await expect(channel, `${name}\n`, [dialect.password, dialect.login, dialect.prompt])).index !== 0) {
|
|
312
|
+
throw new Error("the console did not ask for a password");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// A platform may ask something once logged in, such as whether to read a licence; `others` answers it.
|
|
316
|
+
let answer = await expect(channel, `${password}\n`, [dialect.prompt, dialect.login, ...others.map(([pattern]) => pattern)], 60_000);
|
|
317
|
+
for (let step = 0; answer.index >= 2; step++) {
|
|
318
|
+
if (step >= 5) throw new Error("the console kept asking after the login");
|
|
319
|
+
answer = await expect(channel, others[answer.index - 2]![1], [
|
|
320
|
+
dialect.prompt,
|
|
321
|
+
dialect.login,
|
|
322
|
+
...others.map(([pattern]) => pattern),
|
|
323
|
+
]);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (answer.index === 1) throw new Error(`the console refused the login of ${user}`);
|
|
327
|
+
|
|
328
|
+
// Some consoles print the prompt twice after a login; what arrives late must not reach the first command.
|
|
329
|
+
await quiet(channel, 800);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Wait until the console has printed nothing for `silence` milliseconds, dropping what it printed, at most five seconds. */
|
|
333
|
+
function quiet(channel: Channel, silence: number): Promise<void> {
|
|
334
|
+
return new Promise((done) => {
|
|
335
|
+
let timer = setTimeout(finish, silence);
|
|
336
|
+
const limit = setTimeout(finish, 5_000);
|
|
337
|
+
|
|
338
|
+
function finish() {
|
|
339
|
+
clearTimeout(timer);
|
|
340
|
+
clearTimeout(limit);
|
|
341
|
+
channel.removeListener("data", onData);
|
|
342
|
+
done();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function onData() {
|
|
346
|
+
clearTimeout(timer);
|
|
347
|
+
timer = setTimeout(finish, silence);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
channel.on("data", onData);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** 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. */
|
|
355
|
+
export function afterEcho(text: string, command: string): string {
|
|
356
|
+
// The echo can follow a prompt that arrived late, so it is looked for wherever the command's first character is.
|
|
357
|
+
for (let start = text.indexOf(command[0] ?? ""); start >= 0; start = text.indexOf(command[0]!, start + 1)) {
|
|
358
|
+
const end = echoEnd(text, command, start);
|
|
359
|
+
if (end !== undefined) return text.slice(end).replace(/^\r?\n/, "");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return text;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Where the echo of a command that starts at `start` ends, or undefined when the text there is not its echo. */
|
|
366
|
+
function echoEnd(text: string, command: string, start: number): number | undefined {
|
|
367
|
+
let at = start;
|
|
368
|
+
|
|
369
|
+
for (const character of command) {
|
|
370
|
+
while (at < text.length && text[at] !== character && /[\r\n]/.test(text[at]!)) at++;
|
|
371
|
+
if (text[at] !== character) return undefined;
|
|
372
|
+
at++;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return at;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** 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. */
|
|
379
|
+
export class ConsoleShell {
|
|
380
|
+
constructor(
|
|
381
|
+
private readonly channel: Channel,
|
|
382
|
+
private readonly dialect: ConsoleDialect
|
|
383
|
+
) {}
|
|
384
|
+
|
|
385
|
+
/** Run a command and wait for the prompt. A pager in `others` is answered as it shows. */
|
|
386
|
+
async run(command: string, timeout = 120_000): Promise<string> {
|
|
387
|
+
const pagers = (this.dialect.others ?? []).filter(([, reply]) => !reply.endsWith("\n"));
|
|
388
|
+
const patterns = [this.dialect.prompt, ...pagers.map(([pattern]) => pattern)];
|
|
389
|
+
|
|
390
|
+
let { index, text } = await expect(this.channel, `${command}\n`, patterns, timeout);
|
|
391
|
+
let output = "";
|
|
392
|
+
|
|
393
|
+
// A pager stops the output; its answer goes on without a new line, and the pager's own text is dropped.
|
|
394
|
+
while (index > 0) {
|
|
395
|
+
const [pattern, reply] = pagers[index - 1]!;
|
|
396
|
+
output += text.replace(pattern, "");
|
|
397
|
+
({ index, text } = await expect(this.channel, reply, patterns, timeout));
|
|
398
|
+
}
|
|
399
|
+
output += text;
|
|
400
|
+
|
|
401
|
+
const lines = afterEcho(output, command).split("\n");
|
|
402
|
+
return lines.slice(0, -1).join("\n");
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Log out, so no session is left open on the console. */
|
|
406
|
+
logout(): void {
|
|
407
|
+
this.channel.write(`${this.dialect.logout}\n`);
|
|
408
|
+
}
|
|
409
|
+
}
|