@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/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>;
@@ -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;
@@ -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",
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') }
@@ -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.onReady = () => { setStatus({ kind: 'connected', alias: target }) }
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(engine: PoolEngine, entry: SshHostEntry): Promise<{ client: Client; hops: Client[] }> {
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(engine: PoolEngine, alias: string, size: { cols: number; rows: number }): Promise<ShellSession> {
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(alias: string, size: { cols: number; rows: number }): Promise<ShellSession> {
96
- return openShell(this, alias, size)
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