@linxin666/dsh-ssh 0.3.5 → 0.3.9
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 +404 -301
- package/lib/client.js.map +1 -1
- package/lib/index.js +77 -24
- package/lib/types/client/api.d.ts +7 -0
- package/lib/types/client/index.d.ts +2 -1
- package/lib/types/client/locales.d.ts +6 -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/index.d.ts +2 -1
- package/lib/types/protocol.d.ts +11 -0
- package/package.json +17 -17
- package/src/client/api.ts +16 -1
- package/src/client/index.ts +4 -1
- package/src/client/locales.ts +12 -0
- package/src/client/panel/TerminalTab.tsx +61 -1
- package/src/dsh-home.ts +1 -1
- package/src/engine/connection-pool.ts +30 -4
- package/src/engine/pty.ts +8 -3
- package/src/engine.ts +8 -3
- package/src/index.ts +27 -14
- package/src/protocol.ts +7 -0
- package/src/routes.ts +33 -2
|
@@ -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
|
)
|
package/src/dsh-home.ts
CHANGED
|
@@ -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/index.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { Context } from '@deepseek-ai/cordis'
|
|
12
|
-
import {
|
|
12
|
+
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
13
13
|
import z from 'schemastery'
|
|
14
14
|
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
15
15
|
import type {} from '@deepseek-ai/dsh-system-prompt'
|
|
@@ -31,7 +31,7 @@ export const inject = ['webServer', 'tools', 'systemPrompt']
|
|
|
31
31
|
* surface edits. Spelled here rather than imported: the browser half spells
|
|
32
32
|
* the same value and must not depend on a Host package.
|
|
33
33
|
*/
|
|
34
|
-
export const SSH_SETTINGS_NAMESPACE =
|
|
34
|
+
export const SSH_SETTINGS_NAMESPACE = 'dsh-ssh' as SettingsNamespace
|
|
35
35
|
|
|
36
36
|
/** Plugin config, validated by the same-named schemastery schema. */
|
|
37
37
|
export interface Config {
|
|
@@ -77,18 +77,28 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
77
77
|
// The live source the surfaces read: the settings section once the web
|
|
78
78
|
// settings surface is served, the composition entry otherwise.
|
|
79
79
|
let current: () => Config = () => config ?? {}
|
|
80
|
+
let isSettingsBound = false
|
|
81
|
+
|
|
82
|
+
const store = new HostStore()
|
|
83
|
+
const engine = new SshEngine(store)
|
|
84
|
+
ctx.effect(() => () => { engine.dispose() }, 'dsh-ssh: engine')
|
|
85
|
+
|
|
80
86
|
const resolve = (): Config => {
|
|
81
87
|
const value = current()
|
|
88
|
+
let enabled = value.enabled ?? true
|
|
89
|
+
// When dsh-ssh was seeded with enabled: false (e.g. from an aggregate profile line),
|
|
90
|
+
// but the user has never explicitly configured settings (not bound yet)
|
|
91
|
+
// AND already has active host records in dsh-ssh.json, keep the plugin enabled so
|
|
92
|
+
// existing users are not broken upon upgrade (#1250).
|
|
93
|
+
if (enabled === false && !isSettingsBound && store.list().length > 0) {
|
|
94
|
+
enabled = true
|
|
95
|
+
}
|
|
82
96
|
return {
|
|
83
97
|
announceToAgent: value.announceToAgent ?? DEFAULT_ANNOUNCE,
|
|
84
|
-
enabled
|
|
98
|
+
enabled,
|
|
85
99
|
}
|
|
86
100
|
}
|
|
87
101
|
|
|
88
|
-
const store = new HostStore()
|
|
89
|
-
const engine = new SshEngine(store)
|
|
90
|
-
ctx.effect(() => () => { engine.dispose() }, 'dsh-ssh: engine')
|
|
91
|
-
|
|
92
102
|
// The /api/dsh-ssh route family + terminal upgrade.
|
|
93
103
|
const { routes, upgrade } = makeRoutes({ store, engine })
|
|
94
104
|
let disposeRoutes: (() => void) | undefined
|
|
@@ -152,15 +162,18 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
152
162
|
)
|
|
153
163
|
}
|
|
154
164
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
165
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
166
|
+
settingsCtx.settings.installSection(ctx, SSH_SETTINGS_NAMESPACE, Config, config ?? {}, {
|
|
167
|
+
setSource: (source) => {
|
|
168
|
+
isSettingsBound = true
|
|
169
|
+
current = source
|
|
170
|
+
sync()
|
|
171
|
+
},
|
|
172
|
+
onChange: sync,
|
|
173
|
+
})
|
|
161
174
|
})
|
|
162
175
|
|
|
163
176
|
// Initial registration from the composition entry (covers deployments with
|
|
164
|
-
// no settings service, whose
|
|
177
|
+
// no settings service, whose installSection never fires its hooks).
|
|
165
178
|
sync()
|
|
166
179
|
}
|
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
|
package/src/routes.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { tmpdir } from 'node:os'
|
|
|
15
15
|
import { randomBytes } from 'node:crypto'
|
|
16
16
|
import { WebSocket, WebSocketServer } from 'ws'
|
|
17
17
|
import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
18
|
-
import type { SshEngine, ShellSession } from './engine.ts'
|
|
18
|
+
import type { SshEngine, ShellSession, KeyboardInteractiveHandler } from './engine.ts'
|
|
19
19
|
import { readJsonBody, writeJson } from './http.ts'
|
|
20
20
|
import { isLoopbackRequest } from './loopback.ts'
|
|
21
21
|
import { SSH_API, type HostPayload, type TerminalClientFrame, type TerminalServerFrame } from './protocol.ts'
|
|
@@ -466,6 +466,7 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
|
|
|
466
466
|
let session: ShellSession | undefined
|
|
467
467
|
let closed = false
|
|
468
468
|
let paused = false
|
|
469
|
+
let pendingAuthFinish: ((responses: string[]) => void) | undefined
|
|
469
470
|
// Resume the shell once the socket's send buffer drains below the
|
|
470
471
|
// low-water mark (transport backpressure).
|
|
471
472
|
const resume = (): void => {
|
|
@@ -483,14 +484,38 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
|
|
|
483
484
|
}
|
|
484
485
|
}
|
|
485
486
|
const closeSession = (): void => {
|
|
487
|
+
if (pendingAuthFinish !== undefined) {
|
|
488
|
+
const fn = pendingAuthFinish
|
|
489
|
+
pendingAuthFinish = undefined
|
|
490
|
+
try { fn([]) } catch { /* ignore */ }
|
|
491
|
+
}
|
|
486
492
|
const opened = session
|
|
487
493
|
session = undefined
|
|
488
494
|
if (opened !== undefined) opened.close()
|
|
489
495
|
}
|
|
496
|
+
|
|
497
|
+
const onKeyboardInteractive: KeyboardInteractiveHandler = (name, instructions, _lang, prompts, finish) => {
|
|
498
|
+
const entry = store.find(alias)
|
|
499
|
+
// Auto-answer password if configured and prompt is a password request
|
|
500
|
+
if (entry?.auth.kind === 'password' && entry.auth.password !== undefined && prompts.length > 0 && prompts.every(p => /password/i.test(p.prompt))) {
|
|
501
|
+
const password = entry.auth.password
|
|
502
|
+
finish(prompts.map(() => password))
|
|
503
|
+
return
|
|
504
|
+
}
|
|
505
|
+
// Interactive 2FA prompt sent to the client terminal
|
|
506
|
+
pendingAuthFinish = finish
|
|
507
|
+
sendFrame({
|
|
508
|
+
type: 'auth_prompt',
|
|
509
|
+
name,
|
|
510
|
+
instructions,
|
|
511
|
+
prompts: prompts.map(p => ({ prompt: p.prompt, echo: p.echo })),
|
|
512
|
+
})
|
|
513
|
+
}
|
|
514
|
+
|
|
490
515
|
engine.openShell(alias, {
|
|
491
516
|
cols: Number.isFinite(cols) ? cols : 80,
|
|
492
517
|
rows: Number.isFinite(rows) ? rows : 24,
|
|
493
|
-
}).then((opened) => {
|
|
518
|
+
}, onKeyboardInteractive).then((opened) => {
|
|
494
519
|
if (ws.readyState !== WebSocket.OPEN) {
|
|
495
520
|
opened.close()
|
|
496
521
|
return
|
|
@@ -519,6 +544,12 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
|
|
|
519
544
|
session?.send(frame.data)
|
|
520
545
|
} else if (frame.type === 'resize') {
|
|
521
546
|
session?.resize(Math.max(2, frame.cols), Math.max(1, frame.rows))
|
|
547
|
+
} else if (frame.type === 'auth_response') {
|
|
548
|
+
if (pendingAuthFinish !== undefined) {
|
|
549
|
+
const fn = pendingAuthFinish
|
|
550
|
+
pendingAuthFinish = undefined
|
|
551
|
+
fn(frame.responses)
|
|
552
|
+
}
|
|
522
553
|
}
|
|
523
554
|
})
|
|
524
555
|
ws.on('close', () => {
|