@opentui/ssh 0.0.0-20260612-c3be6d8c

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/index.js ADDED
@@ -0,0 +1,988 @@
1
+ // @bun
2
+ // src/errors.ts
3
+ class SshError extends Error {
4
+ code;
5
+ constructor(code, message) {
6
+ super(`@opentui/ssh: ${message}`);
7
+ this.name = new.target.name;
8
+ this.code = code;
9
+ }
10
+ }
11
+
12
+ class ConfigError extends SshError {
13
+ constructor(message) {
14
+ super("CONFIG", message);
15
+ }
16
+ }
17
+
18
+ class DenyError extends Error {
19
+ reason;
20
+ constructor(reason) {
21
+ super(reason ?? "session denied");
22
+ this.name = "DenyError";
23
+ this.reason = reason;
24
+ }
25
+ }
26
+ var isDeny = (err) => err instanceof DenyError;
27
+ // src/logging.ts
28
+ var formatAddress = (address) => address.port != null ? `${address.address}:${address.port}` : address.address;
29
+ var formatDuration = (ms) => ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
30
+ var escapeControls = (value) => value.replace(/[\u0000-\u001f\u007f-\u009f]/g, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
31
+ function formatLogEvent(event) {
32
+ const who = `${escapeControls(event.identity.username)}@${formatAddress(event.remoteAddress)}`;
33
+ if (event.type === "connect") {
34
+ const method = event.identity.method === "publickey" ? `publickey ${event.identity.fingerprint}` : event.identity.method;
35
+ return `connect ${who} ${method} ${escapeControls(event.term)} ${event.cols}\xD7${event.rows}`;
36
+ }
37
+ return `disconnect ${who} ${formatDuration(event.durationMs)}`;
38
+ }
39
+ function logging(options = {}) {
40
+ const sink = options.log ?? ((event) => console.log(formatLogEvent(event)));
41
+ const emit = (event) => {
42
+ try {
43
+ Promise.resolve(sink(event)).catch(() => {});
44
+ } catch {}
45
+ };
46
+ return async (session, next) => {
47
+ const start = Date.now();
48
+ emit({
49
+ type: "connect",
50
+ identity: session.identity,
51
+ remoteAddress: session.remoteAddress,
52
+ term: session.term,
53
+ cols: session.cols,
54
+ rows: session.rows
55
+ });
56
+ try {
57
+ return await next();
58
+ } finally {
59
+ emit({
60
+ type: "disconnect",
61
+ identity: session.identity,
62
+ remoteAddress: session.remoteAddress,
63
+ term: session.term,
64
+ cols: session.cols,
65
+ rows: session.rows,
66
+ durationMs: Date.now() - start
67
+ });
68
+ }
69
+ };
70
+ }
71
+ // src/server.ts
72
+ import ssh22 from "ssh2";
73
+
74
+ // src/keys.ts
75
+ import { createHash, randomUUID } from "crypto";
76
+ import { existsSync, linkSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
77
+ import { dirname } from "path";
78
+ import ssh2 from "ssh2";
79
+ var { utils } = ssh2;
80
+ var HOST_KEYGEN_ATTEMPTS = 20;
81
+ var isKeyInput = (value) => typeof value === "string" || Buffer.isBuffer(value);
82
+ function sha256Fingerprint(publicKeyBlob) {
83
+ const digest = createHash("sha256").update(publicKeyBlob).digest("base64");
84
+ return `SHA256:${digest.replace(/=+$/, "")}`;
85
+ }
86
+ function parseOneKey(input) {
87
+ const parsed = utils.parseKey(input);
88
+ if (parsed instanceof Error)
89
+ return null;
90
+ return Array.isArray(parsed) ? parsed[0] : parsed;
91
+ }
92
+ function generateParseableHostKey() {
93
+ for (let i = 0;i < HOST_KEYGEN_ATTEMPTS; i++) {
94
+ const pair = utils.generateKeyPairSync("ed25519");
95
+ if (parseOneKey(pair.private))
96
+ return pair.private;
97
+ }
98
+ throw new ConfigError("could not generate a parseable ed25519 host key");
99
+ }
100
+ function resolveHostKey(config) {
101
+ const hostKey = config.hostKey;
102
+ let hostKeyPems;
103
+ let source;
104
+ if (hostKey === undefined) {
105
+ hostKeyPems = [generateParseableHostKey()];
106
+ source = "ephemeral";
107
+ } else if (!hostKey || typeof hostKey !== "object" || Array.isArray(hostKey)) {
108
+ throw new ConfigError("hostKey must contain either path or pem");
109
+ } else if ("pem" in hostKey) {
110
+ if ("path" in hostKey)
111
+ throw new ConfigError("hostKey must contain either path or pem, not both");
112
+ if (!(isKeyInput(hostKey.pem) || Array.isArray(hostKey.pem) && hostKey.pem.every(isKeyInput))) {
113
+ throw new ConfigError("hostKey.pem must be a key or array of keys");
114
+ }
115
+ hostKeyPems = Array.isArray(hostKey.pem) ? hostKey.pem : [hostKey.pem];
116
+ source = "provided";
117
+ } else if ("path" in hostKey) {
118
+ if (typeof hostKey.path !== "string" || hostKey.path.length === 0) {
119
+ throw new ConfigError("hostKey.path must be a non-empty string");
120
+ }
121
+ if (existsSync(hostKey.path)) {
122
+ hostKeyPems = [readFileSync(hostKey.path)];
123
+ source = `loaded ${hostKey.path}`;
124
+ } else {
125
+ const pem = generateParseableHostKey();
126
+ mkdirSync(dirname(hostKey.path), { recursive: true, mode: 448 });
127
+ const temporaryPath = `${hostKey.path}.${process.pid}.${randomUUID()}.tmp`;
128
+ try {
129
+ writeFileSync(temporaryPath, pem, { mode: 384, flag: "wx" });
130
+ try {
131
+ linkSync(temporaryPath, hostKey.path);
132
+ hostKeyPems = [pem];
133
+ source = `generated ${hostKey.path}`;
134
+ } catch (error) {
135
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "EEXIST")
136
+ throw error;
137
+ hostKeyPems = [readFileSync(hostKey.path)];
138
+ source = `loaded ${hostKey.path}`;
139
+ }
140
+ } finally {
141
+ try {
142
+ unlinkSync(temporaryPath);
143
+ } catch {}
144
+ }
145
+ }
146
+ } else {
147
+ throw new ConfigError("hostKey must contain either path or pem");
148
+ }
149
+ const keys = hostKeyPems.map((pem) => parseOneKey(pem));
150
+ if (keys.length === 0)
151
+ throw new ConfigError("hostKey.pem must contain at least one host key");
152
+ if (keys.some((key) => !key))
153
+ throw new ConfigError(`could not parse host key (${source})`);
154
+ return {
155
+ hostKeyPems,
156
+ fingerprints: keys.map((key) => sha256Fingerprint(key.getPublicSSH())),
157
+ algorithms: keys.map((key) => key.type),
158
+ source
159
+ };
160
+ }
161
+
162
+ // src/banner.ts
163
+ function formatBanner(info, descriptor) {
164
+ const displayHost = info.host === "0.0.0.0" || info.host === "::" ? "localhost" : info.host;
165
+ const urlHost = displayHost.includes(":") ? `[${displayHost}]` : displayHost;
166
+ const lines = [
167
+ `@opentui/ssh \u25B8 ssh://${urlHost}:${info.port}`,
168
+ ...info.fingerprints.map((fingerprint, index) => `host key ${fingerprint} (${descriptor.algorithms[index]}, ${descriptor.source})`),
169
+ `auth ${descriptor.methods.join(", ")}`
170
+ ];
171
+ if (descriptor.authorizedKeys?.size) {
172
+ const fps = [...descriptor.authorizedKeys].slice(0, 3).map((b64) => sha256Fingerprint(Buffer.from(b64, "base64")));
173
+ const more = descriptor.authorizedKeys.size > fps.length ? " \u2026" : "";
174
+ lines.push(`authorized ${descriptor.authorizedKeys.size} keys \xB7 ${fps.join(" ")}${more}`);
175
+ }
176
+ return lines;
177
+ }
178
+
179
+ // src/bridge.ts
180
+ import { Readable, Writable } from "stream";
181
+ import { CliRenderEvents, createCliRenderer } from "@opentui/core";
182
+
183
+ // src/safe.ts
184
+ function createSafeInvoke(onError) {
185
+ const report = (err) => {
186
+ try {
187
+ onError(err);
188
+ } catch {}
189
+ };
190
+ const safe = async (fn) => {
191
+ try {
192
+ await fn();
193
+ } catch (err) {
194
+ report(err);
195
+ }
196
+ };
197
+ return Object.assign(safe, { report });
198
+ }
199
+ function ignoreErrors(fn) {
200
+ try {
201
+ fn();
202
+ } catch {}
203
+ }
204
+
205
+ // src/bridge.ts
206
+ var DEFAULT_PTY = { term: "xterm-256color", cols: 80, rows: 24, hasPty: false };
207
+ var MAX_PTY = { cols: 500, rows: 200 };
208
+ var TRANSPORT_DRAIN_TIMEOUT_MS = 1000;
209
+ var UNKNOWN_REMOTE_ADDRESS = { address: "unknown" };
210
+ function clampPtyDimension(value, fallback, max) {
211
+ if (!Number.isFinite(value) || value <= 0)
212
+ return fallback;
213
+ const integer = Math.floor(value);
214
+ return integer > 0 ? Math.min(integer, max) : fallback;
215
+ }
216
+ function normalizePtyInfo(pty) {
217
+ return {
218
+ term: pty.term || DEFAULT_PTY.term,
219
+ cols: clampPtyDimension(pty.cols, DEFAULT_PTY.cols, MAX_PTY.cols),
220
+ rows: clampPtyDimension(pty.rows, DEFAULT_PTY.rows, MAX_PTY.rows),
221
+ hasPty: pty.hasPty
222
+ };
223
+ }
224
+ function createSessionStreams(channel, cols, rows, onActivity) {
225
+ let inputPaused = false;
226
+ const stdin = new Readable({
227
+ read() {
228
+ if (!inputPaused)
229
+ return;
230
+ inputPaused = false;
231
+ channel.resume();
232
+ }
233
+ });
234
+ const onData = (chunk) => {
235
+ onActivity?.();
236
+ if (!stdin.push(chunk) && !inputPaused) {
237
+ inputPaused = true;
238
+ channel.pause();
239
+ }
240
+ };
241
+ channel.on("data", onData);
242
+ let channelGone = false;
243
+ let pendingDrain = null;
244
+ const releasePending = () => {
245
+ const done = pendingDrain;
246
+ pendingDrain = null;
247
+ done?.();
248
+ };
249
+ channel.on("close", () => {
250
+ channelGone = true;
251
+ releasePending();
252
+ });
253
+ channel.on("error", () => {
254
+ channelGone = true;
255
+ releasePending();
256
+ });
257
+ const stdout = new Writable({
258
+ write(chunk, _enc, cb) {
259
+ if (channelGone)
260
+ return cb();
261
+ const bytes = Buffer.from(chunk);
262
+ if (bytes.byteLength === 0)
263
+ return cb();
264
+ const ok = channel.write(bytes);
265
+ if (ok)
266
+ return cb();
267
+ pendingDrain = cb;
268
+ channel.once("drain", releasePending);
269
+ }
270
+ });
271
+ stdout.columns = cols;
272
+ stdout.rows = rows;
273
+ return { stdin, stdout, detach: () => channel.removeListener("data", onData) };
274
+ }
275
+ function createSessionBridge(channel, options) {
276
+ const {
277
+ pty,
278
+ identity,
279
+ idleTimeoutMs,
280
+ maxTimeoutMs,
281
+ safe,
282
+ createRenderer = createCliRenderer,
283
+ remoteAddress = UNKNOWN_REMOTE_ADDRESS
284
+ } = options;
285
+ const initialPty = normalizePtyInfo(pty);
286
+ let resetIdle = () => {};
287
+ const { stdin, stdout, detach } = createSessionStreams(channel, initialPty.cols, initialPty.rows, () => resetIdle());
288
+ let renderer;
289
+ let cols = initialPty.cols;
290
+ let rows = initialPty.rows;
291
+ const context = {};
292
+ const resizeListeners = new Set;
293
+ const closeListeners = new Set;
294
+ let closed = false;
295
+ let channelClosed = false;
296
+ let stdoutFinished = false;
297
+ let pendingRawWrites = 0;
298
+ let transportCloseTimer;
299
+ let resolveTransportClosed;
300
+ const transportClosed = new Promise((resolve) => {
301
+ resolveTransportClosed = resolve;
302
+ });
303
+ let idleTimer;
304
+ let maxTimer;
305
+ const session = {
306
+ get renderer() {
307
+ if (!renderer) {
308
+ throw new Error("@opentui/ssh: session.renderer is unavailable until the handler runs \u2014 a middleware must call next() before using it");
309
+ }
310
+ return renderer;
311
+ },
312
+ identity,
313
+ context,
314
+ term: initialPty.term,
315
+ hasPty: initialPty.hasPty,
316
+ remoteAddress,
317
+ get cols() {
318
+ return cols;
319
+ },
320
+ get rows() {
321
+ return rows;
322
+ },
323
+ onResize(listener) {
324
+ if (closed)
325
+ return () => {};
326
+ resizeListeners.add(listener);
327
+ return () => resizeListeners.delete(listener);
328
+ },
329
+ onClose(listener) {
330
+ if (closed) {
331
+ safe(listener);
332
+ return () => {};
333
+ }
334
+ closeListeners.add(listener);
335
+ return () => closeListeners.delete(listener);
336
+ },
337
+ write(data) {
338
+ if (closed)
339
+ return;
340
+ pendingRawWrites++;
341
+ channel.write(data, () => {
342
+ pendingRawWrites--;
343
+ finishTransportClose();
344
+ });
345
+ },
346
+ end() {
347
+ destroy();
348
+ },
349
+ deny(reason) {
350
+ if (reason && !closed) {
351
+ session.write(/\r?\n$/.test(reason) ? reason : `${reason}\r
352
+ `);
353
+ }
354
+ destroy();
355
+ throw new DenyError(reason);
356
+ }
357
+ };
358
+ const resize = (requestedCols, requestedRows) => {
359
+ if (closed)
360
+ return;
361
+ const nextCols = clampPtyDimension(requestedCols, cols, MAX_PTY.cols);
362
+ const nextRows = clampPtyDimension(requestedRows, rows, MAX_PTY.rows);
363
+ cols = nextCols;
364
+ rows = nextRows;
365
+ stdout.columns = nextCols;
366
+ stdout.rows = nextRows;
367
+ renderer?.resize(nextCols, nextRows);
368
+ resizeListeners.forEach((listener) => safe(() => listener(nextCols, nextRows)));
369
+ };
370
+ const settleTransportClosed = () => {
371
+ if (transportCloseTimer)
372
+ clearTimeout(transportCloseTimer);
373
+ resolveTransportClosed();
374
+ };
375
+ const closeTransport = () => {
376
+ if (channelClosed)
377
+ return settleTransportClosed();
378
+ ignoreErrors(() => channel.exit(0));
379
+ ignoreErrors(() => channel.close());
380
+ settleTransportClosed();
381
+ };
382
+ const finishTransportClose = () => {
383
+ if (!closed || !stdoutFinished || pendingRawWrites > 0 || channelClosed)
384
+ return;
385
+ closeTransport();
386
+ };
387
+ const destroy = () => {
388
+ if (closed)
389
+ return transportClosed;
390
+ closed = true;
391
+ if (idleTimer)
392
+ clearTimeout(idleTimer);
393
+ if (maxTimer)
394
+ clearTimeout(maxTimer);
395
+ ignoreErrors(() => renderer?.destroy());
396
+ if (!channelClosed) {
397
+ transportCloseTimer = setTimeout(closeTransport, TRANSPORT_DRAIN_TIMEOUT_MS);
398
+ queueMicrotask(() => {
399
+ stdout.end(() => {
400
+ stdoutFinished = true;
401
+ finishTransportClose();
402
+ });
403
+ });
404
+ }
405
+ detach();
406
+ closeListeners.forEach((listener) => safe(listener));
407
+ if (channelClosed)
408
+ settleTransportClosed();
409
+ return transportClosed;
410
+ };
411
+ if (idleTimeoutMs && idleTimeoutMs > 0) {
412
+ resetIdle = () => {
413
+ if (closed)
414
+ return;
415
+ if (idleTimer)
416
+ clearTimeout(idleTimer);
417
+ idleTimer = setTimeout(destroy, idleTimeoutMs);
418
+ };
419
+ resetIdle();
420
+ }
421
+ if (maxTimeoutMs && maxTimeoutMs > 0) {
422
+ maxTimer = setTimeout(destroy, maxTimeoutMs);
423
+ }
424
+ channel.on("close", () => {
425
+ channelClosed = true;
426
+ settleTransportClosed();
427
+ destroy();
428
+ });
429
+ channel.on("error", (error) => {
430
+ channelClosed = true;
431
+ settleTransportClosed();
432
+ safe.report(error);
433
+ destroy();
434
+ });
435
+ const attachRenderer = async () => {
436
+ if (renderer)
437
+ return renderer;
438
+ if (closed)
439
+ return null;
440
+ const createdRenderer = await createRenderer({
441
+ stdin,
442
+ stdout,
443
+ width: cols,
444
+ height: rows,
445
+ exitOnCtrlC: false,
446
+ exitSignals: [],
447
+ consoleMode: "disabled",
448
+ targetFps: 30
449
+ });
450
+ if (closed) {
451
+ ignoreErrors(() => createdRenderer.destroy());
452
+ return null;
453
+ }
454
+ if (createdRenderer.width !== cols || createdRenderer.height !== rows) {
455
+ createdRenderer.resize(cols, rows);
456
+ }
457
+ createdRenderer.on(CliRenderEvents.DESTROY, destroy);
458
+ renderer = createdRenderer;
459
+ return renderer;
460
+ };
461
+ const enterApp = async (handler) => {
462
+ const ended = new Promise((resolve) => session.onClose(resolve));
463
+ if (closed)
464
+ return ended;
465
+ let attachedRenderer;
466
+ try {
467
+ attachedRenderer = await attachRenderer();
468
+ } catch (err) {
469
+ destroy();
470
+ throw err;
471
+ }
472
+ if (!attachedRenderer)
473
+ return ended;
474
+ const handlerDone = Promise.resolve().then(() => handler(session)).then(() => ({ type: "handler" }), (err) => ({ type: "handler-error", err }));
475
+ const outcome = await Promise.race([handlerDone, ended.then(() => ({ type: "ended" }))]);
476
+ if (outcome.type === "handler-error")
477
+ throw outcome.err;
478
+ if (outcome.type === "handler")
479
+ await ended;
480
+ if (outcome.type === "ended") {
481
+ handlerDone.then((late) => {
482
+ if (late.type === "handler-error")
483
+ safe.report(late.err);
484
+ });
485
+ }
486
+ };
487
+ return {
488
+ session,
489
+ get closed() {
490
+ return closed;
491
+ },
492
+ enterApp,
493
+ resize,
494
+ destroy
495
+ };
496
+ }
497
+
498
+ // src/run-session.ts
499
+ function runSession(middlewares, handler, bridge, safe) {
500
+ const session = bridge.session;
501
+ const context = session.context;
502
+ const dispatch = async (index) => {
503
+ if (index === middlewares.length)
504
+ return bridge.enterApp(handler);
505
+ const mw = middlewares[index];
506
+ let nextCalled = false;
507
+ const next = (add) => {
508
+ if (nextCalled)
509
+ throw new Error("@opentui/ssh: next() called more than once in a single middleware");
510
+ nextCalled = true;
511
+ if (add)
512
+ Object.assign(context, add);
513
+ return dispatch(index + 1);
514
+ };
515
+ await mw(session, next);
516
+ };
517
+ safe(async () => {
518
+ try {
519
+ await dispatch(0);
520
+ } catch (err) {
521
+ if (!isDeny(err))
522
+ throw err;
523
+ } finally {
524
+ session.end();
525
+ }
526
+ });
527
+ }
528
+
529
+ // src/connection.ts
530
+ var SHUTDOWN_DRAIN_TIMEOUT_MS = 1000;
531
+ var normalizeAddress = (address) => {
532
+ if (!address)
533
+ return "unknown";
534
+ return address.startsWith("::ffff:") ? address.slice("::ffff:".length) : address;
535
+ };
536
+ var toRemoteAddress = (address, port) => ({
537
+ address: normalizeAddress(address),
538
+ ...typeof port === "number" ? { port } : {}
539
+ });
540
+ function createConnectionHandler(dependencies) {
541
+ const { authenticator, middlewares, handler, safe, idleTimeoutMs, maxTimeoutMs, sessionLimits } = dependencies;
542
+ const clients = new Set;
543
+ const bridges = new Map;
544
+ let activeSessions = 0;
545
+ let acceptingSessions = false;
546
+ const onConnection = (client, info) => {
547
+ clients.add(client);
548
+ let connected = true;
549
+ let connectionSessions = 0;
550
+ let identity = { method: "none", username: "unknown" };
551
+ const remoteAddress = toRemoteAddress(info.ip, info.port);
552
+ client.on("authentication", async (ctx) => {
553
+ const outcome = await authenticator.handle(ctx);
554
+ if (!connected)
555
+ return;
556
+ if (outcome.type === "reject")
557
+ return ctx.reject(outcome.methods);
558
+ if (outcome.type === "accept")
559
+ identity = outcome.identity;
560
+ return ctx.accept();
561
+ });
562
+ client.on("ready", () => {
563
+ client.on("session", (acceptSession) => {
564
+ const sshSession = acceptSession();
565
+ let pty = DEFAULT_PTY;
566
+ let activeBridge;
567
+ sshSession.on("pty", (accept, _reject, info2) => {
568
+ pty = {
569
+ term: info2.term ?? "",
570
+ cols: info2.cols,
571
+ rows: info2.rows,
572
+ hasPty: true
573
+ };
574
+ accept?.();
575
+ });
576
+ sshSession.on("window-change", (accept, _reject, info2) => {
577
+ accept?.();
578
+ activeBridge?.resize(info2.cols, info2.rows);
579
+ });
580
+ sshSession.on("shell", (accept, reject) => {
581
+ if (!acceptingSessions || connectionSessions >= sessionLimits.perConnection || activeSessions >= sessionLimits.global) {
582
+ reject?.();
583
+ return;
584
+ }
585
+ connectionSessions++;
586
+ activeSessions++;
587
+ let released = false;
588
+ const release = () => {
589
+ if (released)
590
+ return;
591
+ released = true;
592
+ connectionSessions--;
593
+ activeSessions--;
594
+ };
595
+ let channel;
596
+ try {
597
+ channel = accept();
598
+ const shellBridge = createSessionBridge(channel, {
599
+ pty,
600
+ identity,
601
+ idleTimeoutMs,
602
+ maxTimeoutMs,
603
+ safe,
604
+ remoteAddress
605
+ });
606
+ activeBridge = shellBridge;
607
+ bridges.set(shellBridge, release);
608
+ shellBridge.session.onClose(() => {
609
+ shellBridge.destroy().finally(() => {
610
+ bridges.delete(shellBridge);
611
+ release();
612
+ });
613
+ });
614
+ runSession(middlewares, handler, shellBridge, safe);
615
+ } catch (error) {
616
+ const acceptedChannel = channel;
617
+ if (acceptedChannel)
618
+ ignoreErrors(() => acceptedChannel.close());
619
+ release();
620
+ safe.report(error);
621
+ }
622
+ });
623
+ });
624
+ });
625
+ client.on("close", () => {
626
+ connected = false;
627
+ clients.delete(client);
628
+ });
629
+ client.on("error", (err) => safe.report(err));
630
+ };
631
+ const closeAll = async () => {
632
+ acceptingSessions = false;
633
+ const draining = Promise.all([...bridges.keys()].map((bridge) => bridge.destroy()));
634
+ let timeout;
635
+ await Promise.race([
636
+ draining,
637
+ new Promise((resolve) => {
638
+ timeout = setTimeout(resolve, SHUTDOWN_DRAIN_TIMEOUT_MS);
639
+ })
640
+ ]);
641
+ if (timeout)
642
+ clearTimeout(timeout);
643
+ for (const release of bridges.values())
644
+ release();
645
+ bridges.clear();
646
+ for (const client of clients) {
647
+ ignoreErrors(() => client.end());
648
+ ignoreErrors(() => {
649
+ client._sock?.destroy?.();
650
+ });
651
+ }
652
+ clients.clear();
653
+ };
654
+ return {
655
+ onConnection,
656
+ closeAll,
657
+ setAccepting(accepting) {
658
+ acceptingSessions = accepting;
659
+ }
660
+ };
661
+ }
662
+
663
+ // src/auth.ts
664
+ import { readFileSync as readFileSync2 } from "fs";
665
+ function attemptFromAuthContext(ctx) {
666
+ switch (ctx.method) {
667
+ case "none":
668
+ return { method: "none", username: ctx.username };
669
+ case "password":
670
+ return { method: "password", username: ctx.username, password: ctx.password };
671
+ case "keyboard-interactive":
672
+ return {
673
+ method: "keyboard-interactive",
674
+ username: ctx.username,
675
+ prompt: (questions) => new Promise((resolve) => {
676
+ ctx.prompt(questions, (answers) => resolve(answers ?? []));
677
+ })
678
+ };
679
+ case "publickey":
680
+ return {
681
+ method: "publickey",
682
+ username: ctx.username,
683
+ key: { algorithm: ctx.key.algo, blob: ctx.key.data },
684
+ signature: ctx.signature,
685
+ blob: ctx.blob,
686
+ hashAlgo: ctx.hashAlgo
687
+ };
688
+ default:
689
+ return null;
690
+ }
691
+ }
692
+ function parseAttemptKey(key) {
693
+ return parseOneKey(`${key.algorithm} ${key.blob.toString("base64")}`);
694
+ }
695
+ function createAuthenticator(auth, authorizedKeys, onError = () => {}) {
696
+ const guard = async (fn) => {
697
+ try {
698
+ return await fn() === true;
699
+ } catch (err) {
700
+ onError(err);
701
+ return false;
702
+ }
703
+ };
704
+ const advertisedMethods = () => {
705
+ const methods = [];
706
+ if (auth.publicKey || authorizedKeys)
707
+ methods.push("publickey");
708
+ if (auth.password)
709
+ methods.push("password");
710
+ if (auth.keyboardInteractive)
711
+ methods.push("keyboard-interactive");
712
+ if (auth.none)
713
+ methods.push("none");
714
+ return methods;
715
+ };
716
+ const reject = () => ({ type: "reject", methods: advertisedMethods() });
717
+ const allowFn = typeof auth.publicKey === "object" ? auth.publicKey.allow : undefined;
718
+ const staticAdmits = (attempt) => auth.publicKey === "any" || authorizedKeys?.has(attempt.key.blob.toString("base64")) === true;
719
+ const authenticate = async (attempt) => {
720
+ switch (attempt.method) {
721
+ case "none":
722
+ return auth.none ? { type: "accept", identity: { method: "none", username: attempt.username } } : reject();
723
+ case "password": {
724
+ const fn = auth.password;
725
+ if (!fn)
726
+ return reject();
727
+ const ok = await guard(() => fn({ username: attempt.username, password: attempt.password }));
728
+ return ok ? { type: "accept", identity: { method: "password", username: attempt.username } } : reject();
729
+ }
730
+ case "keyboard-interactive": {
731
+ const fn = auth.keyboardInteractive;
732
+ if (!fn)
733
+ return reject();
734
+ const ok = await guard(() => fn({ username: attempt.username, prompt: attempt.prompt }));
735
+ return ok ? { type: "accept", identity: { method: "keyboard-interactive", username: attempt.username } } : reject();
736
+ }
737
+ case "publickey": {
738
+ if (!auth.publicKey && !authorizedKeys)
739
+ return reject();
740
+ if (!attempt.signature) {
741
+ if (staticAdmits(attempt) || typeof allowFn === "function")
742
+ return { type: "acceptProbe" };
743
+ return reject();
744
+ }
745
+ const signature = attempt.signature;
746
+ const fingerprint = sha256Fingerprint(attempt.key.blob);
747
+ if (!attempt.blob)
748
+ return reject();
749
+ const blob = attempt.blob;
750
+ const parsed = parseAttemptKey(attempt.key);
751
+ const verified = await guard(() => parsed?.verify(blob, signature, attempt.hashAlgo) === true);
752
+ if (!verified)
753
+ return reject();
754
+ if (!staticAdmits(attempt)) {
755
+ if (!allowFn)
756
+ return reject();
757
+ const allowed = await guard(() => allowFn({ username: attempt.username, fingerprint, publicKey: attempt.key }));
758
+ if (!allowed)
759
+ return reject();
760
+ }
761
+ return {
762
+ type: "accept",
763
+ identity: { method: "publickey", username: attempt.username, fingerprint, publicKey: attempt.key }
764
+ };
765
+ }
766
+ default: {
767
+ const _exhaustive = attempt;
768
+ return reject();
769
+ }
770
+ }
771
+ };
772
+ const handle = async (ctx) => {
773
+ const attempt = attemptFromAuthContext(ctx);
774
+ if (!attempt)
775
+ return reject();
776
+ try {
777
+ return await authenticate(attempt);
778
+ } catch (err) {
779
+ onError(err);
780
+ return reject();
781
+ }
782
+ };
783
+ return { advertisedMethods, authenticate, handle };
784
+ }
785
+ function loadAuthorizedKeys(source) {
786
+ let lines;
787
+ if (typeof source === "string") {
788
+ try {
789
+ lines = readFileSync2(source, "utf8").split(`
790
+ `);
791
+ } catch {
792
+ throw new ConfigError(`could not read authorizedKeys file: ${source}`);
793
+ }
794
+ } else {
795
+ lines = source;
796
+ }
797
+ const set = new Set;
798
+ for (const raw of lines) {
799
+ const line = raw.trim();
800
+ if (!line || line.startsWith("#"))
801
+ continue;
802
+ const key = parseOneKey(line);
803
+ if (!key)
804
+ throw new ConfigError(`invalid authorizedKeys entry: ${line.slice(0, 40)}`);
805
+ set.add(key.getPublicSSH().toString("base64"));
806
+ }
807
+ if (set.size === 0)
808
+ throw new ConfigError("authorizedKeys did not contain any public keys");
809
+ return set;
810
+ }
811
+ function resolveAuth(auth, onError) {
812
+ const isOpen = auth === undefined || auth === "open";
813
+ if (!isOpen) {
814
+ if (!auth || typeof auth !== "object" || Array.isArray(auth))
815
+ throw new ConfigError("invalid auth configuration");
816
+ if ("none" in auth)
817
+ throw new ConfigError('auth.none is invalid \u2014 use auth: "open" for no authentication');
818
+ if (auth.password !== undefined && typeof auth.password !== "function") {
819
+ throw new ConfigError("auth.password must be a function");
820
+ }
821
+ if (auth.keyboardInteractive !== undefined && typeof auth.keyboardInteractive !== "function") {
822
+ throw new ConfigError("auth.keyboardInteractive must be a function");
823
+ }
824
+ const publicKey = auth.publicKey;
825
+ if (publicKey !== undefined && publicKey !== "any") {
826
+ if (!publicKey || typeof publicKey !== "object" || Array.isArray(publicKey)) {
827
+ throw new ConfigError('auth.publicKey must be "any" or a policy object');
828
+ }
829
+ if (publicKey.allow !== undefined && typeof publicKey.allow !== "function") {
830
+ throw new ConfigError("auth.publicKey.allow must be a function");
831
+ }
832
+ const authorizedKeys2 = publicKey.authorizedKeys;
833
+ if (authorizedKeys2 !== undefined && typeof authorizedKeys2 !== "string" && !(Array.isArray(authorizedKeys2) && authorizedKeys2.every((key) => typeof key === "string"))) {
834
+ throw new ConfigError("auth.publicKey.authorizedKeys must be a path or array of public keys");
835
+ }
836
+ }
837
+ }
838
+ const authConfig = isOpen ? { none: true } : auth;
839
+ const publicKeyPolicy = typeof authConfig.publicKey === "object" ? authConfig.publicKey : undefined;
840
+ if (publicKeyPolicy && !publicKeyPolicy.authorizedKeys && typeof publicKeyPolicy.allow !== "function") {
841
+ throw new ConfigError('auth.publicKey must set "any", authorizedKeys, or allow');
842
+ }
843
+ const authorizedKeys = publicKeyPolicy?.authorizedKeys ? loadAuthorizedKeys(publicKeyPolicy.authorizedKeys) : undefined;
844
+ const authenticator = createAuthenticator(authConfig, authorizedKeys, onError);
845
+ const methods = authenticator.advertisedMethods();
846
+ if (!isOpen && methods.length === 0) {
847
+ throw new ConfigError("auth: {} configures no authentication methods \u2014 no client could connect. " + 'Set publicKey / password / keyboardInteractive, or use auth: "open" for no authentication.');
848
+ }
849
+ return { authenticator, noneOnly: isOpen, authorizedKeys };
850
+ }
851
+
852
+ // src/runtime.ts
853
+ var MAX_DURATION_MS = 24 * 60 * 60 * 1000;
854
+ var DURATION_UNITS = { ms: 1, s: 1000, m: 60000, h: 3600000 };
855
+ var DEFAULT_SESSION_LIMITS = { perConnection: 1, global: 100 };
856
+ function parseDuration(name, value) {
857
+ let ms;
858
+ if (typeof value === "number") {
859
+ ms = value;
860
+ } else {
861
+ const match = /^(\d+)\s*(ms|s|m|h)?$/.exec(value.trim());
862
+ if (!match)
863
+ throw new ConfigError(`invalid ${name}: ${value}`);
864
+ const unit = match[2];
865
+ ms = Number(match[1]) * DURATION_UNITS[unit ?? "ms"];
866
+ }
867
+ if (!Number.isSafeInteger(ms) || ms <= 0 || ms > MAX_DURATION_MS)
868
+ throw new ConfigError(`invalid ${name}: ${value}`);
869
+ return ms;
870
+ }
871
+ function parseLimit(name, value, fallback) {
872
+ const limit = value === undefined ? fallback : value;
873
+ if (!Number.isSafeInteger(limit) || limit <= 0)
874
+ throw new ConfigError(`invalid ${name}: ${limit}`);
875
+ return limit;
876
+ }
877
+ function resolveRuntime(config) {
878
+ const { hostKeyPems, fingerprints, algorithms, source } = resolveHostKey(config);
879
+ const idleTimeoutMs = config.idleTimeout != null ? parseDuration("idleTimeout", config.idleTimeout) : undefined;
880
+ const maxTimeoutMs = config.maxTimeout != null ? parseDuration("maxTimeout", config.maxTimeout) : undefined;
881
+ const sessionLimits = {
882
+ perConnection: parseLimit("limits.session.perConnection", config.limits?.session?.perConnection, DEFAULT_SESSION_LIMITS.perConnection),
883
+ global: parseLimit("limits.session.global", config.limits?.session?.global, DEFAULT_SESSION_LIMITS.global)
884
+ };
885
+ const onError = config.onError ?? ((err) => console.error(err));
886
+ const safe = createSafeInvoke(onError);
887
+ const { authenticator, noneOnly, authorizedKeys } = resolveAuth(config.auth, safe.report);
888
+ const banner = { algorithms, source, methods: authenticator.advertisedMethods(), authorizedKeys };
889
+ return {
890
+ hostKeys: hostKeyPems,
891
+ fingerprints,
892
+ authenticator,
893
+ idleTimeoutMs,
894
+ maxTimeoutMs,
895
+ sessionLimits,
896
+ safe,
897
+ noneOnly,
898
+ banner
899
+ };
900
+ }
901
+
902
+ // src/server.ts
903
+ var { Server: Ssh2Server } = ssh22;
904
+ var isLoopback = (h) => h === "127.0.0.1" || h === "::1" || h === "localhost";
905
+ function buildServer(config, middlewares, handler) {
906
+ const runtime = resolveRuntime(config);
907
+ const connectionHandler = createConnectionHandler({
908
+ authenticator: runtime.authenticator,
909
+ middlewares,
910
+ handler,
911
+ safe: runtime.safe,
912
+ idleTimeoutMs: runtime.idleTimeoutMs,
913
+ maxTimeoutMs: runtime.maxTimeoutMs,
914
+ sessionLimits: runtime.sessionLimits
915
+ });
916
+ const sshServer = new Ssh2Server({ hostKeys: runtime.hostKeys }, connectionHandler.onConnection);
917
+ let reportsServerErrors = false;
918
+ let bindingAttempts = 0;
919
+ sshServer.on("error", (err) => {
920
+ if (bindingAttempts === 0 && reportsServerErrors)
921
+ runtime.safe.report(err);
922
+ });
923
+ return {
924
+ listen(port = 2222, host = "127.0.0.1") {
925
+ return new Promise((resolve, reject) => {
926
+ if (runtime.noneOnly && !isLoopback(host)) {
927
+ console.warn(`@opentui/ssh: no authentication configured while listening on ${host}. ` + "Anyone who can reach this port, including through published container ports, tunnels, " + "or proxies, gets a session. Set `auth` to restrict access.");
928
+ }
929
+ bindingAttempts++;
930
+ const onError = (err) => {
931
+ bindingAttempts--;
932
+ reject(err);
933
+ };
934
+ sshServer.once("error", onError);
935
+ try {
936
+ sshServer.listen(port, host, () => {
937
+ bindingAttempts--;
938
+ sshServer.removeListener("error", onError);
939
+ reportsServerErrors = true;
940
+ connectionHandler.setAccepting(true);
941
+ const addressInfo = sshServer.address();
942
+ const actualPort = typeof addressInfo === "object" && addressInfo ? addressInfo.port : port;
943
+ const boundHost = typeof addressInfo === "object" && addressInfo ? addressInfo.address : host;
944
+ const info = { host: boundHost, port: actualPort, fingerprints: runtime.fingerprints };
945
+ if (config.startupBanner !== false) {
946
+ console.log(formatBanner(info, runtime.banner).join(`
947
+ `));
948
+ }
949
+ resolve(info);
950
+ });
951
+ } catch (error) {
952
+ bindingAttempts--;
953
+ sshServer.removeListener("error", onError);
954
+ reject(error);
955
+ }
956
+ });
957
+ },
958
+ async close() {
959
+ await connectionHandler.closeAll();
960
+ return new Promise((resolve) => {
961
+ sshServer.close(() => resolve());
962
+ });
963
+ }
964
+ };
965
+ }
966
+ function makeBuilder(config, middlewares) {
967
+ return {
968
+ use(mw) {
969
+ return makeBuilder(config, [...middlewares, mw]);
970
+ },
971
+ serve(handler) {
972
+ return buildServer(config, middlewares, handler);
973
+ }
974
+ };
975
+ }
976
+ function createServer(config = {}) {
977
+ return makeBuilder(config, []);
978
+ }
979
+ export {
980
+ logging,
981
+ createServer,
982
+ SshError,
983
+ DenyError,
984
+ ConfigError
985
+ };
986
+
987
+ //# debugId=AE7EAB0B30F431B864756E2164756E21
988
+ //# sourceMappingURL=index.js.map