@linxin666/dsh-ssh 0.3.4 → 0.3.6
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/lib/client.js +109 -9
- package/lib/client.js.map +1 -1
- package/lib/index.js +58 -10
- package/lib/types/client/api.d.ts +7 -0
- package/lib/types/client/locales.d.ts +5 -0
- package/lib/types/engine/connection-pool.d.ts +7 -2
- package/lib/types/engine/pty.d.ts +2 -2
- package/lib/types/engine.d.ts +3 -3
- package/lib/types/protocol.d.ts +11 -0
- package/package.json +1 -1
- package/src/client/api.ts +11 -0
- package/src/client/locales.ts +10 -0
- package/src/client/panel/TerminalTab.tsx +61 -1
- package/src/engine/connection-pool.ts +30 -4
- package/src/engine/pty.ts +8 -3
- package/src/engine.ts +8 -3
- package/src/protocol.ts +7 -0
- package/src/routes.ts +33 -2
package/lib/index.js
CHANGED
|
@@ -1408,7 +1408,8 @@ function buildConnectConfig(entry, sock, opts) {
|
|
|
1408
1408
|
username: entry.user,
|
|
1409
1409
|
readyTimeout: opts.connectTimeoutMs,
|
|
1410
1410
|
keepaliveInterval: opts.keepaliveIntervalMs,
|
|
1411
|
-
keepaliveCountMax: 3
|
|
1411
|
+
keepaliveCountMax: 3,
|
|
1412
|
+
tryKeyboard: true
|
|
1412
1413
|
};
|
|
1413
1414
|
if (sock !== void 0) config.sock = sock;
|
|
1414
1415
|
if (entry.auth.kind === "password") config.password = entry.auth.password;
|
|
@@ -1433,7 +1434,7 @@ function resolveAgentPath(agentPath) {
|
|
|
1433
1434
|
if (process.platform === "win32") return "pageant";
|
|
1434
1435
|
}
|
|
1435
1436
|
/** Connect one ssh2 client (resolve on ready, reject on error/close). */
|
|
1436
|
-
function connectClient(config) {
|
|
1437
|
+
function connectClient(config, onKeyboardInteractive) {
|
|
1437
1438
|
return new Promise((resolve, reject) => {
|
|
1438
1439
|
const client = new Client();
|
|
1439
1440
|
let settled = false;
|
|
@@ -1450,6 +1451,20 @@ function connectClient(config) {
|
|
|
1450
1451
|
settled = true;
|
|
1451
1452
|
resolve(client);
|
|
1452
1453
|
});
|
|
1454
|
+
client.on("keyboard-interactive", (name, instructions, instructionsLang, prompts, finish) => {
|
|
1455
|
+
if (onKeyboardInteractive !== void 0) {
|
|
1456
|
+
onKeyboardInteractive(name, instructions, instructionsLang, prompts.map((p) => ({
|
|
1457
|
+
prompt: p.prompt,
|
|
1458
|
+
echo: Boolean(p.echo)
|
|
1459
|
+
})), finish);
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
if (config.password !== void 0 && prompts.length > 0 && prompts.every((p) => /password/i.test(p.prompt))) {
|
|
1463
|
+
finish(prompts.map(() => config.password));
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
fail(/* @__PURE__ */ new Error("Authentication failed (keyboard-interactive): " + (prompts.map((p) => p.prompt.trim()).join(", ") || "unsupported interactive challenge")));
|
|
1467
|
+
});
|
|
1453
1468
|
client.on("error", fail);
|
|
1454
1469
|
try {
|
|
1455
1470
|
client.connect(config);
|
|
@@ -1475,7 +1490,7 @@ function appendOutput(target, chunk, maxBytes) {
|
|
|
1475
1490
|
* order, each forwarding a stream to the next destination, ending with the
|
|
1476
1491
|
* target client. Shared by the pool and standalone shell sessions.
|
|
1477
1492
|
*/
|
|
1478
|
-
async function connectChain(engine, entry) {
|
|
1493
|
+
async function connectChain(engine, entry, onKeyboardInteractive) {
|
|
1479
1494
|
const hops = [];
|
|
1480
1495
|
let sock;
|
|
1481
1496
|
const chain = entry.proxyJump;
|
|
@@ -1484,7 +1499,7 @@ async function connectChain(engine, entry) {
|
|
|
1484
1499
|
const hopAlias = chain[index];
|
|
1485
1500
|
const hop = engine.store.find(hopAlias);
|
|
1486
1501
|
if (hop === void 0) throw new Error("proxyJump alias '" + hopAlias + "' not found — create it first");
|
|
1487
|
-
const hopClient = await connectClient(buildConnectConfig(hop, sock, engine.opts));
|
|
1502
|
+
const hopClient = await connectClient(buildConnectConfig(hop, sock, engine.opts), onKeyboardInteractive);
|
|
1488
1503
|
hops.push(hopClient);
|
|
1489
1504
|
const next = index + 1 < chain.length ? engine.store.find(chain[index + 1]) : void 0;
|
|
1490
1505
|
const nextHost = next !== void 0 ? next.host : entry.host;
|
|
@@ -1502,7 +1517,7 @@ async function connectChain(engine, entry) {
|
|
|
1502
1517
|
}
|
|
1503
1518
|
let target;
|
|
1504
1519
|
try {
|
|
1505
|
-
target = await connectClient(buildConnectConfig(entry, sock, engine.opts));
|
|
1520
|
+
target = await connectClient(buildConnectConfig(entry, sock, engine.opts), onKeyboardInteractive);
|
|
1506
1521
|
return {
|
|
1507
1522
|
client: target,
|
|
1508
1523
|
hops
|
|
@@ -1688,10 +1703,10 @@ async function execCommand(engine, alias, command, timeoutMs) {
|
|
|
1688
1703
|
* The shell is a long-lived exclusive stream: it uses its own connection so
|
|
1689
1704
|
* closing it can never tear down a pooled exec/tunnel sharing the alias.
|
|
1690
1705
|
*/
|
|
1691
|
-
async function openShell(engine, alias, size) {
|
|
1706
|
+
async function openShell(engine, alias, size, onKeyboardInteractive) {
|
|
1692
1707
|
const entry = engine.store.find(alias);
|
|
1693
1708
|
if (entry === void 0) throw new Error("alias '" + alias + "' not found — add it first");
|
|
1694
|
-
const { client, hops } = await connectChain(engine, entry);
|
|
1709
|
+
const { client, hops } = await connectChain(engine, entry, onKeyboardInteractive);
|
|
1695
1710
|
return await new Promise((resolve, reject) => {
|
|
1696
1711
|
client.shell({
|
|
1697
1712
|
term: "xterm-256color",
|
|
@@ -2161,8 +2176,8 @@ var SshEngine = class {
|
|
|
2161
2176
|
return cluster(this, options);
|
|
2162
2177
|
}
|
|
2163
2178
|
/** Open a PTY shell session for the web terminal (standalone connection). */
|
|
2164
|
-
async openShell(alias, size) {
|
|
2165
|
-
return openShell(this, alias, size);
|
|
2179
|
+
async openShell(alias, size, onKeyboardInteractive) {
|
|
2180
|
+
return openShell(this, alias, size, onKeyboardInteractive);
|
|
2166
2181
|
}
|
|
2167
2182
|
/** Upload one local file (or directory tree) to a remote path. */
|
|
2168
2183
|
async upload(alias, localPath, remotePath, recursive, onProgress) {
|
|
@@ -2796,6 +2811,7 @@ function makeRoutes(deps) {
|
|
|
2796
2811
|
let session;
|
|
2797
2812
|
let closed = false;
|
|
2798
2813
|
let paused = false;
|
|
2814
|
+
let pendingAuthFinish;
|
|
2799
2815
|
const resume = () => {
|
|
2800
2816
|
if (paused && ws.bufferedAmount < BACKPRESSURE_LOW_WATER) {
|
|
2801
2817
|
paused = false;
|
|
@@ -2811,14 +2827,39 @@ function makeRoutes(deps) {
|
|
|
2811
2827
|
}
|
|
2812
2828
|
};
|
|
2813
2829
|
const closeSession = () => {
|
|
2830
|
+
if (pendingAuthFinish !== void 0) {
|
|
2831
|
+
const fn = pendingAuthFinish;
|
|
2832
|
+
pendingAuthFinish = void 0;
|
|
2833
|
+
try {
|
|
2834
|
+
fn([]);
|
|
2835
|
+
} catch {}
|
|
2836
|
+
}
|
|
2814
2837
|
const opened = session;
|
|
2815
2838
|
session = void 0;
|
|
2816
2839
|
if (opened !== void 0) opened.close();
|
|
2817
2840
|
};
|
|
2841
|
+
const onKeyboardInteractive = (name, instructions, _lang, prompts, finish) => {
|
|
2842
|
+
const entry = store.find(alias);
|
|
2843
|
+
if (entry?.auth.kind === "password" && entry.auth.password !== void 0 && prompts.length > 0 && prompts.every((p) => /password/i.test(p.prompt))) {
|
|
2844
|
+
const password = entry.auth.password;
|
|
2845
|
+
finish(prompts.map(() => password));
|
|
2846
|
+
return;
|
|
2847
|
+
}
|
|
2848
|
+
pendingAuthFinish = finish;
|
|
2849
|
+
sendFrame({
|
|
2850
|
+
type: "auth_prompt",
|
|
2851
|
+
name,
|
|
2852
|
+
instructions,
|
|
2853
|
+
prompts: prompts.map((p) => ({
|
|
2854
|
+
prompt: p.prompt,
|
|
2855
|
+
echo: p.echo
|
|
2856
|
+
}))
|
|
2857
|
+
});
|
|
2858
|
+
};
|
|
2818
2859
|
engine.openShell(alias, {
|
|
2819
2860
|
cols: Number.isFinite(cols) ? cols : 80,
|
|
2820
2861
|
rows: Number.isFinite(rows) ? rows : 24
|
|
2821
|
-
}).then((opened) => {
|
|
2862
|
+
}, onKeyboardInteractive).then((opened) => {
|
|
2822
2863
|
if (ws.readyState !== WebSocket.OPEN) {
|
|
2823
2864
|
opened.close();
|
|
2824
2865
|
return;
|
|
@@ -2863,6 +2904,13 @@ function makeRoutes(deps) {
|
|
|
2863
2904
|
}
|
|
2864
2905
|
if (frame.type === "input") session?.send(frame.data);
|
|
2865
2906
|
else if (frame.type === "resize") session?.resize(Math.max(2, frame.cols), Math.max(1, frame.rows));
|
|
2907
|
+
else if (frame.type === "auth_response") {
|
|
2908
|
+
if (pendingAuthFinish !== void 0) {
|
|
2909
|
+
const fn = pendingAuthFinish;
|
|
2910
|
+
pendingAuthFinish = void 0;
|
|
2911
|
+
fn(frame.responses);
|
|
2912
|
+
}
|
|
2913
|
+
}
|
|
2866
2914
|
});
|
|
2867
2915
|
ws.on("close", () => {
|
|
2868
2916
|
closed = true;
|
|
@@ -15,10 +15,17 @@ export interface TerminalConnection {
|
|
|
15
15
|
onOutput: ((data: string) => void) | undefined;
|
|
16
16
|
/** Fired on the exit frame (or transport error). */
|
|
17
17
|
onExit: ((code: number | null, error?: string) => void) | undefined;
|
|
18
|
+
/** Fired when the server requests keyboard-interactive 2FA. */
|
|
19
|
+
onAuthPrompt: ((name: string, instructions: string, prompts: Array<{
|
|
20
|
+
prompt: string;
|
|
21
|
+
echo: boolean;
|
|
22
|
+
}>) => void) | undefined;
|
|
18
23
|
/** Send raw input to the remote shell. */
|
|
19
24
|
send(data: string): void;
|
|
20
25
|
/** Resize the remote PTY. */
|
|
21
26
|
resize(cols: number, rows: number): void;
|
|
27
|
+
/** Send interactive 2FA response codes to the remote server. */
|
|
28
|
+
sendAuthResponse(responses: string[]): void;
|
|
22
29
|
/** Close the socket and the remote session. */
|
|
23
30
|
close(): void;
|
|
24
31
|
}
|
|
@@ -81,6 +81,11 @@ export declare const zh: {
|
|
|
81
81
|
readonly 'terminal.ready': "终端已连接({alias})";
|
|
82
82
|
readonly 'terminal.exited': "终端已退出({alias})";
|
|
83
83
|
readonly 'terminal.error': "终端错误:{error}";
|
|
84
|
+
readonly 'terminal.auth.title': "二次身份验证 (2FA / 交互式认证)";
|
|
85
|
+
readonly 'terminal.auth.submit': "提交验证码";
|
|
86
|
+
readonly 'terminal.auth.cancel': "取消";
|
|
87
|
+
readonly 'terminal.auth.placeholder': "请输入验证码或口令";
|
|
88
|
+
readonly 'terminal.auth.hint': "服务器请求进行交互式二次验证(输入仅本次生效,不落盘):";
|
|
84
89
|
readonly 'transfer.selectHost': "选择主机";
|
|
85
90
|
readonly 'transfer.remotePath': "远程路径";
|
|
86
91
|
readonly 'transfer.remotePathHint': "如 /tmp/ 或 /home/user/app.tar.gz";
|
|
@@ -50,8 +50,13 @@ export interface PoolEngine {
|
|
|
50
50
|
export declare function buildConnectConfig(entry: SshHostEntry, sock: ConnectConfig['sock'] | undefined, opts: Required<EngineOptions>): ConnectConfig;
|
|
51
51
|
/** Resolve the ssh2 agent path for 'agent' auth. */
|
|
52
52
|
export declare function resolveAgentPath(agentPath?: string): string | undefined;
|
|
53
|
+
/** Keyboard-interactive handler callback for 2FA / dynamic prompt flow. */
|
|
54
|
+
export type KeyboardInteractiveHandler = (name: string, instructions: string, instructionsLang: string, prompts: Array<{
|
|
55
|
+
prompt: string;
|
|
56
|
+
echo: boolean;
|
|
57
|
+
}>, finish: (responses: string[]) => void) => void;
|
|
53
58
|
/** Connect one ssh2 client (resolve on ready, reject on error/close). */
|
|
54
|
-
export declare function connectClient(config: ConnectConfig): Promise<Client>;
|
|
59
|
+
export declare function connectClient(config: ConnectConfig, onKeyboardInteractive?: KeyboardInteractiveHandler): Promise<Client>;
|
|
55
60
|
/** Cap captured output at the configured byte budget (marks truncation). */
|
|
56
61
|
export declare function appendOutput(target: {
|
|
57
62
|
text: string;
|
|
@@ -62,7 +67,7 @@ export declare function appendOutput(target: {
|
|
|
62
67
|
* order, each forwarding a stream to the next destination, ending with the
|
|
63
68
|
* target client. Shared by the pool and standalone shell sessions.
|
|
64
69
|
*/
|
|
65
|
-
export declare function connectChain(engine: PoolEngine, entry: SshHostEntry): Promise<{
|
|
70
|
+
export declare function connectChain(engine: PoolEngine, entry: SshHostEntry, onKeyboardInteractive?: KeyboardInteractiveHandler): Promise<{
|
|
66
71
|
client: Client;
|
|
67
72
|
hops: Client[];
|
|
68
73
|
}>;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* ssh2 connection with a long-lived shell channel, resize, and pausable
|
|
4
4
|
* output delivery.
|
|
5
5
|
*/
|
|
6
|
-
import { type PoolEngine } from './connection-pool.ts';
|
|
6
|
+
import { type KeyboardInteractiveHandler, type PoolEngine } from './connection-pool.ts';
|
|
7
7
|
/** A live PTY shell session. */
|
|
8
8
|
export interface ShellSession {
|
|
9
9
|
/** Assign to receive remote output. */
|
|
@@ -29,4 +29,4 @@ export interface ShellSession {
|
|
|
29
29
|
export declare function openShell(engine: PoolEngine, alias: string, size: {
|
|
30
30
|
cols: number;
|
|
31
31
|
rows: number;
|
|
32
|
-
}): Promise<ShellSession>;
|
|
32
|
+
}, onKeyboardInteractive?: KeyboardInteractiveHandler): Promise<ShellSession>;
|
package/lib/types/engine.d.ts
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { ClusterResult, ExecResult, SshHostSummary, TestResult, TransferProgress, TunnelInfo } from './protocol.ts';
|
|
9
9
|
import type { HostStore } from './store.ts';
|
|
10
|
-
import { type EngineOptions, type PoolRecord } from './engine/connection-pool.ts';
|
|
10
|
+
import { type EngineOptions, type KeyboardInteractiveHandler, type PoolRecord } from './engine/connection-pool.ts';
|
|
11
11
|
import { type ShellSession } from './engine/pty.ts';
|
|
12
12
|
import { type TunnelRecord } from './engine/tunnel.ts';
|
|
13
|
-
export type { EngineOptions } from './engine/connection-pool.ts';
|
|
13
|
+
export type { EngineOptions, KeyboardInteractiveHandler } from './engine/connection-pool.ts';
|
|
14
14
|
export type { ShellSession } from './engine/pty.ts';
|
|
15
15
|
export type { PoolRecord } from './engine/connection-pool.ts';
|
|
16
16
|
export type { TunnelRecord } from './engine/tunnel.ts';
|
|
@@ -50,7 +50,7 @@ export declare class SshEngine {
|
|
|
50
50
|
openShell(alias: string, size: {
|
|
51
51
|
cols: number;
|
|
52
52
|
rows: number;
|
|
53
|
-
}): Promise<ShellSession>;
|
|
53
|
+
}, onKeyboardInteractive?: KeyboardInteractiveHandler): Promise<ShellSession>;
|
|
54
54
|
/** Upload one local file (or directory tree) to a remote path. */
|
|
55
55
|
upload(alias: string, localPath: string, remotePath: string, recursive: boolean, onProgress?: (progress: TransferProgress) => void): Promise<{
|
|
56
56
|
bytes: number;
|
package/lib/types/protocol.d.ts
CHANGED
|
@@ -160,6 +160,14 @@ export type TerminalServerFrame = {
|
|
|
160
160
|
type: 'exit';
|
|
161
161
|
code: number | null;
|
|
162
162
|
error?: string;
|
|
163
|
+
} | {
|
|
164
|
+
type: 'auth_prompt';
|
|
165
|
+
name: string;
|
|
166
|
+
instructions: string;
|
|
167
|
+
prompts: Array<{
|
|
168
|
+
prompt: string;
|
|
169
|
+
echo: boolean;
|
|
170
|
+
}>;
|
|
163
171
|
};
|
|
164
172
|
export type TerminalClientFrame = {
|
|
165
173
|
type: 'input';
|
|
@@ -168,6 +176,9 @@ export type TerminalClientFrame = {
|
|
|
168
176
|
type: 'resize';
|
|
169
177
|
cols: number;
|
|
170
178
|
rows: number;
|
|
179
|
+
} | {
|
|
180
|
+
type: 'auth_response';
|
|
181
|
+
responses: string[];
|
|
171
182
|
};
|
|
172
183
|
/** Route paths the client calls (shared literals). */
|
|
173
184
|
export declare const SSH_API_BASE: "/api/dsh-ssh";
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-ssh",
|
|
3
3
|
"description": "Remote SSH operations for the dsh web GUI: host config store (~/.dsh/dsh-ssh.json, import from ~/.ssh/config), a persistent ssh2 connection pool with jump-host support, exec / PTY web terminal / SFTP transfer / local port-forward tunnels / cluster execution, plus agent tools (ssh_list, ssh_exec, ssh_upload, ssh_download, ssh_tunnel, ssh_cluster). Hot-pluggable — mounted via ~/.dsh/cordis.patch.yml + a profile node_modules symlink, no dsh source changes.",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.6",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": "^22.19.0 || >=24.0.0"
|
package/src/client/api.ts
CHANGED
|
@@ -80,10 +80,14 @@ export interface TerminalConnection {
|
|
|
80
80
|
onOutput: ((data: string) => void) | undefined
|
|
81
81
|
/** Fired on the exit frame (or transport error). */
|
|
82
82
|
onExit: ((code: number | null, error?: string) => void) | undefined
|
|
83
|
+
/** Fired when the server requests keyboard-interactive 2FA. */
|
|
84
|
+
onAuthPrompt: ((name: string, instructions: string, prompts: Array<{ prompt: string; echo: boolean }>) => void) | undefined
|
|
83
85
|
/** Send raw input to the remote shell. */
|
|
84
86
|
send(data: string): void
|
|
85
87
|
/** Resize the remote PTY. */
|
|
86
88
|
resize(cols: number, rows: number): void
|
|
89
|
+
/** Send interactive 2FA response codes to the remote server. */
|
|
90
|
+
sendAuthResponse(responses: string[]): void
|
|
87
91
|
/** Close the socket and the remote session. */
|
|
88
92
|
close(): void
|
|
89
93
|
}
|
|
@@ -345,6 +349,7 @@ export class SshApi {
|
|
|
345
349
|
onReady: undefined,
|
|
346
350
|
onOutput: undefined,
|
|
347
351
|
onExit: undefined,
|
|
352
|
+
onAuthPrompt: undefined,
|
|
348
353
|
send: (data) => {
|
|
349
354
|
if (socket.readyState === WebSocket.OPEN) {
|
|
350
355
|
socket.send(JSON.stringify({ type: 'input', data } satisfies TerminalClientFrame))
|
|
@@ -355,6 +360,11 @@ export class SshApi {
|
|
|
355
360
|
socket.send(JSON.stringify({ type: 'resize', cols, rows } satisfies TerminalClientFrame))
|
|
356
361
|
}
|
|
357
362
|
},
|
|
363
|
+
sendAuthResponse: (responses) => {
|
|
364
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
365
|
+
socket.send(JSON.stringify({ type: 'auth_response', responses } satisfies TerminalClientFrame))
|
|
366
|
+
}
|
|
367
|
+
},
|
|
358
368
|
close: () => {
|
|
359
369
|
try { socket.close() } catch { /* already closed */ }
|
|
360
370
|
},
|
|
@@ -369,6 +379,7 @@ export class SshApi {
|
|
|
369
379
|
if (frame.type === 'ready') connection.onReady?.()
|
|
370
380
|
else if (frame.type === 'output') connection.onOutput?.(frame.data)
|
|
371
381
|
else if (frame.type === 'exit') connection.onExit?.(frame.code, frame.error)
|
|
382
|
+
else if (frame.type === 'auth_prompt') connection.onAuthPrompt?.(frame.name, frame.instructions, frame.prompts)
|
|
372
383
|
}
|
|
373
384
|
socket.onclose = () => { connection.onExit?.(null, 'connection closed') }
|
|
374
385
|
socket.onerror = () => { connection.onExit?.(null, 'connection error') }
|
package/src/client/locales.ts
CHANGED
|
@@ -85,6 +85,11 @@ export const zh = {
|
|
|
85
85
|
'terminal.ready': '终端已连接({alias})',
|
|
86
86
|
'terminal.exited': '终端已退出({alias})',
|
|
87
87
|
'terminal.error': '终端错误:{error}',
|
|
88
|
+
'terminal.auth.title': '二次身份验证 (2FA / 交互式认证)',
|
|
89
|
+
'terminal.auth.submit': '提交验证码',
|
|
90
|
+
'terminal.auth.cancel': '取消',
|
|
91
|
+
'terminal.auth.placeholder': '请输入验证码或口令',
|
|
92
|
+
'terminal.auth.hint': '服务器请求进行交互式二次验证(输入仅本次生效,不落盘):',
|
|
88
93
|
// transfer
|
|
89
94
|
'transfer.selectHost': '选择主机',
|
|
90
95
|
'transfer.remotePath': '远程路径',
|
|
@@ -231,6 +236,11 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
231
236
|
'terminal.ready': 'Terminal connected ({alias})',
|
|
232
237
|
'terminal.exited': 'Terminal exited ({alias})',
|
|
233
238
|
'terminal.error': 'Terminal error: {error}',
|
|
239
|
+
'terminal.auth.title': 'Two-Factor Authentication (2FA)',
|
|
240
|
+
'terminal.auth.submit': 'Submit Code',
|
|
241
|
+
'terminal.auth.cancel': 'Cancel',
|
|
242
|
+
'terminal.auth.placeholder': 'Enter code or passphrase',
|
|
243
|
+
'terminal.auth.hint': 'The server requested interactive 2FA (session only, never saved):',
|
|
234
244
|
'transfer.selectHost': 'Select host',
|
|
235
245
|
'transfer.remotePath': 'Remote path',
|
|
236
246
|
'transfer.remotePathHint': 'e.g. /tmp/ or /home/user/app.tar.gz',
|
|
@@ -36,6 +36,13 @@ type TerminalStatus =
|
|
|
36
36
|
| { kind: 'exited'; alias: string; detail?: string }
|
|
37
37
|
| { kind: 'error'; detail: string }
|
|
38
38
|
|
|
39
|
+
/** In-flight 2FA challenge prompts. */
|
|
40
|
+
interface AuthPromptState {
|
|
41
|
+
name: string
|
|
42
|
+
instructions: string
|
|
43
|
+
prompts: Array<{ prompt: string; echo: boolean }>
|
|
44
|
+
}
|
|
45
|
+
|
|
39
46
|
/** Injected-once guard for the xterm stylesheet (one tag per page load). */
|
|
40
47
|
let xtermCssInjected = false
|
|
41
48
|
|
|
@@ -60,6 +67,8 @@ export function TerminalTab({ api, presetAlias, requestId, terminalFont }: Termi
|
|
|
60
67
|
const [hosts, setHosts] = useState<SshHostSummary[]>([])
|
|
61
68
|
const [alias, setAlias] = useState(presetAlias ?? '')
|
|
62
69
|
const [status, setStatus] = useState<TerminalStatus>({ kind: 'idle' })
|
|
70
|
+
const [authPrompt, setAuthPrompt] = useState<AuthPromptState | undefined>(undefined)
|
|
71
|
+
const [authInputs, setAuthInputs] = useState<string[]>([])
|
|
63
72
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
|
64
73
|
const termRef = useRef<Terminal | null>(null)
|
|
65
74
|
const fitRef = useRef<FitAddon | null>(null)
|
|
@@ -103,12 +112,15 @@ export function TerminalTab({ api, presetAlias, requestId, terminalFont }: Termi
|
|
|
103
112
|
}, [presetAlias, requestId])
|
|
104
113
|
|
|
105
114
|
const teardown = (): void => {
|
|
115
|
+
setAuthPrompt(undefined)
|
|
116
|
+
setAuthInputs([])
|
|
106
117
|
const connection = connRef.current
|
|
107
118
|
connRef.current = null
|
|
108
119
|
if (connection !== null) {
|
|
109
120
|
connection.onReady = undefined
|
|
110
121
|
connection.onOutput = undefined
|
|
111
122
|
connection.onExit = undefined
|
|
123
|
+
connection.onAuthPrompt = undefined
|
|
112
124
|
connection.close()
|
|
113
125
|
}
|
|
114
126
|
// Release the xterm input subscription explicitly and dispose the
|
|
@@ -182,11 +194,21 @@ export function TerminalTab({ api, presetAlias, requestId, terminalFont }: Termi
|
|
|
182
194
|
connRef.current = connection
|
|
183
195
|
let settled = false
|
|
184
196
|
dataSubRef.current = term.onData(data => { connection.send(data) })
|
|
185
|
-
connection.
|
|
197
|
+
connection.onAuthPrompt = (name, instructions, prompts) => {
|
|
198
|
+
setAuthPrompt({ name, instructions, prompts })
|
|
199
|
+
setAuthInputs(prompts.map(() => ''))
|
|
200
|
+
}
|
|
201
|
+
connection.onReady = () => {
|
|
202
|
+
setAuthPrompt(undefined)
|
|
203
|
+
setAuthInputs([])
|
|
204
|
+
setStatus({ kind: 'connected', alias: target })
|
|
205
|
+
}
|
|
186
206
|
connection.onOutput = data => { term.write(data) }
|
|
187
207
|
connection.onExit = (_code, error) => {
|
|
188
208
|
if (settled) return
|
|
189
209
|
settled = true
|
|
210
|
+
setAuthPrompt(undefined)
|
|
211
|
+
setAuthInputs([])
|
|
190
212
|
dataSubRef.current?.dispose()
|
|
191
213
|
dataSubRef.current = null
|
|
192
214
|
term.options.disableStdin = true
|
|
@@ -201,6 +223,13 @@ export function TerminalTab({ api, presetAlias, requestId, terminalFont }: Termi
|
|
|
201
223
|
setStatus({ kind: 'idle' })
|
|
202
224
|
}
|
|
203
225
|
|
|
226
|
+
const submitAuth = (e?: React.FormEvent): void => {
|
|
227
|
+
e?.preventDefault()
|
|
228
|
+
if (connRef.current === null || authPrompt === undefined) return
|
|
229
|
+
connRef.current.sendAuthResponse(authInputs)
|
|
230
|
+
setAuthPrompt(undefined)
|
|
231
|
+
}
|
|
232
|
+
|
|
204
233
|
const active = status.kind === 'connecting' || status.kind === 'connected'
|
|
205
234
|
|
|
206
235
|
return (
|
|
@@ -224,6 +253,37 @@ export function TerminalTab({ api, presetAlias, requestId, terminalFont }: Termi
|
|
|
224
253
|
{status.kind === 'idle' && (
|
|
225
254
|
<div className={css.termPlaceholder}>{hosts.length === 0 ? tt('hosts.empty') : tt('terminal.placeholder')}</div>
|
|
226
255
|
)}
|
|
256
|
+
{authPrompt !== undefined && (
|
|
257
|
+
<div className={css.modalBackdrop} style={{ position: 'absolute', zIndex: 10 }}>
|
|
258
|
+
<form className={css.modalCard} style={{ maxWidth: 420 }} onSubmit={submitAuth}>
|
|
259
|
+
<div className={css.modalHeader}>
|
|
260
|
+
<h3 className={css.modalTitle}>{authPrompt.name || tt('terminal.auth.title')}</h3>
|
|
261
|
+
</div>
|
|
262
|
+
<p className={css.hint}>{authPrompt.instructions || tt('terminal.auth.hint')}</p>
|
|
263
|
+
{authPrompt.prompts.map((p, idx) => (
|
|
264
|
+
<label key={idx} className={css.field}>
|
|
265
|
+
<span className={css.fieldLabel}>{p.prompt.trim() || tt('terminal.auth.title')}</span>
|
|
266
|
+
<input
|
|
267
|
+
autoFocus={idx === 0}
|
|
268
|
+
className={css.input}
|
|
269
|
+
type={p.echo ? 'text' : 'password'}
|
|
270
|
+
placeholder={tt('terminal.auth.placeholder')}
|
|
271
|
+
value={authInputs[idx] ?? ''}
|
|
272
|
+
onChange={event => {
|
|
273
|
+
const next = [...authInputs]
|
|
274
|
+
next[idx] = event.target.value
|
|
275
|
+
setAuthInputs(next)
|
|
276
|
+
}}
|
|
277
|
+
/>
|
|
278
|
+
</label>
|
|
279
|
+
))}
|
|
280
|
+
<div className={css.modalFooter}>
|
|
281
|
+
<button type="button" className={css.ghostButton} onClick={disconnect}>{tt('terminal.auth.cancel')}</button>
|
|
282
|
+
<button type="submit" className={css.primaryButton}>{tt('terminal.auth.submit')}</button>
|
|
283
|
+
</div>
|
|
284
|
+
</form>
|
|
285
|
+
</div>
|
|
286
|
+
)}
|
|
227
287
|
</div>
|
|
228
288
|
</div>
|
|
229
289
|
)
|
|
@@ -70,6 +70,7 @@ export function buildConnectConfig(entry: SshHostEntry, sock: ConnectConfig['soc
|
|
|
70
70
|
readyTimeout: opts.connectTimeoutMs,
|
|
71
71
|
keepaliveInterval: opts.keepaliveIntervalMs,
|
|
72
72
|
keepaliveCountMax: 3,
|
|
73
|
+
tryKeyboard: true,
|
|
73
74
|
}
|
|
74
75
|
if (sock !== undefined) config.sock = sock
|
|
75
76
|
if (entry.auth.kind === 'password') {
|
|
@@ -103,8 +104,17 @@ export function resolveAgentPath(agentPath?: string): string | undefined {
|
|
|
103
104
|
return undefined
|
|
104
105
|
}
|
|
105
106
|
|
|
107
|
+
/** Keyboard-interactive handler callback for 2FA / dynamic prompt flow. */
|
|
108
|
+
export type KeyboardInteractiveHandler = (
|
|
109
|
+
name: string,
|
|
110
|
+
instructions: string,
|
|
111
|
+
instructionsLang: string,
|
|
112
|
+
prompts: Array<{ prompt: string; echo: boolean }>,
|
|
113
|
+
finish: (responses: string[]) => void,
|
|
114
|
+
) => void
|
|
115
|
+
|
|
106
116
|
/** Connect one ssh2 client (resolve on ready, reject on error/close). */
|
|
107
|
-
export function connectClient(config: ConnectConfig): Promise<Client> {
|
|
117
|
+
export function connectClient(config: ConnectConfig, onKeyboardInteractive?: KeyboardInteractiveHandler): Promise<Client> {
|
|
108
118
|
return new Promise((resolve, reject) => {
|
|
109
119
|
const client = new Client()
|
|
110
120
|
let settled = false
|
|
@@ -119,6 +129,18 @@ export function connectClient(config: ConnectConfig): Promise<Client> {
|
|
|
119
129
|
settled = true
|
|
120
130
|
resolve(client)
|
|
121
131
|
})
|
|
132
|
+
client.on('keyboard-interactive', (name, instructions, instructionsLang, prompts, finish) => {
|
|
133
|
+
if (onKeyboardInteractive !== undefined) {
|
|
134
|
+
onKeyboardInteractive(name, instructions, instructionsLang, prompts.map(p => ({ prompt: p.prompt, echo: Boolean(p.echo) })), finish)
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
// Step 1: Automatic PAM password fallback: if a password is configured and all prompts ask for password
|
|
138
|
+
if (config.password !== undefined && prompts.length > 0 && prompts.every(p => /password/i.test(p.prompt))) {
|
|
139
|
+
finish(prompts.map(() => config.password as string))
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
fail(new Error('Authentication failed (keyboard-interactive): ' + (prompts.map(p => p.prompt.trim()).join(', ') || 'unsupported interactive challenge')))
|
|
143
|
+
})
|
|
122
144
|
// Keep an error listener attached after the handshake: when TCP connects
|
|
123
145
|
// but the handshake drops, ssh2 can emit a second 'error' after a single
|
|
124
146
|
// once-listener is gone, which would surface as an unhandled 'error' event.
|
|
@@ -152,7 +174,11 @@ export function appendOutput(target: { text: string; truncated: boolean }, chunk
|
|
|
152
174
|
* order, each forwarding a stream to the next destination, ending with the
|
|
153
175
|
* target client. Shared by the pool and standalone shell sessions.
|
|
154
176
|
*/
|
|
155
|
-
export async function connectChain(
|
|
177
|
+
export async function connectChain(
|
|
178
|
+
engine: PoolEngine,
|
|
179
|
+
entry: SshHostEntry,
|
|
180
|
+
onKeyboardInteractive?: KeyboardInteractiveHandler,
|
|
181
|
+
): Promise<{ client: Client; hops: Client[] }> {
|
|
156
182
|
const hops: Client[] = []
|
|
157
183
|
let sock: ConnectConfig['sock']
|
|
158
184
|
const chain = entry.proxyJump
|
|
@@ -163,7 +189,7 @@ export async function connectChain(engine: PoolEngine, entry: SshHostEntry): Pro
|
|
|
163
189
|
if (hop === undefined) {
|
|
164
190
|
throw new Error('proxyJump alias \'' + hopAlias + '\' not found — create it first')
|
|
165
191
|
}
|
|
166
|
-
const hopClient = await connectClient(buildConnectConfig(hop, sock, engine.opts))
|
|
192
|
+
const hopClient = await connectClient(buildConnectConfig(hop, sock, engine.opts), onKeyboardInteractive)
|
|
167
193
|
hops.push(hopClient)
|
|
168
194
|
const next = index + 1 < chain.length ? engine.store.find(chain[index + 1]) : undefined
|
|
169
195
|
const nextHost = next !== undefined ? next.host : entry.host
|
|
@@ -187,7 +213,7 @@ export async function connectChain(engine: PoolEngine, entry: SshHostEntry): Pro
|
|
|
187
213
|
}
|
|
188
214
|
let target: Client | undefined
|
|
189
215
|
try {
|
|
190
|
-
target = await connectClient(buildConnectConfig(entry, sock, engine.opts))
|
|
216
|
+
target = await connectClient(buildConnectConfig(entry, sock, engine.opts), onKeyboardInteractive)
|
|
191
217
|
return { client: target, hops }
|
|
192
218
|
} catch (error) {
|
|
193
219
|
for (const client of hops) client.end()
|
package/src/engine/pty.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* output delivery.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { connectChain, type PoolEngine } from './connection-pool.ts'
|
|
7
|
+
import { connectChain, type KeyboardInteractiveHandler, type PoolEngine } from './connection-pool.ts'
|
|
8
8
|
|
|
9
9
|
/** A live PTY shell session. */
|
|
10
10
|
export interface ShellSession {
|
|
@@ -29,10 +29,15 @@ export interface ShellSession {
|
|
|
29
29
|
* The shell is a long-lived exclusive stream: it uses its own connection so
|
|
30
30
|
* closing it can never tear down a pooled exec/tunnel sharing the alias.
|
|
31
31
|
*/
|
|
32
|
-
export async function openShell(
|
|
32
|
+
export async function openShell(
|
|
33
|
+
engine: PoolEngine,
|
|
34
|
+
alias: string,
|
|
35
|
+
size: { cols: number; rows: number },
|
|
36
|
+
onKeyboardInteractive?: KeyboardInteractiveHandler,
|
|
37
|
+
): Promise<ShellSession> {
|
|
33
38
|
const entry = engine.store.find(alias)
|
|
34
39
|
if (entry === undefined) throw new Error('alias \'' + alias + '\' not found — add it first')
|
|
35
|
-
const { client, hops } = await connectChain(engine, entry)
|
|
40
|
+
const { client, hops } = await connectChain(engine, entry, onKeyboardInteractive)
|
|
36
41
|
return await new Promise<ShellSession>((resolve, reject) => {
|
|
37
42
|
client.shell({ term: 'xterm-256color', cols: size.cols, rows: size.rows }, (error, stream) => {
|
|
38
43
|
if (error !== undefined) {
|
package/src/engine.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
disposeRecord,
|
|
15
15
|
sweepPool,
|
|
16
16
|
type EngineOptions,
|
|
17
|
+
type KeyboardInteractiveHandler,
|
|
17
18
|
type PoolRecord,
|
|
18
19
|
} from './engine/connection-pool.ts'
|
|
19
20
|
import { openShell, type ShellSession } from './engine/pty.ts'
|
|
@@ -21,7 +22,7 @@ import { download, ls, upload } from './engine/sftp.ts'
|
|
|
21
22
|
import { listTunnels, startTunnel, stopAllTunnels, stopTunnel, type TunnelRecord } from './engine/tunnel.ts'
|
|
22
23
|
import { cluster } from './engine/cluster.ts'
|
|
23
24
|
|
|
24
|
-
export type { EngineOptions } from './engine/connection-pool.ts'
|
|
25
|
+
export type { EngineOptions, KeyboardInteractiveHandler } from './engine/connection-pool.ts'
|
|
25
26
|
export type { ShellSession } from './engine/pty.ts'
|
|
26
27
|
export type { PoolRecord } from './engine/connection-pool.ts'
|
|
27
28
|
export type { TunnelRecord } from './engine/tunnel.ts'
|
|
@@ -92,8 +93,12 @@ export class SshEngine {
|
|
|
92
93
|
// -------------------------------------------------------------- shell
|
|
93
94
|
|
|
94
95
|
/** Open a PTY shell session for the web terminal (standalone connection). */
|
|
95
|
-
async openShell(
|
|
96
|
-
|
|
96
|
+
async openShell(
|
|
97
|
+
alias: string,
|
|
98
|
+
size: { cols: number; rows: number },
|
|
99
|
+
onKeyboardInteractive?: KeyboardInteractiveHandler,
|
|
100
|
+
): Promise<ShellSession> {
|
|
101
|
+
return openShell(this, alias, size, onKeyboardInteractive)
|
|
97
102
|
}
|
|
98
103
|
|
|
99
104
|
// -------------------------------------------------------------- sftp
|
package/src/protocol.ts
CHANGED
|
@@ -167,10 +167,17 @@ export type TerminalServerFrame =
|
|
|
167
167
|
| { type: 'ready'; alias: string }
|
|
168
168
|
| { type: 'output'; data: string }
|
|
169
169
|
| { type: 'exit'; code: number | null; error?: string }
|
|
170
|
+
| {
|
|
171
|
+
type: 'auth_prompt'
|
|
172
|
+
name: string
|
|
173
|
+
instructions: string
|
|
174
|
+
prompts: Array<{ prompt: string; echo: boolean }>
|
|
175
|
+
}
|
|
170
176
|
|
|
171
177
|
export type TerminalClientFrame =
|
|
172
178
|
| { type: 'input'; data: string }
|
|
173
179
|
| { type: 'resize'; cols: number; rows: number }
|
|
180
|
+
| { type: 'auth_response'; responses: string[] }
|
|
174
181
|
|
|
175
182
|
/** Route paths the client calls (shared literals). */
|
|
176
183
|
export const SSH_API_BASE = '/api/dsh-ssh' as const
|