@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,10 +1,11 @@
1
- // Serial consoles on a jump host. Circuit logs in to the jump host over SSH and starts a small relay there, which joins the serial port to the session. The device's CLI then streams through it as through an SSH shell.
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 path the relay is started with, which reaches the jump host's shell. */
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
- if (!PORT.test(console.port)) throw new Error(`console port ${console.port} is not a path under /dev`);
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
- const client = await connectJump(console.jump);
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,26 +140,10 @@ 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
- // The relay says it is ready once it holds the port, or why it cannot.
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),
118
147
  close: () => {
119
148
  channel.end();
120
149
  client.end();
@@ -125,3 +154,256 @@ export async function openConsole(console: Console): Promise<ConsoleSession> {
125
154
  throw error;
126
155
  }
127
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
+ }