@takodotid/circuit 0.27.0 → 0.28.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +542 -166
- package/dist/types/adapters/devices/fortios/index.d.ts +3 -4
- package/dist/types/adapters/devices/junos/index.d.ts +6 -3
- 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/TRAPS.md +1 -0
- package/src/adapters/devices/fortios/index.ts +16 -84
- package/src/adapters/devices/junos/TRAPS.md +3 -0
- package/src/adapters/devices/junos/index.ts +81 -9
- 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 +319 -26
package/src/transport/console.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
// Serial consoles on a jump host
|
|
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
2
|
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
|
+
import { EventEmitter } from "node:events";
|
|
4
5
|
import { Duplex } from "node:stream";
|
|
5
6
|
import { Client, type ClientChannel } from "ssh2";
|
|
6
7
|
import { resolve } from "../core/secrets";
|
|
7
|
-
import type { Console, Jump } from "../schema";
|
|
8
|
+
import type { Console, Device, Jump } from "../schema";
|
|
8
9
|
import { exec } from "./ssh";
|
|
9
10
|
|
|
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. */
|
|
@@ -53,8 +54,37 @@ while True:
|
|
|
53
54
|
|
|
54
55
|
const RELAY_PATH = "/tmp/circuit-console.py";
|
|
55
56
|
|
|
56
|
-
/** A serial port
|
|
57
|
+
/** A serial port the relay is started with: a path under /dev, which reaches a shell, or a Windows COM port. */
|
|
57
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
|
+
`;
|
|
58
88
|
|
|
59
89
|
/** A session to a jump host, through `cloudflared access ssh` when it sits behind a Cloudflare Tunnel. */
|
|
60
90
|
function connectJump(jump: Jump): Promise<Client> {
|
|
@@ -80,12 +110,27 @@ function connectJump(jump: Jump): Promise<Client> {
|
|
|
80
110
|
/** An open console: the stream to the device's CLI, and how to let it go. */
|
|
81
111
|
export type ConsoleSession = { channel: ClientChannel; close: () => void };
|
|
82
112
|
|
|
83
|
-
/** Open a device's serial console through its jump host. Fails when another session holds the port. */
|
|
113
|
+
/** Open a device's serial console, through its jump host or on this computer. Fails when another session holds the port. */
|
|
84
114
|
export async function openConsole(console: Console): Promise<ConsoleSession> {
|
|
85
|
-
|
|
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
|
+
}
|
|
86
119
|
if (!Number.isInteger(console.baud) || console.baud <= 0) throw new Error(`console baud ${console.baud} is not a line speed`);
|
|
87
120
|
|
|
88
|
-
|
|
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);
|
|
89
134
|
|
|
90
135
|
try {
|
|
91
136
|
const encoded = Buffer.from(RELAY).toString("base64");
|
|
@@ -95,29 +140,16 @@ export async function openConsole(console: Console): Promise<ConsoleSession> {
|
|
|
95
140
|
client.exec(`python3 ${RELAY_PATH} ${console.port} ${console.baud}`, (error, stream) => (error ? fail(error) : done(stream)));
|
|
96
141
|
});
|
|
97
142
|
|
|
98
|
-
|
|
99
|
-
await new Promise<void>((done, fail) => {
|
|
100
|
-
let said = "";
|
|
101
|
-
const limit = setTimeout(() => fail(new Error(`the console relay did not start: ${said.trim()}`)), 30_000);
|
|
102
|
-
|
|
103
|
-
channel.stderr.on("data", (chunk: Buffer) => {
|
|
104
|
-
said += chunk.toString("utf8");
|
|
105
|
-
if (said.includes("ready\n")) {
|
|
106
|
-
clearTimeout(limit);
|
|
107
|
-
done();
|
|
108
|
-
}
|
|
109
|
-
});
|
|
110
|
-
channel.on("close", () => {
|
|
111
|
-
clearTimeout(limit);
|
|
112
|
-
fail(new Error(`the console relay stopped: ${said.trim() || "no reason given"}`));
|
|
113
|
-
});
|
|
114
|
-
});
|
|
143
|
+
await relayReady(channel);
|
|
115
144
|
|
|
116
145
|
return {
|
|
117
|
-
channel,
|
|
146
|
+
channel: asTerminal(channel),
|
|
147
|
+
// What was written last, such as a logout, is still on its way to the device; the relay ends once the console goes quiet.
|
|
118
148
|
close: () => {
|
|
119
|
-
channel.
|
|
120
|
-
|
|
149
|
+
void quiet(channel, 1_000).then(() => {
|
|
150
|
+
channel.end();
|
|
151
|
+
client.end();
|
|
152
|
+
});
|
|
121
153
|
},
|
|
122
154
|
};
|
|
123
155
|
} catch (error) {
|
|
@@ -125,3 +157,264 @@ export async function openConsole(console: Console): Promise<ConsoleSession> {
|
|
|
125
157
|
throw error;
|
|
126
158
|
}
|
|
127
159
|
}
|
|
160
|
+
|
|
161
|
+
/** The relay as a child process: Python on Linux and macOS, PowerShell on Windows. */
|
|
162
|
+
async function openHere(console: Console): Promise<ConsoleSession> {
|
|
163
|
+
const child =
|
|
164
|
+
process.platform === "win32"
|
|
165
|
+
? spawn("powershell", ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_RELAY], {
|
|
166
|
+
env: { ...process.env, CIRCUIT_PORT: console.port, CIRCUIT_BAUD: String(console.baud) },
|
|
167
|
+
})
|
|
168
|
+
: spawn("python3", ["-c", RELAY, console.port, String(console.baud)]);
|
|
169
|
+
|
|
170
|
+
// The child's streams, in the shape an SSH channel has.
|
|
171
|
+
const channel = Object.assign(new EventEmitter(), {
|
|
172
|
+
stderr: child.stderr,
|
|
173
|
+
write: (data: string | Buffer) => child.stdin.write(data),
|
|
174
|
+
end: () => child.stdin.end(),
|
|
175
|
+
});
|
|
176
|
+
child.stdout.on("data", (chunk: Buffer) => channel.emit("data", chunk));
|
|
177
|
+
child.on("close", () => channel.emit("close"));
|
|
178
|
+
child.on("error", (error) => channel.emit("close", error));
|
|
179
|
+
|
|
180
|
+
const asChannel = channel as unknown as ClientChannel;
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
await relayReady(asChannel);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
child.kill();
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
channel: asTerminal(asChannel),
|
|
191
|
+
close: () => {
|
|
192
|
+
void quiet(asChannel, 1_000).then(() => {
|
|
193
|
+
channel.end();
|
|
194
|
+
setTimeout(() => child.kill(), 2_000).unref();
|
|
195
|
+
});
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Wait until the relay says it holds the port, or why it cannot. */
|
|
201
|
+
function relayReady(channel: ClientChannel): Promise<void> {
|
|
202
|
+
return new Promise((done, fail) => {
|
|
203
|
+
let said = "";
|
|
204
|
+
const limit = setTimeout(() => fail(new Error(`the console relay did not start: ${said.trim()}`)), 30_000);
|
|
205
|
+
|
|
206
|
+
channel.stderr.on("data", (chunk: Buffer) => {
|
|
207
|
+
said += chunk.toString("utf8");
|
|
208
|
+
if (said.includes("ready")) {
|
|
209
|
+
clearTimeout(limit);
|
|
210
|
+
done();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
channel.on("close", () => {
|
|
214
|
+
clearTimeout(limit);
|
|
215
|
+
fail(new Error(`the console relay stopped: ${said.trim() || "no reason given"}`));
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 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. */
|
|
221
|
+
function asTerminal(channel: ClientChannel): ClientChannel {
|
|
222
|
+
const write = channel.write.bind(channel);
|
|
223
|
+
channel.write = ((data: unknown, ...rest: unknown[]) =>
|
|
224
|
+
write(typeof data === "string" ? data.replace(/\r?\n/g, "\r") : data, ...(rest as []))) as typeof channel.write;
|
|
225
|
+
return channel;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** A device's console and its login, for `--console`. */
|
|
229
|
+
export function consoleOf(device: Device) {
|
|
230
|
+
const console = device.connection.console;
|
|
231
|
+
if (!console) throw new Error(`${device.name}: no connection.console`);
|
|
232
|
+
|
|
233
|
+
const user = device.users?.[device.connection.user];
|
|
234
|
+
if (!user) throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
|
|
235
|
+
|
|
236
|
+
return { console, user: device.connection.user as string, password: resolve(user.password) };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** How a platform's console asks for a login and shows its prompt, and what moves it on from anywhere else. */
|
|
240
|
+
export type ConsoleDialect = {
|
|
241
|
+
/** The login prompt. */
|
|
242
|
+
login: RegExp;
|
|
243
|
+
/** The password prompt. */
|
|
244
|
+
password: RegExp;
|
|
245
|
+
/** The CLI's prompt once logged in. */
|
|
246
|
+
prompt: RegExp;
|
|
247
|
+
/** What logs out from the prompt. */
|
|
248
|
+
logout: string;
|
|
249
|
+
/** 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. */
|
|
250
|
+
others?: readonly (readonly [RegExp, string])[];
|
|
251
|
+
/** The name typed at the login prompt, when the platform adds to it. */
|
|
252
|
+
user?: (user: string) => string;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/** Terminal escape sequences: cursor moves, screen clears, character sets and modes. */
|
|
256
|
+
const ESCAPES = /\u001b(?:\[[0-9;?]*[A-Za-z]|[()][A-Z0-9]|[=>78c])/g;
|
|
257
|
+
|
|
258
|
+
/** The channel a console speaks on: an SSH stream from the relay, or anything shaped like one. */
|
|
259
|
+
type Channel = Pick<ClientChannel, "write"> & {
|
|
260
|
+
on(event: "data", listener: (chunk: Buffer) => void): unknown;
|
|
261
|
+
removeListener(event: "data", listener: (chunk: Buffer) => void): unknown;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
/** Write, then wait until what the console printed since ends with one of the patterns. Returns which, and what was printed. */
|
|
265
|
+
export function expect(channel: Channel, write: string, patterns: readonly RegExp[], timeout = 30_000) {
|
|
266
|
+
return new Promise<{ index: number; text: string }>((done, fail) => {
|
|
267
|
+
let text = "";
|
|
268
|
+
|
|
269
|
+
const stop = () => {
|
|
270
|
+
clearTimeout(limit);
|
|
271
|
+
channel.removeListener("data", onData);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const onData = (chunk: Buffer) => {
|
|
275
|
+
// A terminal's escape sequences, which clear the screen or move the cursor, say nothing a reader needs.
|
|
276
|
+
text += chunk.toString("utf8").replace(/\r/g, "").replace(ESCAPES, "");
|
|
277
|
+
|
|
278
|
+
const index = patterns.findIndex((pattern) => pattern.test(text));
|
|
279
|
+
if (index < 0) return;
|
|
280
|
+
|
|
281
|
+
stop();
|
|
282
|
+
done({ index, text });
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const limit = setTimeout(() => {
|
|
286
|
+
stop();
|
|
287
|
+
fail(new Error(`the console did not answer: ${text.slice(-200)}`));
|
|
288
|
+
}, timeout);
|
|
289
|
+
|
|
290
|
+
channel.on("data", onData);
|
|
291
|
+
if (write) channel.write(write);
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 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.
|
|
297
|
+
*/
|
|
298
|
+
export async function consoleLogin(channel: Channel, dialect: ConsoleDialect, user: string, password: string): Promise<void> {
|
|
299
|
+
const others = dialect.others ?? [];
|
|
300
|
+
const states = [dialect.login, dialect.password, ...others.map(([pattern]) => pattern), dialect.prompt];
|
|
301
|
+
|
|
302
|
+
// What to send in each state to get closer to a login prompt: Enter at a password prompt fails the login and asks again.
|
|
303
|
+
const toward = (index: number) => {
|
|
304
|
+
if (index === 1) return "\n";
|
|
305
|
+
if (index < 2 + others.length) return others[index - 2]![1];
|
|
306
|
+
return `${dialect.logout}\n`;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// What the last session printed as it left, such as a login prompt after its logout, arrives late; it must not be taken for an answer.
|
|
310
|
+
await quiet(channel, 1_000);
|
|
311
|
+
|
|
312
|
+
let state = (await expect(channel, "\n", states)).index;
|
|
313
|
+
for (let step = 0; state !== 0; step++) {
|
|
314
|
+
if (step >= 10) throw new Error("the console never reached a login prompt");
|
|
315
|
+
state = (await expect(channel, toward(state), states)).index;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// A console can print the login prompt more than once; a name typed while it does is lost.
|
|
319
|
+
await quiet(channel, 1_000);
|
|
320
|
+
|
|
321
|
+
const name = dialect.user ? dialect.user(user) : user;
|
|
322
|
+
if ((await expect(channel, `${name}\n`, [dialect.password, dialect.login, dialect.prompt])).index !== 0) {
|
|
323
|
+
throw new Error("the console did not ask for a password");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// A platform may ask something once logged in, such as whether to read a licence; `others` answers it.
|
|
327
|
+
let answer = await expect(channel, `${password}\n`, [dialect.prompt, dialect.login, ...others.map(([pattern]) => pattern)], 60_000);
|
|
328
|
+
for (let step = 0; answer.index >= 2; step++) {
|
|
329
|
+
if (step >= 5) throw new Error("the console kept asking after the login");
|
|
330
|
+
answer = await expect(channel, others[answer.index - 2]![1], [
|
|
331
|
+
dialect.prompt,
|
|
332
|
+
dialect.login,
|
|
333
|
+
...others.map(([pattern]) => pattern),
|
|
334
|
+
]);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (answer.index === 1) throw new Error(`the console refused the login of ${user}`);
|
|
338
|
+
|
|
339
|
+
// Some consoles print the prompt twice after a login; what arrives late must not reach the first command.
|
|
340
|
+
await quiet(channel, 800);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Wait until the console has printed nothing for `silence` milliseconds, dropping what it printed, at most five seconds. */
|
|
344
|
+
function quiet(channel: Channel, silence: number): Promise<void> {
|
|
345
|
+
return new Promise((done) => {
|
|
346
|
+
let timer = setTimeout(finish, silence);
|
|
347
|
+
const limit = setTimeout(finish, 5_000);
|
|
348
|
+
|
|
349
|
+
function finish() {
|
|
350
|
+
clearTimeout(timer);
|
|
351
|
+
clearTimeout(limit);
|
|
352
|
+
channel.removeListener("data", onData);
|
|
353
|
+
done();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function onData() {
|
|
357
|
+
clearTimeout(timer);
|
|
358
|
+
timer = setTimeout(finish, silence);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
channel.on("data", onData);
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** 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. */
|
|
366
|
+
export function afterEcho(text: string, command: string): string {
|
|
367
|
+
// The echo can follow a prompt that arrived late, so it is looked for wherever the command's first character is.
|
|
368
|
+
for (let start = text.indexOf(command[0] ?? ""); start >= 0; start = text.indexOf(command[0]!, start + 1)) {
|
|
369
|
+
const end = echoEnd(text, command, start);
|
|
370
|
+
if (end !== undefined) return text.slice(end).replace(/^\r?\n/, "");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return text;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Where the echo of a command that starts at `start` ends, or undefined when the text there is not its echo. */
|
|
377
|
+
function echoEnd(text: string, command: string, start: number): number | undefined {
|
|
378
|
+
let at = start;
|
|
379
|
+
|
|
380
|
+
for (const character of command) {
|
|
381
|
+
while (at < text.length && text[at] !== character && /[\r\n]/.test(text[at]!)) at++;
|
|
382
|
+
if (text[at] !== character) return undefined;
|
|
383
|
+
at++;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return at;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** 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. */
|
|
390
|
+
export class ConsoleShell {
|
|
391
|
+
constructor(
|
|
392
|
+
private readonly channel: Channel,
|
|
393
|
+
private readonly dialect: ConsoleDialect
|
|
394
|
+
) {}
|
|
395
|
+
|
|
396
|
+
/** Run a command and wait for the prompt. A pager in `others` is answered as it shows. */
|
|
397
|
+
async run(command: string, timeout = 120_000): Promise<string> {
|
|
398
|
+
const pagers = (this.dialect.others ?? []).filter(([, reply]) => !reply.endsWith("\n"));
|
|
399
|
+
const patterns = [this.dialect.prompt, ...pagers.map(([pattern]) => pattern)];
|
|
400
|
+
|
|
401
|
+
let { index, text } = await expect(this.channel, `${command}\n`, patterns, timeout);
|
|
402
|
+
let output = "";
|
|
403
|
+
|
|
404
|
+
// A pager stops the output; its answer goes on without a new line, and the pager's own text is dropped.
|
|
405
|
+
while (index > 0) {
|
|
406
|
+
const [pattern, reply] = pagers[index - 1]!;
|
|
407
|
+
output += text.replace(pattern, "");
|
|
408
|
+
({ index, text } = await expect(this.channel, reply, patterns, timeout));
|
|
409
|
+
}
|
|
410
|
+
output += text;
|
|
411
|
+
|
|
412
|
+
const lines = afterEcho(output, command).split("\n");
|
|
413
|
+
return lines.slice(0, -1).join("\n");
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Log out, so no session is left open on the console. */
|
|
417
|
+
logout(): void {
|
|
418
|
+
this.channel.write(`${this.dialect.logout}\n`);
|
|
419
|
+
}
|
|
420
|
+
}
|