From 41d0c0ab853eefa914f9ea416957f8c37295eeff Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 30 Sep 2026 13:58:26 -0500 Subject: [PATCH] fix: prompt for jump host TOTP in terminal, file manager and metrics (#1312) --- packages/plugin-sdk/src/backend.ts | 4 ++ plugins/file-manager/src/backend/index.ts | 50 +++++++++++++++- .../host-metrics/src/backend/interactive.ts | 55 +++++++++++++++-- .../backend/interactive-jump-totp.test.ts | 60 +++++++++++++++++++ .../src/backend/terminal-socket.ts | 44 ++++++++++++++ src/backend/plugins/ctx-ssh-auth.ts | 6 +- 6 files changed, 211 insertions(+), 8 deletions(-) create mode 100644 plugins/host-metrics/tests/backend/interactive-jump-totp.test.ts diff --git a/packages/plugin-sdk/src/backend.ts b/packages/plugin-sdk/src/backend.ts index c489ead15..ecbe2e4cc 100644 --- a/packages/plugin-sdk/src/backend.ts +++ b/packages/plugin-sdk/src/backend.ts @@ -1084,6 +1084,8 @@ export interface PluginSsh { options?: { /** Background work: resolve the hops as this host's owner. */ forHost?: PluginSshHost; + /** Answers a hop asking for TOTP or other input. */ + prompt?: PluginSshPromptChannel; }, ) => Promise>; @@ -1140,6 +1142,8 @@ export interface PluginSsh { /** False when the caller already resolved DNS, or a jump host will. */ resolveDns?: boolean; log?: (level: "info" | "warning" | "error", message: string) => void; + /** Answers a jump host asking for TOTP or other input. */ + prompt?: PluginSshPromptChannel; }, ) => Promise<{ jumpClient: unknown | null; via: string }>; diff --git a/plugins/file-manager/src/backend/index.ts b/plugins/file-manager/src/backend/index.ts index 5692ab145..f6a7c021c 100644 --- a/plugins/file-manager/src/backend/index.ts +++ b/plugins/file-manager/src/backend/index.ts @@ -1,7 +1,11 @@ import express, { type Router } from "express"; import cookieParser from "cookie-parser"; import { Client as SSHClient } from "ssh2"; -import type { PluginContext, PluginSshHost } from "@termix/plugin-sdk/backend"; +import type { + PluginContext, + PluginSshHost, + PluginSshPromptChannel, +} from "@termix/plugin-sdk/backend"; import { getErrorMessage } from "./error-message.js"; import { setPluginSsh, pluginSsh } from "./ssh.js"; import { setPluginCtx } from "./plugin-ctx.js"; @@ -1141,8 +1145,45 @@ export async function activate(ctx: PluginContext) { ); } + // A jump host asking for TOTP answers through /connect-totp like the host. + const jumpHostPrompt: PluginSshPromptChannel = { + ask: (request) => + new Promise((resolve) => { + if ( + (request.kind !== "totp" && request.kind !== "input") || + responseSent || + pendingTOTPSessions[sessionId] + ) { + resolve(null); + return; + } + responseSent = true; + parkForAnswer( + (answers) => resolve(answers[0] ?? null), + [ + { + prompt: request.prompt, + echo: request.kind === "input" ? request.echo : false, + }, + ], + 0, + ); + res.json({ + requires_totp: true, + sessionId, + prompt: request.prompt, + ...(request.kind === "input" && !request.isPush + ? { isPassword: true } + : {}), + connectionLogs, + }); + }), + }; + try { - const transport = await ssh.openTransport(connectTarget, config); + const transport = await ssh.openTransport(connectTarget, config, { + prompt: jumpHostPrompt, + }); if (transport.via === "proxy") { connectionLogs.push( createConnectionLog( @@ -1162,6 +1203,11 @@ export async function activate(ctx: PluginContext) { sessionId, hostId, }); + // Already answered with a jump host prompt: fail the pending verify. + if (responseSent) { + client.emit("error", error); + return; + } const stage = isJumpHostChainError(error) || jumpHostCount > 0 ? "jump" : "proxy"; connectionLogs.push( diff --git a/plugins/host-metrics/src/backend/interactive.ts b/plugins/host-metrics/src/backend/interactive.ts index f4571496b..c2acef205 100644 --- a/plugins/host-metrics/src/backend/interactive.ts +++ b/plugins/host-metrics/src/backend/interactive.ts @@ -1,5 +1,8 @@ import { Client, type ConnectConfig } from "ssh2"; -import type { PluginSsh } from "@termix/plugin-sdk/backend"; +import type { + PluginSsh, + PluginSshPromptChannel, +} from "@termix/plugin-sdk/backend"; import { connectionLog, newSessionId, @@ -217,8 +220,48 @@ export async function startInteractive( connectionLog("info", "proxy", "Connecting via jump host chain"), ); } + const jumpHostPrompt: PluginSshPromptChannel = { + ask: (request) => + new Promise((answer) => { + if (request.kind !== "totp" || settled) { + answer(null); + return; + } + const sessionId = newSessionId("totp"); + sessions.addPending({ + client, + finish: (responses) => answer(responses[0] ?? null), + config, + createdAt: Date.now(), + sessionId, + hostId: host.id, + userId, + prompts: [{ prompt: request.prompt, echo: false }], + totpPromptIndex: 0, + resolvedPassword: host.password ?? undefined, + totpAttempts: 0, + }); + logs.push( + connectionLog( + "info", + "stats_totp", + "Jump host TOTP verification required", + ), + ); + settle(() => + resolve({ + success: false, + requires_totp: true, + sessionId, + prompt: request.prompt, + }), + ); + }), + }; ssh - .openTransport(host, config as Record) + .openTransport(host, config as Record, { + prompt: jumpHostPrompt, + }) .then((transport) => { if (transport.jumpClient) { const jumpClient = transport.jumpClient as Client; @@ -226,7 +269,9 @@ export async function startInteractive( } client.connect(config); }) - .catch((error) => + .catch((error) => { + // Already answered with a jump host prompt: fail the pending submit. + if (settled) client.emit("error", error); settle(() => { logs.push( connectionLog( @@ -236,8 +281,8 @@ export async function startInteractive( ), ); reject(error); - }), - ); + }); + }); }, ); return { status: 200, body: { ...result, connectionLogs: logs } }; diff --git a/plugins/host-metrics/tests/backend/interactive-jump-totp.test.ts b/plugins/host-metrics/tests/backend/interactive-jump-totp.test.ts new file mode 100644 index 000000000..2de87a25c --- /dev/null +++ b/plugins/host-metrics/tests/backend/interactive-jump-totp.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; +import type { PluginSsh } from "@termix/plugin-sdk/backend"; +import { startInteractive } from "../../src/backend/interactive.js"; +import type { MetricsHost } from "../../src/backend/helpers.js"; + +describe("startInteractive through a jump host", () => { + it("returns requires_totp when a jump host asks for a code", async () => { + let answer: Promise | null = null; + const ssh = { + prepare: vi.fn(async () => ({ + config: {}, + outcome: { status: "ready" }, + })), + openTransport: vi.fn( + ( + _host: unknown, + _config: unknown, + options?: { + prompt?: { ask: (r: unknown) => Promise }; + }, + ) => { + answer = options!.prompt!.ask({ + kind: "totp", + prompt: "Verification code:", + retry: false, + }); + return new Promise(() => {}); + }, + ), + } as unknown as PluginSsh; + const pending: Array<{ finish: (r: string[]) => void }> = []; + const sessions = { + get: () => undefined, + addPending: (session: { finish: (r: string[]) => void }) => + pending.push(session), + }; + const result = await startInteractive( + { + ssh, + sessions: sessions as never, + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } as never, + registerViewer: vi.fn(), + }, + { + id: 7, + ip: "10.0.0.1", + port: 22, + jumpHosts: [{ hostId: 2 }], + } as MetricsHost, + "u1", + ); + + expect(result.body).toMatchObject({ + requires_totp: true, + prompt: "Verification code:", + }); + pending[0].finish(["123456"]); + await expect(answer).resolves.toBe("123456"); + }); +}); diff --git a/plugins/ssh-terminal/src/backend/terminal-socket.ts b/plugins/ssh-terminal/src/backend/terminal-socket.ts index 398570d5c..f6b4ceeee 100644 --- a/plugins/ssh-terminal/src/backend/terminal-socket.ts +++ b/plugins/ssh-terminal/src/backend/terminal-socket.ts @@ -12,6 +12,7 @@ import { type PluginContext, type PluginSshHost, type PluginSshPrepared, + type PluginSshPromptChannel, type PluginWebSocketConnection, } from "@termix/plugin-sdk/backend"; import { @@ -2816,12 +2817,55 @@ export function createTerminalSocket(deps: TerminalSocketDeps) { !!connectTarget.useSocks5 && (!!connectTarget.socks5Host || proxyChain.length > 0); + // A jump host asking for TOTP or input uses the same messages as the + // target host, answered through totp_response / password_response. + const jumpHostPrompt: PluginSshPromptChannel = { + ask: (request) => + new Promise((resolve) => { + if ( + (request.kind !== "totp" && request.kind !== "input") || + ws.readyState !== WebSocket.OPEN + ) { + resolve(null); + return; + } + if (totpTimeout) clearTimeout(totpTimeout); + totpTimeout = setTimeout(() => { + keyboardInteractiveFinish = null; + totpPromptSent = false; + resolve(null); + }, 180000); + keyboardInteractiveFinish = (answers) => + resolve((answers[0] ?? "").trim()); + if (request.kind === "totp") { + totpPromptSent = true; + sendLog("auth", "info", "Jump host TOTP verification required"); + ws.send( + JSON.stringify( + request.retry + ? { type: "totp_retry" } + : { type: "totp_required", prompt: request.prompt }, + ), + ); + } else { + ws.send( + JSON.stringify({ + type: "password_required", + prompt: request.prompt, + echo: request.echo, + }), + ); + } + }), + }; + let transport: Awaited>; try { transport = await ctx.ssh.openTransport(connectTarget, connectConfig, { // Resolved above, or deliberately left to the jump host. resolveDns: false, log: (level, message) => sendLog("handshake", level, message), + prompt: jumpHostPrompt, }); } catch (error) { sshLogger.error("SSH transport failed", error, { diff --git a/src/backend/plugins/ctx-ssh-auth.ts b/src/backend/plugins/ctx-ssh-auth.ts index b08c5eac8..2da73c815 100644 --- a/src/backend/plugins/ctx-ssh-auth.ts +++ b/src/backend/plugins/ctx-ssh-auth.ts @@ -265,7 +265,11 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh { const userId = userFor(chainOptions?.forHost); const { createJumpHostChain } = await import("../hosts/jump-host-chain.js"); - const client = await createJumpHostChain(jumpHosts, userId); + const client = await createJumpHostChain( + jumpHosts, + userId, + chainOptions?.prompt as Parameters[2], + ); const details = `jump chain of ${jumpHosts.length} hop(s)`; if (!client) { await audit("ssh_jump_chain", details, {