diff --git a/electron/c2s-auth.cjs b/electron/c2s-auth.cjs new file mode 100644 index 000000000..693230048 --- /dev/null +++ b/electron/c2s-auth.cjs @@ -0,0 +1,78 @@ +const { randomUUID } = require("node:crypto"); + +function createC2SAuthBridge(ipcMain, getWindow) { + const pending = new Map(); + ipcMain.handle("answer-c2s-auth", (event, id, answer) => { + const item = pending.get(id); + if (!item || item.sender !== event.sender) return false; + if (answer !== null && (typeof answer !== "string" || answer.length > 8192)) + return false; + item.finish(answer); + return true; + }); + return function attach(ws, tunnelName) { + const active = new Set(); + const close = () => { + for (const id of active) pending.get(id)?.finish(null); + ws.off("message", onMessage); + ws.off("close", close); + ws.off("error", close); + }; + const onMessage = (raw, binary) => { + if (binary) return; + let message; + try { + message = JSON.parse(raw.toString()); + } catch { + return; + } + if (message.type !== "auth-prompt") return; + const window = getWindow(); + if ( + !window || + window.isDestroyed() || + active.size || + typeof message.requestId !== "string" || + !["totp", "input", "browser"].includes(message.request?.kind) + ) { + ws.close(); + return; + } + const sender = window.webContents; + const id = randomUUID(); + const finish = (answer) => { + if (!pending.delete(id)) return; + active.delete(id); + clearTimeout(timer); + sender.off("destroyed", cancelled); + sender.off("did-start-loading", cancelled); + if (!sender.isDestroyed()) + sender.send("c2s-auth-prompt", { id, closed: true }); + if (ws.readyState === 1) + ws.send( + JSON.stringify({ + type: "auth-response", + requestId: message.requestId, + answer, + }), + ); + if (answer === null) ws.close(); + }; + const cancelled = () => finish(null); + const timer = setTimeout(cancelled, 60_000); + active.add(id); + pending.set(id, { sender, finish }); + sender.once("destroyed", cancelled); + sender.once("did-start-loading", cancelled); + sender.send("c2s-auth-prompt", { + id, + tunnelName, + request: message.request, + }); + }; + ws.on("message", onMessage); + ws.once("close", close); + ws.once("error", close); + }; +} +module.exports = { createC2SAuthBridge }; diff --git a/electron/main.cjs b/electron/main.cjs index 6a2be3110..69dfb549d 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -2020,6 +2020,11 @@ async function getC2SRelayHeaders(relayUrl) { return headers; } +const attachC2SAuth = require("./c2s-auth.cjs").createC2SAuthBridge( + ipcMain, + () => mainWindow, +); + function getC2STunnelName(tunnel, index = 0) { if (tunnel.name) return tunnel.name; const localAddress = getC2SLocalAddress(tunnel); @@ -2136,6 +2141,7 @@ async function openC2SRelay( relayUrl, getWebSocketOptions(relayUrl, { headers }), ); + attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel)); const pendingChunks = []; let ready = false; let closed = false; @@ -2225,22 +2231,33 @@ async function openC2SRelay( }); } -async function testC2SRelay(tunnel, targetHost, targetPort, authToken) { +async function testC2SRelay( + tunnel, + targetHost, + targetPort, + authToken, + runtime, +) { const tunnelConfig = normalizeC2STunnelAddresses(tunnel); const relayUrl = getC2SRelayUrl(); const headers = await getC2SRelayHeaders(relayUrl, authToken); + if (runtime && c2sTunnelRuntimes.get(tunnel.name) !== runtime) { + return { success: false, error: "Tunnel stopped" }; + } const ws = new WebSocket( relayUrl, getWebSocketOptions(relayUrl, { headers }), ); + attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel)); + if (runtime) runtime.authSocket = ws; return new Promise((resolve) => { let settled = false; const settle = (result) => { if (settled) return; settled = true; try { - ws.close(); + if (!runtime || !result.success) ws.close(); } catch { // expected during shutdown } @@ -2249,12 +2266,13 @@ async function testC2SRelay(tunnel, targetHost, targetPort, authToken) { const timer = setTimeout(() => { settle({ success: false, error: "Tunnel test timed out" }); - }, 15000); + }, 180_000); ws.on("open", () => { ws.send( JSON.stringify({ type: "test", + keepAlive: !!runtime, tunnelConfig, targetHost, targetPort, @@ -2283,6 +2301,9 @@ async function testC2SRelay(tunnel, targetHost, targetPort, authToken) { settle(createC2SFailure(error.message, "Tunnel test failed")); }); ws.on("close", () => { + if (runtime && c2sTunnelRuntimes.get(tunnel.name) === runtime) { + setC2STunnelError(tunnel.name, "Endpoint SSH connection closed"); + } clearTimeout(timer); settle({ success: false, error: "Tunnel test connection closed" }); }); @@ -2535,6 +2556,7 @@ async function startC2SRemoteTunnel(tunnel, index = 0, authToken) { relayUrl, getWebSocketOptions(relayUrl, { headers }), ); + attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel)); const sockets = new Map(); let closed = false; @@ -2723,7 +2745,10 @@ async function startC2STunnel(tunnel, index = 0, authToken) { const resolvedTunnel = await resolveC2SRemoteSourceHost(tunnel); const mode = resolvedTunnel.mode || resolvedTunnel.tunnelType || "local"; const tunnelName = getC2STunnelName(resolvedTunnel, index); - const tunnelConfig = normalizeC2STunnelAddresses(resolvedTunnel); + const tunnelConfig = { + ...normalizeC2STunnelAddresses(resolvedTunnel), + sessionId: crypto.randomUUID(), + }; const bindHost = getC2SLocalAddress(tunnelConfig); const sourcePort = Number(resolvedTunnel.sourcePort); logToFile(`[c2s] starting tunnel ${tunnelName}`, { @@ -2774,6 +2799,10 @@ async function startC2STunnel(tunnel, index = 0, authToken) { const sockets = new Set(); const server = net.createServer((socket) => { + if (!c2sTunnelRuntimes.get(tunnelName)?.status.connected) { + socket.destroy(); + return; + } sockets.add(socket); socket.on("close", () => sockets.delete(socket)); if (mode === "dynamic") { @@ -2816,33 +2845,38 @@ async function startC2STunnel(tunnel, index = 0, authToken) { reason: "Verifying endpoint SSH connection", }); - const verifyTunnel = - mode === "dynamic" - ? testC2SRelay( - { ...tunnel, name: `${tunnelName}::verify`, mode }, - undefined, - undefined, - ) - : testC2SRelay( - { ...tunnel, name: `${tunnelName}::verify`, mode }, - tunnel.targetHost || "127.0.0.1", - Number(tunnel.endpointPort), - ); + const runtime = c2sTunnelRuntimes.get(tunnelName); + const verifyTunnel = testC2SRelay( + { ...tunnelConfig, name: tunnelName, mode }, + mode === "dynamic" ? undefined : getC2SRemoteAddress(tunnelConfig), + mode === "dynamic" ? undefined : Number(tunnelConfig.endpointPort), + authToken, + runtime, + ); - verifyTunnel.then((result) => { - if (!c2sTunnelRuntimes.has(tunnelName)) return; - if (result.success) { - setC2STunnelStatus(tunnelName, { - connected: true, - status: "CONNECTED", - }); - } else { - setC2STunnelError( - tunnelName, - result.error || "Endpoint SSH connection failed", - ); - } - }); + verifyTunnel + .then((result) => { + if (c2sTunnelRuntimes.get(tunnelName) !== runtime) return; + if (result.success) { + setC2STunnelStatus(tunnelName, { + connected: true, + status: "CONNECTED", + }); + } else { + setC2STunnelError( + tunnelName, + result.error || "Endpoint SSH connection failed", + ); + } + }) + .catch((error) => { + if (c2sTunnelRuntimes.get(tunnelName) === runtime) { + setC2STunnelError( + tunnelName, + error.message || "Endpoint SSH connection failed", + ); + } + }); resolve({ success: true, tunnelName }); }); @@ -2859,6 +2893,7 @@ async function stopC2STunnel(tunnelName) { connected: false, status: "DISCONNECTING", }); + runtime.authSocket?.close(); return new Promise((resolve) => { if (typeof runtime.close === "function") { @@ -2883,6 +2918,7 @@ async function stopC2STunnel(tunnelName) { function stopAllC2STunnels() { for (const [tunnelName, runtime] of c2sTunnelRuntimes.entries()) { try { + runtime.authSocket?.close(); if (typeof runtime.close === "function") { runtime.close(); } else { diff --git a/electron/preload.js b/electron/preload.js index 7ded60d62..133119513 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -32,6 +32,13 @@ contextBridge.exposeInMainWorld("electronAPI", { getSetting: (key) => ipcRenderer.invoke("get-setting", key), setSetting: (key, value) => ipcRenderer.invoke("set-setting", key, value), + answerC2SAuth: (id, answer) => + ipcRenderer.invoke("answer-c2s-auth", id, answer), + onC2SAuthPrompt: (callback) => { + const listener = (_event, prompt) => callback(prompt); + ipcRenderer.on("c2s-auth-prompt", listener); + return () => ipcRenderer.removeListener("c2s-auth-prompt", listener); + }, getC2STunnelConfig: () => ipcRenderer.invoke("get-c2s-tunnel-config"), saveC2STunnelConfig: (config) => ipcRenderer.invoke("save-c2s-tunnel-config", config), diff --git a/packages/plugin-sdk/src/desktop.ts b/packages/plugin-sdk/src/desktop.ts index f970407a0..86e73e633 100644 --- a/packages/plugin-sdk/src/desktop.ts +++ b/packages/plugin-sdk/src/desktop.ts @@ -3,6 +3,8 @@ * only inside the Termix desktop app; check isElectron before calling it. */ +import type { PluginSshPromptRequest } from "./backend.js"; + export interface ServerConfig { serverUrl?: string; allowInvalidCertificate?: boolean; @@ -65,6 +67,15 @@ export interface ElectronAPI { serverUrl: string, allowInvalidCertificate?: boolean, ) => Promise; + answerC2SAuth: (id: string, answer: string | null) => Promise; + onC2SAuthPrompt: ( + callback: (prompt: { + id: string; + closed?: boolean; + tunnelName?: string; + request?: PluginSshPromptRequest; + }) => void, + ) => () => void; getC2STunnelConfig: () => Promise; saveC2STunnelConfig: ( config: unknown[], diff --git a/plugins/tunnels/locales/en.json b/plugins/tunnels/locales/en.json index 9b1e272dc..fd37e8918 100644 --- a/plugins/tunnels/locales/en.json +++ b/plugins/tunnels/locales/en.json @@ -148,7 +148,11 @@ "remoteSessionExpired": "Remote Sync session expired. Reconnect Remote Sync and try again.", "activeTunnels": "Active Tunnels", "start": "Start", - "stop": "Stop" + "stop": "Stop", + "authTitle": "Tunnel authentication", + "authRetry": "The previous code was not accepted. Enter a new code.", + "authAnswer": "Authentication response", + "authContinue": "Continue" }, "permissions": { "use": { diff --git a/plugins/tunnels/src/backend/c2s-auth.ts b/plugins/tunnels/src/backend/c2s-auth.ts new file mode 100644 index 000000000..c8104cd1d --- /dev/null +++ b/plugins/tunnels/src/backend/c2s-auth.ts @@ -0,0 +1,53 @@ +import { randomUUID } from "node:crypto"; +import type { WebSocket } from "ws"; +import type { PluginSshPromptChannel } from "@termix/plugin-sdk/backend"; + +/** Answers belong to this authenticated socket and one outstanding challenge. */ +export function createC2SPrompt(ws: WebSocket): PluginSshPromptChannel { + return { + ask(request) { + if (ws.readyState !== 1) + return Promise.reject(new Error("Tunnel closed")); + const requestId = randomUUID(); + return new Promise((resolve, reject) => { + const finish = (answer: string | null, error?: Error) => { + clearTimeout(timer); + ws.off("message", onMessage); + ws.off("close", onClose); + ws.off("error", onClose); + if (error) reject(error); + else resolve(answer); + }; + const onClose = () => + finish(null, new Error("Tunnel authentication cancelled")); + const onMessage = (raw: Buffer, binary: boolean) => { + if (binary) return; + let value; + try { + value = JSON.parse(raw.toString()); + } catch { + return; + } + if (value.type !== "auth-response" || value.requestId !== requestId) + return; + if (value.answer === null) { + onClose(); + ws.close(); + } else if ( + typeof value.answer === "string" && + value.answer.length <= 8192 + ) + finish(value.answer); + }; + const timer = setTimeout(() => { + finish(null, new Error("Tunnel authentication timed out")); + ws.close(); + }, 60_000); + ws.on("message", onMessage); + ws.once("close", onClose); + ws.once("error", onClose); + ws.send(JSON.stringify({ type: "auth-prompt", requestId, request })); + }); + }, + }; +} diff --git a/plugins/tunnels/src/backend/c2s-connections.ts b/plugins/tunnels/src/backend/c2s-connections.ts new file mode 100644 index 000000000..ca699ee3e --- /dev/null +++ b/plugins/tunnels/src/backend/c2s-connections.ts @@ -0,0 +1,94 @@ +import { randomUUID } from "node:crypto"; +import type { Client } from "ssh2"; +import type { WebSocket } from "ws"; +import type { + PluginContext, + PluginSshConnection, +} from "@termix/plugin-sdk/backend"; +import { createC2SPrompt } from "./c2s-auth.js"; + +/** A desktop runtime holds one authenticated SSH connection across SOCKS streams. */ +export function createC2SConnections(ctx: PluginContext) { + type Entry = { + sockets: Set; + source: Promise>; + }; + const entries = new Map(); + ctx.disposables.add(() => { + for (const entry of entries.values()) + for (const ws of entry.sockets) ws.close(); + entries.clear(); + }); + return async (ws: WebSocket, hostId: number, sessionId?: string) => { + if (ws.readyState !== 1) throw new Error("Tunnel closed"); + const key = JSON.stringify([ + ctx.currentActor(), + hostId, + typeof sessionId === "string" && sessionId.length <= 128 + ? sessionId + : randomUUID(), + ]); + let entry = entries.get(key); + if (!entry) { + const source = ctx.ssh.connect(hostId, { + purpose: "tunnel", + profile: "forward", + timeoutMs: 60_000, + prompt: createC2SPrompt(ws), + }); + entry = { sockets: new Set(), source }; + entries.set(key, entry); + const own = entry; + void source.then( + (connection) => { + const close = () => { + if (entries.get(key) === own) entries.delete(key); + for (const socket of own.sockets) socket.close(); + }; + connection.client.once("close", close); + connection.client.once("error", close); + }, + () => { + if (entries.get(key) === own) entries.delete(key); + for (const socket of own.sockets) { + if (socket.readyState === 1) + socket.send( + JSON.stringify({ + type: "error", + error: "Endpoint SSH authentication failed", + }), + ); + socket.close(); + } + }, + ); + } + const own = entry; + own.sockets.add(ws); + let released = false; + const dispose = () => { + if (released) return; + released = true; + ws.off("close", dispose); + ws.off("error", dispose); + own.sockets.delete(ws); + if (!own.sockets.size) { + if (entries.get(key) === own) entries.delete(key); + void own.source.then( + (source) => source.dispose(), + () => {}, + ); + } + }; + ws.once("close", dispose); + ws.once("error", dispose); + try { + const source = await own.source; + if (released || ws.readyState !== 1) throw new Error("Tunnel closed"); + return { ...source, dispose }; + } catch (error) { + dispose(); + throw error; + } + }; +} diff --git a/plugins/tunnels/src/backend/c2s-relay.ts b/plugins/tunnels/src/backend/c2s-relay.ts index 88f1f8944..0ebf87e4a 100644 --- a/plugins/tunnels/src/backend/c2s-relay.ts +++ b/plugins/tunnels/src/backend/c2s-relay.ts @@ -1,10 +1,8 @@ import type { Duplex } from "node:stream"; -import type { Client, ClientChannel } from "ssh2"; +import type { ClientChannel } from "ssh2"; import type { WebSocket } from "ws"; -import type { - PluginContext, - PluginSshConnection, -} from "@termix/plugin-sdk/backend"; +import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { createC2SConnections } from "./c2s-connections.js"; import type { TunnelMode } from "./types.js"; import { bindForwardIn, @@ -16,6 +14,7 @@ import { getTunnelMode } from "./utils.js"; /** What the desktop app sends in the first message on the relay socket. */ export interface C2SRelayTunnel { name?: string; + sessionId?: string; mode?: TunnelMode; tunnelType?: "local" | "remote"; localAddress?: string; @@ -30,6 +29,7 @@ export interface C2SRelayTunnel { export interface C2SOpenMessage { type: "open" | "test"; + keepAlive?: boolean; tunnelConfig?: C2SRelayTunnel; targetHost?: string; targetPort?: number; @@ -151,6 +151,7 @@ function address(value: unknown, fallback: string): string { interface ResolvedRelay { name: string; + sessionId?: string; hostId: number; mode: TunnelMode; remoteAddress: string; @@ -165,6 +166,7 @@ interface ResolvedRelay { */ export function createC2SRelay(ctx: PluginContext) { let streamCounter = 0; + const connectSource = createC2SConnections(ctx); async function findHostIdBySyncId(syncId: string): Promise { /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -188,6 +190,7 @@ export function createC2SRelay(ctx: PluginContext) { return { name: tunnel.name || `c2s:${hostId}`, hostId, + sessionId: tunnel.sessionId, mode: getTunnelMode({ mode: tunnel.mode, tunnelType: tunnel.tunnelType, @@ -201,14 +204,6 @@ export function createC2SRelay(ctx: PluginContext) { }; } - function connectSource(hostId: number): Promise> { - return ctx.ssh.connect(hostId, { - purpose: "tunnel", - profile: "forward", - timeoutMs: 60_000, - }); - } - async function openRemote(ws: WebSocket, relay: ResolvedRelay) { const bindHost = relay.remoteAddress; const bindPort = relay.sourcePort; @@ -216,7 +211,7 @@ export function createC2SRelay(ctx: PluginContext) { throw new Error("Invalid remote port"); } - const source = await connectSource(relay.hostId); + const source = await connectSource(ws, relay.hostId, relay.sessionId); const sourceClient = source.client; let actualPort: number; try { @@ -226,6 +221,11 @@ export function createC2SRelay(ctx: PluginContext) { throw error; } + if (ws.readyState !== 1) { + unbindForwardIn(sourceClient, bindHost, actualPort, ctx.log); + source.dispose(); + return; + } const streams = new Map(); let closed = false; @@ -340,7 +340,7 @@ export function createC2SRelay(ctx: PluginContext) { throw new Error("Invalid client tunnel target"); } - const source = await connectSource(relay.hostId); + const source = await connectSource(ws, relay.hostId, relay.sessionId); let outbound: ClientChannel; try { outbound = await forwardOut(source.client, targetHost, targetPort); @@ -349,6 +349,11 @@ export function createC2SRelay(ctx: PluginContext) { throw error; } + if (ws.readyState !== 1) { + outbound.destroy(); + source.dispose(); + return; + } const close = () => { try { outbound.destroy(); @@ -384,7 +389,7 @@ export function createC2SRelay(ctx: PluginContext) { async function test(ws: WebSocket, message: C2SOpenMessage): Promise { const relay = await resolve(message.tunnelConfig ?? {}); - const source = await connectSource(relay.hostId); + const source = await connectSource(ws, relay.hostId, relay.sessionId); try { if (relay.mode === "remote") { const bindPort = relay.sourcePort; @@ -410,7 +415,7 @@ export function createC2SRelay(ctx: PluginContext) { } sendMessage(ws, { type: "ready" }); } finally { - source.dispose(); + if (!message.keepAlive) source.dispose(); } } diff --git a/plugins/tunnels/src/frontend/TunnelAuthPrompts.tsx b/plugins/tunnels/src/frontend/TunnelAuthPrompts.tsx new file mode 100644 index 000000000..b3f12eccb --- /dev/null +++ b/plugins/tunnels/src/frontend/TunnelAuthPrompts.tsx @@ -0,0 +1,135 @@ +import { useEffect, useRef, useState } from "react"; +import type { PluginSshPromptRequest } from "@termix/plugin-sdk/backend"; +import { useTranslation } from "@termix/plugin-sdk/frontend"; +import { + Button, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + Input, +} from "@termix/plugin-sdk/ui"; + +type Challenge = { + id: string; + tunnelName?: string; + request: PluginSshPromptRequest; +}; + +export function TunnelAuthPrompts() { + const [queue, setQueue] = useState([]); + const pending = useRef(new Set()); + useEffect(() => { + const active = pending.current; + const api = window.electronAPI; + if (!api?.onC2SAuthPrompt) return; + const dispose = api.onC2SAuthPrompt((event) => { + if (event.closed) { + active.delete(event.id); + setQueue((items) => items.filter((item) => item.id !== event.id)); + } else if (event.request && !active.has(event.id)) { + active.add(event.id); + setQueue((items) => [...items, { ...event, request: event.request! }]); + } + }); + return () => { + dispose(); + for (const id of active) void api.answerC2SAuth(id, null); + active.clear(); + }; + }, []); + const current = queue[0]; + return current ? ( + { + pending.current.delete(current.id); + setQueue((items) => items.filter((item) => item.id !== current.id)); + void window.electronAPI.answerC2SAuth(current.id, answer); + }} + /> + ) : null; +} + +function AuthDialog({ + challenge, + onAnswer, +}: { + challenge: Challenge; + onAnswer: (answer: string | null) => void; +}) { + const { t } = useTranslation(); + const [answer, setAnswer] = useState(""); + const request = challenge.request; + const browser = request.kind === "browser"; + const url = + browser && request.url && /^https?:\/\//i.test(request.url) + ? request.url + : null; + return ( + { + if (!open) onAnswer(null); + }} + > + + + {t("tunnels.authTitle")} + +

{challenge.tunnelName}

+
{ + event.preventDefault(); + onAnswer(answer); + }} + className="space-y-3" + > +

+ {browser ? request.instructions : request.prompt} +

+ {request.kind === "totp" && request.retry && ( +

{t("tunnels.authRetry")}

+ )} + {browser ? ( + <> +

{request.code}

+ {url && ( + + {url} + + )} + + ) : ( + setAnswer(event.target.value)} + /> + )} +
+ + +
+
+
+
+ ); +} diff --git a/plugins/tunnels/src/frontend/index.tsx b/plugins/tunnels/src/frontend/index.tsx index ddcc4d8cf..f7979d697 100644 --- a/plugins/tunnels/src/frontend/index.tsx +++ b/plugins/tunnels/src/frontend/index.tsx @@ -1,6 +1,7 @@ import type { ComponentType } from "react"; import { Network, Plug } from "lucide-react"; import type { TabProps, TermixApp } from "@termix/plugin-sdk/frontend"; +import { TunnelAuthPrompts } from "./TunnelAuthPrompts"; import { TunnelTab } from "./TunnelTab"; import { HostTunnelsSection } from "./HostTunnelsSection"; import { @@ -27,6 +28,12 @@ export function activate(app: TermixApp): void { if (typeof window !== "undefined" && window.electronAPI?.setC2SRelayPath) { void window.electronAPI.setC2SRelayPath(C2S_RELAY_PATH); } + app.registerSlotContribution("shell.overlay", { + actionId: "tunnels.authPrompts", + titleKey: "tunnels.authTitle", + kind: "component", + component: TunnelAuthPrompts as ComponentType>, + }); // apiFor("remote") hands back app.api itself outside the desktop app. const remote = app.apiFor("remote"); setTunnelsApi(app.api, { diff --git a/plugins/tunnels/tests/backend/c2s-auth.test.ts b/plugins/tunnels/tests/backend/c2s-auth.test.ts new file mode 100644 index 000000000..55fd4a971 --- /dev/null +++ b/plugins/tunnels/tests/backend/c2s-auth.test.ts @@ -0,0 +1,155 @@ +import { EventEmitter } from "node:events"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { WebSocket } from "ws"; +import type { + PluginContext, + PluginSshConnectOptions, +} from "@termix/plugin-sdk/backend"; +import { createC2SPrompt } from "../../src/backend/c2s-auth.js"; +import { createC2SConnections } from "../../src/backend/c2s-connections.js"; + +class Socket extends EventEmitter { + readyState = 1; + send = vi.fn(); + close() { + this.readyState = 3; + this.emit("close"); + } + get ws() { + return this as unknown as WebSocket; + } + answer(requestId: string, answer: string | null) { + this.emit( + "message", + Buffer.from(JSON.stringify({ type: "auth-response", requestId, answer })), + false, + ); + } +} +const flush = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; +afterEach(() => vi.useRealTimers()); + +describe("client tunnel authentication", () => { + it("accepts only the current challenge on its socket and removes answer listeners", async () => { + const socket = new Socket(); + const prompt = createC2SPrompt(socket.ws); + const result = prompt.ask({ kind: "totp", prompt: "Code:", retry: false }); + const sent = JSON.parse(socket.send.mock.calls[0][0]); + socket.answer("stale", "wrong"); + expect(socket.listenerCount("message")).toBe(1); + socket.answer(sent.requestId, "123456"); + await expect(result).resolves.toBe("123456"); + expect(socket.listenerCount("message")).toBe(0); + expect(socket.send).toHaveBeenCalledTimes(1); + const retry = prompt.ask({ kind: "totp", prompt: "Code:", retry: true }); + socket.answer(sent.requestId, "old"); + socket.close(); + await expect(retry).rejects.toThrow("cancelled"); + }); + + it("cancels on a null answer and bounds unanswered prompts", async () => { + vi.useFakeTimers(); + const cancelled = new Socket(); + const request = createC2SPrompt(cancelled.ws).ask({ + kind: "input", + prompt: "Password:", + echo: false, + isPush: false, + }); + cancelled.answer( + JSON.parse(cancelled.send.mock.calls[0][0]).requestId, + null, + ); + await expect(request).rejects.toThrow("cancelled"); + expect(cancelled.readyState).toBe(3); + const socket = new Socket(); + const result = createC2SPrompt(socket.ws).ask({ + kind: "totp", + prompt: "Code:", + retry: false, + }); + const failed = expect(result).rejects.toThrow("timed out"); + await vi.advanceTimersByTimeAsync(60_000); + await failed; + expect(socket.readyState).toBe(3); + expect(socket.listenerCount("message")).toBe(0); + }); + + it("reuses one authenticated source for concurrent streams, scoped to user, host and desktop runtime", async () => { + let actor = "alice"; + const disposals: Array<() => void> = []; + const connections: Array<{ + client: EventEmitter; + dispose: ReturnType; + }> = []; + const connect = vi.fn( + async (_id: number, options: PluginSshConnectOptions) => { + expect(options.prompt).toBeDefined(); + const result = { + client: new EventEmitter(), + host: {}, + jumpClient: null, + dispose: vi.fn(), + }; + connections.push(result); + return result; + }, + ); + const acquire = createC2SConnections({ + currentActor: () => actor, + disposables: { add: (fn: () => void) => disposals.push(fn) }, + ssh: { connect }, + } as unknown as PluginContext); + const anchor = new Socket(); + const first = new Socket(); + const second = new Socket(); + await Promise.all([ + acquire(anchor.ws, 1, "runtime-a"), + acquire(first.ws, 1, "runtime-a"), + acquire(second.ws, 1, "runtime-a"), + ]); + expect(connect).toHaveBeenCalledTimes(1); + first.close(); + second.close(); + await flush(); + expect(connections[0].dispose).not.toHaveBeenCalled(); + await acquire(new Socket().ws, 1, "runtime-b"); + await acquire(new Socket().ws, 2, "runtime-a"); + actor = "bob"; + await acquire(new Socket().ws, 1, "runtime-a"); + expect(connect).toHaveBeenCalledTimes(4); + anchor.close(); + await flush(); + expect(connections[0].dispose).toHaveBeenCalledOnce(); + disposals.forEach((dispose) => dispose()); + await flush(); + expect( + connections.every((entry) => entry.dispose.mock.calls.length === 1), + ).toBe(true); + }); + + it("disposes a connection that authenticates after the desktop stopped", async () => { + let ready!: (value: unknown) => void; + const source = { client: new EventEmitter(), dispose: vi.fn() }; + const acquire = createC2SConnections({ + currentActor: () => "alice", + disposables: { add: vi.fn() }, + ssh: { + connect: () => + new Promise((resolve) => { + ready = resolve; + }), + }, + } as unknown as PluginContext); + const socket = new Socket(); + const pending = acquire(socket.ws, 1, "stopped"); + socket.close(); + ready(source); + await expect(pending).rejects.toThrow("closed"); + await flush(); + expect(source.dispose).toHaveBeenCalledOnce(); + }); +}); diff --git a/plugins/tunnels/tests/backend/c2s-relay-auth.test.ts b/plugins/tunnels/tests/backend/c2s-relay-auth.test.ts new file mode 100644 index 000000000..48c234a0a --- /dev/null +++ b/plugins/tunnels/tests/backend/c2s-relay-auth.test.ts @@ -0,0 +1,108 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { describe, expect, it, vi } from "vitest"; +import type { WebSocket } from "ws"; +import type { PluginSshConnectOptions } from "@termix/plugin-sdk/backend"; +import { createMockCtx } from "@termix/plugin-sdk/testing"; +import { createC2SRelay } from "../../src/backend/c2s-relay.js"; +import { host, manifest } from "./helpers.js"; +class Socket extends EventEmitter { + readyState = 1; + send = vi.fn(); + close() { + this.readyState = 3; + this.emit("close"); + } + message(value: unknown) { + this.emit("message", Buffer.from(JSON.stringify(value)), false); + } + get ws() { + return this as unknown as WebSocket; + } + messages() { + return this.send.mock.calls.map(([data]) => JSON.parse(String(data))); + } +} + +describe("interactive client relay", () => { + it("authenticates preflight once and forwards subsequent SOCKS streams on the retained connection", async () => { + const mock = createMockCtx({ + pluginId: "tunnels", + manifest, + capabilities: manifest.capabilities, + permissions: ["tunnels.use"], + hosts: [host()], + }); + const stream = new PassThrough(); + const client = Object.assign(new EventEmitter(), { + forwardOut: vi.fn((_ip, _port, _target, _targetPort, callback) => + callback(null, stream), + ), + }); + const dispose = vi.fn(); + const connect = vi.fn( + async (_id: number, options: PluginSshConnectOptions) => { + expect( + await options.prompt!.ask({ + kind: "totp", + prompt: "Verification code:", + retry: false, + }), + ).toBe("123456"); + return { client, dispose, host: host(), jumpClient: null }; + }, + ); + mock.ctx.ssh.connect = connect as typeof mock.ctx.ssh.connect; + const handle = createC2SRelay(mock.ctx); + const anchor = new Socket(); + const socket = new Socket(); + const tunnelConfig = { + sourceHostId: 7, + mode: "dynamic", + sessionId: "desktop-runtime", + }; + try { + handle(anchor.ws, "user-1"); + anchor.message({ type: "test", keepAlive: true, tunnelConfig }); + await vi.waitFor(() => + expect(anchor.messages()[0]?.type).toBe("auth-prompt"), + ); + anchor.message({ + type: "auth-response", + requestId: anchor.messages()[0].requestId, + answer: "123456", + }); + await vi.waitFor(() => + expect(anchor.messages().at(-1)?.type).toBe("ready"), + ); + expect(dispose).not.toHaveBeenCalled(); + handle(socket.ws, "user-1"); + socket.message({ + type: "open", + tunnelConfig, + targetHost: "internal.example", + targetPort: 443, + }); + await vi.waitFor(() => + expect(socket.messages().at(-1)?.type).toBe("ready"), + ); + expect(connect).toHaveBeenCalledOnce(); + expect(client.forwardOut).toHaveBeenCalledWith( + "127.0.0.1", + 0, + "internal.example", + 443, + expect.any(Function), + ); + socket.close(); + expect(dispose).not.toHaveBeenCalled(); + anchor.close(); + await vi.waitFor(() => expect(dispose).toHaveBeenCalledOnce()); + } finally { + socket.close(); + anchor.close(); + stream.destroy(); + for (const cleanup of mock.disposals) await cleanup(); + } + }); +}); diff --git a/plugins/tunnels/tests/frontend/auth-prompts.test.tsx b/plugins/tunnels/tests/frontend/auth-prompts.test.tsx new file mode 100644 index 000000000..198a8e985 --- /dev/null +++ b/plugins/tunnels/tests/frontend/auth-prompts.test.tsx @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, screen } from "@testing-library/react"; +import { + renderWithApp, + type RenderedPluginApp, +} from "@termix/plugin-sdk/testing"; +import type { PluginManifest } from "@termix/plugin-sdk/manifest"; +import * as plugin from "../../src/frontend/index"; +import manifestJson from "../../manifest.json"; +import locales from "../../locales/en.json"; +let rendered: RenderedPluginApp | null = null; +const original = window.electronAPI; +afterEach(async () => { + await act(async () => { + await rendered?.deactivate(); + }); + rendered = null; + window.electronAPI = original; +}); + +describe("tunnel prompt overlay", () => { + it("queues challenges, clears the previous code, and cancels on plugin disposal", async () => { + type Event = Parameters< + Parameters[0] + >[0]; + let receive!: (event: Event) => void; + const answer = vi.fn().mockResolvedValue(true); + const unsubscribe = vi.fn(); + window.electronAPI = { + ...original, + answerC2SAuth: answer, + onC2SAuthPrompt: (fn) => { + receive = fn; + return unsubscribe; + }, + }; + rendered = await renderWithApp(plugin, { + manifest: manifestJson as unknown as PluginManifest, + locales, + }); + rendered.renderSlot("shell.overlay"); + act(() => { + receive({ + id: "one", + tunnelName: "SOCKS", + request: { kind: "totp", prompt: "Code:", retry: false }, + }); + receive({ + id: "two", + tunnelName: "Other", + request: { kind: "totp", prompt: "Code:", retry: true }, + }); + }); + const input = screen.getByLabelText("Authentication response"); + expect(input.getAttribute("type")).toBe("password"); + fireEvent.change(input, { target: { value: "123456" } }); + fireEvent.click(screen.getByText("Continue")); + expect(answer).toHaveBeenCalledWith("one", "123456"); + expect( + (screen.getByLabelText("Authentication response") as HTMLInputElement) + .value, + ).toBe(""); + expect(screen.getByRole("alert").textContent).toContain("previous code"); + act(() => receive({ id: "two", closed: true })); + expect(screen.queryByRole("dialog")).toBeNull(); + act(() => + receive({ + id: "three", + request: { + kind: "input", + prompt: "Password:", + echo: false, + isPush: false, + }, + }), + ); + await act(async () => { + await rendered!.deactivate(); + }); + rendered = null; + expect(answer).toHaveBeenCalledWith("three", null); + expect(unsubscribe).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/backend/hosts/connect/connect-host.ts b/src/backend/hosts/connect/connect-host.ts index 081be48bf..8f5b3c2d2 100644 --- a/src/backend/hosts/connect/connect-host.ts +++ b/src/backend/hosts/connect/connect-host.ts @@ -120,6 +120,7 @@ export async function connectHost( } else { ({ jumpClient } = await openSshTransport(host, config, { log: options.log, + prompt: options.prompt, ...options.transport, })); } diff --git a/src/backend/hosts/connect/transport.ts b/src/backend/hosts/connect/transport.ts index 31d9400c4..f75137abf 100644 --- a/src/backend/hosts/connect/transport.ts +++ b/src/backend/hosts/connect/transport.ts @@ -19,6 +19,7 @@ import type { MutableConnectConfig, SshAuthLog, SshConnectHost, + SshPromptChannel, } from "./types.js"; function getHostSocks5Config(host: SshConnectHost): SOCKS5Config | null { @@ -48,6 +49,7 @@ class SshTransportError extends Error { } export interface OpenTransportOptions { + prompt?: SshPromptChannel; /** Knock before connecting when the host has a sequence. Default true. */ portKnock?: boolean; /** Resolve DNS up front for a direct connection. Default true. */ @@ -111,7 +113,11 @@ export async function openSshTransport( const jumpUserId = host.userId || ""; if (host.jumpHosts && host.jumpHosts.length > 0 && jumpUserId) { - const jumpClient = await createJumpHostChain(host.jumpHosts, jumpUserId); + const jumpClient = await createJumpHostChain( + host.jumpHosts, + jumpUserId, + options.prompt, + ); if (!jumpClient) { throw new SshTransportError( "Failed to connect through jump hosts", diff --git a/src/backend/hosts/jump-host-chain.ts b/src/backend/hosts/jump-host-chain.ts index 35f881b7b..6b0bcca48 100644 --- a/src/backend/hosts/jump-host-chain.ts +++ b/src/backend/hosts/jump-host-chain.ts @@ -4,8 +4,11 @@ import { createSocks5Connection } from "../utils/socks5-helper.js"; import { getErrorMessage } from "../utils/error-message.js"; import { getJumpHostSocks5Config } from "./jump-host-proxy.js"; import { buildConnectConfig } from "./connect/build-connect-config.js"; -import { createAutoKeyboardInteractiveHandler } from "./connect/keyboard-interactive.js"; -import type { SshConnectHost } from "./connect/types.js"; +import { + createAutoKeyboardInteractiveHandler, + createPromptKeyboardInteractiveHandler, +} from "./connect/keyboard-interactive.js"; +import type { SshConnectHost, SshPromptChannel } from "./connect/types.js"; import { resolveHostById } from "./host-resolver.js"; type JumpHostConfig = { @@ -61,6 +64,7 @@ class JumpHostChainError extends Error { export async function createJumpHostChain( jumpHosts: Array<{ hostId: number }>, userId: string, + prompt?: SshPromptChannel, ): Promise { if (!jumpHosts || jumpHosts.length === 0) { return null; @@ -68,6 +72,12 @@ export async function createJumpHostChain( let currentClient: SSHClient | null = null; const clients: SSHClient[] = []; + let closed = false; + const closeChain = () => { + if (closed) return; + closed = true; + for (const client of clients) client.end(); + }; try { const jumpHostConfigs: Array>> = @@ -87,7 +97,7 @@ export async function createJumpHostChain( hopIndex: i, totalHops, }); - clients.forEach((c) => c.end()); + closeChain(); throw new JumpHostChainError( `Jump host ${i + 1} of ${totalHops} was not found`, i, @@ -108,10 +118,12 @@ export async function createJumpHostChain( } for (let i = 0; i < jumpHostConfigs.length; i++) { + if (closed) throw new Error("Jump host chain closed"); const jumpHostConfig = jumpHostConfigs[i]!; const jumpClient = new SSHClient(); clients.push(jumpClient); + jumpClient.once("close", closeChain); let lastError: Error | null = null; @@ -128,6 +140,11 @@ export async function createJumpHostChain( jumpClient.end(); }, readyTimeoutMs + 5000); + jumpClient.once("close", () => { + clearTimeout(timeout); + lastError = new Error("Jump host connection closed"); + resolve(false); + }); jumpClient.on("ready", () => { clearTimeout(timeout); resolve(true); @@ -161,8 +178,20 @@ export async function createJumpHostChain( // every auth type, not just password, key and agent. const built = await buildConnectConfig( jumpHostConfig as unknown as SshConnectHost, - { userId, purpose: "jump-host", profile: "jump", client: jumpClient }, + { + userId, + purpose: "jump-host", + profile: "jump", + client: jumpClient, + interactive: !!prompt, + hostKeySocket: prompt?.hostKeySocket ?? null, + }, ); + if (closed) { + clearTimeout(timeout); + resolve(false); + return; + } if (built.outcome.status !== "ready") { clearTimeout(timeout); lastError = new Error( @@ -176,9 +205,28 @@ export async function createJumpHostChain( jumpClient.on( "keyboard-interactive", - createAutoKeyboardInteractiveHandler( - jumpHostConfig as unknown as SshConnectHost, - ), + prompt + ? createPromptKeyboardInteractiveHandler( + jumpHostConfig as unknown as SshConnectHost, + { + ...prompt, + ask: (request) => + prompt.ask( + request.kind === "browser" + ? { + ...request, + instructions: `Jump host ${i + 1}/${totalHops} (${jumpHostConfig.ip}): ${request.instructions}`, + } + : { + ...request, + prompt: `Jump host ${i + 1}/${totalHops} (${jumpHostConfig.ip}): ${request.prompt}`, + }, + ), + }, + ) + : createAutoKeyboardInteractiveHandler( + jumpHostConfig as unknown as SshConnectHost, + ), ); if (currentClient) { @@ -207,7 +255,7 @@ export async function createJumpHostChain( }); if (!connected) { - clients.forEach((c) => c.end()); + closeChain(); throw new JumpHostChainError( getErrorMessage( lastError, @@ -227,7 +275,7 @@ export async function createJumpHostChain( fileLogger.error("Failed to create jump host chain", error, { operation: "jump_host_chain", }); - clients.forEach((c) => c.end()); + closeChain(); return null; } } diff --git a/src/backend/tests/electron/c2s-auth.test.ts b/src/backend/tests/electron/c2s-auth.test.ts new file mode 100644 index 000000000..429490b5d --- /dev/null +++ b/src/backend/tests/electron/c2s-auth.test.ts @@ -0,0 +1,86 @@ +import { createRequire } from "node:module"; +import { EventEmitter } from "node:events"; +import { afterEach, describe, expect, it, vi } from "vitest"; +const require = createRequire(import.meta.url); +const { createC2SAuthBridge } = require("../../../../electron/c2s-auth.cjs"); + +function setup() { + const sender = Object.assign(new EventEmitter(), { + send: vi.fn(), + isDestroyed: () => false, + }); + const ws = Object.assign(new EventEmitter(), { + readyState: 1, + send: vi.fn(), + close: vi.fn(() => { + ws.readyState = 3; + ws.emit("close"); + }), + }); + let answer!: ( + event: { sender: unknown }, + id: string, + value: unknown, + ) => boolean; + const attach = createC2SAuthBridge( + { + handle: (_name: string, fn: typeof answer) => { + answer = fn; + }, + }, + () => ({ isDestroyed: () => false, webContents: sender }), + ); + attach(ws, "dynamic"); + const prompt = () => + ws.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "auth-prompt", + requestId: "server-challenge", + request: { kind: "totp", prompt: "Code:", retry: false }, + }), + ), + false, + ); + return { sender, ws, answer, prompt }; +} +afterEach(() => vi.useRealTimers()); +describe("desktop tunnel prompt bridge", () => { + it("binds answers to the renderer and challenge, and rejects replay", () => { + const { sender, ws, answer, prompt } = setup(); + prompt(); + const id = sender.send.mock.calls[0][1].id; + expect(answer({ sender: {} }, id, "123456")).toBe(false); + expect(answer({ sender }, "unknown", "123456")).toBe(false); + expect(answer({ sender }, id, { secret: "bad" })).toBe(false); + expect(ws.send).not.toHaveBeenCalled(); + expect(answer({ sender }, id, "123456")).toBe(true); + expect(JSON.parse(ws.send.mock.calls[0][0])).toEqual({ + type: "auth-response", + requestId: "server-challenge", + answer: "123456", + }); + expect(answer({ sender }, id, "again")).toBe(false); + expect(sender.listenerCount("destroyed")).toBe(0); + ws.close(); + }); + it.each(["close", "reload", "timeout"])( + "clears a pending dialog on %s", + (reason) => { + vi.useFakeTimers(); + const { sender, ws, answer, prompt } = setup(); + prompt(); + const id = sender.send.mock.calls[0][1].id; + if (reason === "close") ws.close(); + else if (reason === "reload") sender.emit("did-start-loading"); + else vi.advanceTimersByTime(60_000); + expect(sender.send).toHaveBeenLastCalledWith("c2s-auth-prompt", { + id, + closed: true, + }); + expect(answer({ sender }, id, "late")).toBe(false); + expect(ws.close).toHaveBeenCalled(); + }, + ); +}); diff --git a/src/backend/tests/electron/c2s-runtime.test.ts b/src/backend/tests/electron/c2s-runtime.test.ts new file mode 100644 index 000000000..8db0a24e4 --- /dev/null +++ b/src/backend/tests/electron/c2s-runtime.test.ts @@ -0,0 +1,143 @@ +import { EventEmitter } from "node:events"; +import { readFileSync } from "node:fs"; +import vm from "node:vm"; +import { describe, expect, it, vi } from "vitest"; + +const main = readFileSync( + new URL("../../../../electron/main.cjs", import.meta.url), + "utf8", +); +const section = (from: string, to: string) => + main.slice(main.indexOf(from), main.indexOf(to, main.indexOf(from))); +function setup() { + const sockets: Socket[] = []; + class Socket extends EventEmitter { + readyState = 1; + send = vi.fn(); + close = vi.fn(() => { + if (this.readyState === 3) return; + this.readyState = 3; + this.emit("close"); + }); + constructor() { + super(); + sockets.push(this); + queueMicrotask(() => this.emit("open")); + } + ready() { + this.emit( + "message", + Buffer.from(JSON.stringify({ type: "ready" })), + false, + ); + } + } + const runtimes = new Map(); + let incoming!: (socket: EventEmitter) => void; + const server = Object.assign(new EventEmitter(), { + listen: (_options: unknown, ready: () => void) => ready(), + close: vi.fn((done?: () => void) => done?.()), + }); + const headers = vi.fn(async () => ({})); + const dynamic = vi.fn(); + const context = vm.createContext({ + setTimeout, + clearTimeout, + WebSocket: Socket, + crypto: { randomUUID: () => "runtime-token" }, + net: { + createServer: (callback: typeof incoming) => { + incoming = callback; + return server; + }, + }, + c2sTunnelRuntimes: runtimes, + normalizeC2STunnelAddresses: (t: unknown) => t, + resolveC2SRemoteSourceHost: async (t: unknown) => t, + getC2STunnelName: (t: { name: string }) => t.name, + getC2SLocalAddress: () => "127.0.0.1", + getC2SRemoteAddress: () => "127.0.0.1", + getC2SRelayUrl: () => "wss://server/relay", + getC2SRelayHeaders: headers, + getWebSocketOptions: (_url: string, options: unknown) => options, + attachC2SAuth: vi.fn(), + logToFile: vi.fn(), + emitC2STunnelStatuses: vi.fn(), + checkLocalPortAvailable: async () => ({ available: true }), + handleC2SDynamicConnection: dynamic, + setC2STunnelStatus: (name: string, status: unknown) => { + runtimes.get(name).status = status; + }, + setC2STunnelError: (name: string, error: string) => { + runtimes.get(name).status = { connected: false, reason: error }; + }, + createC2SFailure: (error: string) => ({ success: false, error }), + }); + vm.runInContext( + section("async function testC2SRelay(", "async function testC2STunnel(") + + section("async function startC2STunnel(", "function stopAllC2STunnels("), + context, + ); + return { + context, + runtimes, + sockets, + server, + headers, + dynamic, + incoming: () => incoming, + }; +} + +describe("client tunnel runtime authentication", () => { + it("holds preflight open, shares its runtime identity and token, and releases it when stopped", async () => { + const { context, runtimes, sockets, server, headers, dynamic, incoming } = + setup(); + const tunnel = { + name: "socks", + mode: "dynamic", + sourceHostId: 7, + sourcePort: 1080, + }; + await context.startC2STunnel(tunnel, 0, "local-auth"); + await vi.waitFor(() => expect(sockets).toHaveLength(1)); + const premature = Object.assign(new EventEmitter(), { destroy: vi.fn() }); + incoming()(premature); + expect(premature.destroy).toHaveBeenCalledOnce(); + expect(headers).toHaveBeenCalledWith("wss://server/relay", "local-auth"); + expect(JSON.parse(sockets[0].send.mock.calls[0][0])).toMatchObject({ + keepAlive: true, + tunnelConfig: { sessionId: "runtime-token" }, + }); + sockets[0].ready(); + await vi.waitFor(() => + expect(runtimes.get("socks").status.connected).toBe(true), + ); + expect(sockets[0].close).not.toHaveBeenCalled(); + const client = Object.assign(new EventEmitter(), { destroy: vi.fn() }); + incoming()(client); + expect(dynamic).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: "runtime-token" }), + client, + "local-auth", + ); + await context.stopC2STunnel("socks"); + expect(sockets[0].close).toHaveBeenCalledOnce(); + expect(client.destroy).toHaveBeenCalledOnce(); + expect(server.close).toHaveBeenCalledOnce(); + expect(runtimes.size).toBe(0); + }); + + it("does not retain a socket for a standalone test or a stopped runtime", async () => { + const { context, sockets } = setup(); + const test = context.testC2SRelay({ name: "test" }); + await vi.waitFor(() => expect(sockets).toHaveLength(1)); + sockets[0].ready(); + expect(await test).toEqual({ success: true }); + expect(sockets[0].close).toHaveBeenCalledOnce(); + expect( + await context.testC2SRelay({ name: "stopped" }, null, null, null, {}), + ).toEqual({ success: false, error: "Tunnel stopped" }); + expect(sockets).toHaveLength(1); + }); +}); diff --git a/src/backend/tests/hosts/connect/pipeline.test.ts b/src/backend/tests/hosts/connect/pipeline.test.ts index fae4eebd7..b1c43dc59 100644 --- a/src/backend/tests/hosts/connect/pipeline.test.ts +++ b/src/backend/tests/hosts/connect/pipeline.test.ts @@ -518,6 +518,89 @@ describe("connectHost", () => { dispose(); }); + it("answers TOTP on key and password jump hops and closes the entire chain", async () => { + const { createJumpHostChain } = await vi.importActual< + typeof import("../../../hosts/jump-host-chain.js") + >("../../../hosts/jump-host-chain.js"); + mocks.resolveHostById + .mockResolvedValueOnce( + host({ id: 2, authType: "key", key: plainKey, password: null }), + ) + .mockResolvedValueOnce(host({ id: 3, password: "hop-password" })); + FakeSshClient.nextBehaviour = ["hang", "hang"]; + const prompt = { + ask: vi + .fn() + .mockResolvedValueOnce("111111") + .mockResolvedValueOnce("222222"), + }; + const pending = createJumpHostChain( + [{ hostId: 2 }, { hostId: 3 }], + "user-1", + prompt, + ); + await vi.waitFor(() => + expect(FakeSshClient.instances[0]?.connectConfig).toBeTruthy(), + ); + const first = FakeSshClient.instances[0]; + expect(first.connectConfig?.privateKey).toBeTruthy(); + const firstAnswer = vi.fn(); + first.emit( + "keyboard-interactive", + "", + "", + "", + [{ prompt: "Verification code:", echo: false }], + firstAnswer, + ); + await vi.waitFor(() => + expect(firstAnswer).toHaveBeenCalledWith(["111111"]), + ); + first.emit("ready"); + await vi.waitFor(() => + expect(FakeSshClient.instances[1]?.connectConfig).toBeTruthy(), + ); + const second = FakeSshClient.instances[1]; + const secondAnswer = vi.fn(); + second.emit( + "keyboard-interactive", + "", + "", + "", + [ + { prompt: "Password:", echo: false }, + { prompt: "Verification code:", echo: false }, + ], + secondAnswer, + ); + await vi.waitFor(() => + expect(secondAnswer).toHaveBeenCalledWith(["hop-password", "222222"]), + ); + second.emit("ready"); + expect(await pending).toBe(second); + expect(prompt.ask.mock.calls[0][0].prompt).toContain("Jump host 1/2"); + expect(prompt.ask.mock.calls[1][0].prompt).toContain("Jump host 2/2"); + second.emit("close"); + expect(first.ended).toBe(true); + expect(second.ended).toBe(true); + }); + + it("passes the interactive prompt channel through to jump hosts", async () => { + const jumpClient = new FakeSshClient(); + mocks.createJumpHostChain.mockResolvedValueOnce(jumpClient); + const prompt = { ask: vi.fn().mockResolvedValue("123456") }; + const connection = await connectHost(host({ jumpHosts: [{ hostId: 2 }] }), { + userId: "user-1", + prompt, + }); + expect(mocks.createJumpHostChain).toHaveBeenCalledWith( + [{ hostId: 2 }], + "owner-1", + prompt, + ); + connection.dispose(); + }); + it("goes through the jump chain and forwards to the target", async () => { const jumpClient = new FakeSshClient(); mocks.createJumpHostChain.mockResolvedValueOnce(jumpClient); @@ -528,6 +611,7 @@ describe("connectHost", () => { expect(mocks.createJumpHostChain).toHaveBeenCalledWith( [{ hostId: 2 }, { hostId: 3 }], "owner-1", + undefined, ); expect(jumpClient.forwardOut).toHaveBeenCalledWith( "127.0.0.1",