mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-01 08:05:23 +00:00
fix(tunnels): support TOTP for client tunnels and jump hosts (#1508)
This commit is contained in:
1 parent
ec7c2c2835
commit
9b690b9935
19 files changed
+1203
-58
No files matched your search
@@ -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 };
|
||||||
+66
-30
@@ -2020,6 +2020,11 @@ async function getC2SRelayHeaders(relayUrl) {
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const attachC2SAuth = require("./c2s-auth.cjs").createC2SAuthBridge(
|
||||||
|
ipcMain,
|
||||||
|
() => mainWindow,
|
||||||
|
);
|
||||||
|
|
||||||
function getC2STunnelName(tunnel, index = 0) {
|
function getC2STunnelName(tunnel, index = 0) {
|
||||||
if (tunnel.name) return tunnel.name;
|
if (tunnel.name) return tunnel.name;
|
||||||
const localAddress = getC2SLocalAddress(tunnel);
|
const localAddress = getC2SLocalAddress(tunnel);
|
||||||
@@ -2136,6 +2141,7 @@ async function openC2SRelay(
|
|||||||
relayUrl,
|
relayUrl,
|
||||||
getWebSocketOptions(relayUrl, { headers }),
|
getWebSocketOptions(relayUrl, { headers }),
|
||||||
);
|
);
|
||||||
|
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
|
||||||
const pendingChunks = [];
|
const pendingChunks = [];
|
||||||
let ready = false;
|
let ready = false;
|
||||||
let closed = 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 tunnelConfig = normalizeC2STunnelAddresses(tunnel);
|
||||||
const relayUrl = getC2SRelayUrl();
|
const relayUrl = getC2SRelayUrl();
|
||||||
const headers = await getC2SRelayHeaders(relayUrl, authToken);
|
const headers = await getC2SRelayHeaders(relayUrl, authToken);
|
||||||
|
if (runtime && c2sTunnelRuntimes.get(tunnel.name) !== runtime) {
|
||||||
|
return { success: false, error: "Tunnel stopped" };
|
||||||
|
}
|
||||||
const ws = new WebSocket(
|
const ws = new WebSocket(
|
||||||
relayUrl,
|
relayUrl,
|
||||||
getWebSocketOptions(relayUrl, { headers }),
|
getWebSocketOptions(relayUrl, { headers }),
|
||||||
);
|
);
|
||||||
|
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
|
||||||
|
|
||||||
|
if (runtime) runtime.authSocket = ws;
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const settle = (result) => {
|
const settle = (result) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
try {
|
try {
|
||||||
ws.close();
|
if (!runtime || !result.success) ws.close();
|
||||||
} catch {
|
} catch {
|
||||||
// expected during shutdown
|
// expected during shutdown
|
||||||
}
|
}
|
||||||
@@ -2249,12 +2266,13 @@ async function testC2SRelay(tunnel, targetHost, targetPort, authToken) {
|
|||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
settle({ success: false, error: "Tunnel test timed out" });
|
settle({ success: false, error: "Tunnel test timed out" });
|
||||||
}, 15000);
|
}, 180_000);
|
||||||
|
|
||||||
ws.on("open", () => {
|
ws.on("open", () => {
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "test",
|
type: "test",
|
||||||
|
keepAlive: !!runtime,
|
||||||
tunnelConfig,
|
tunnelConfig,
|
||||||
targetHost,
|
targetHost,
|
||||||
targetPort,
|
targetPort,
|
||||||
@@ -2283,6 +2301,9 @@ async function testC2SRelay(tunnel, targetHost, targetPort, authToken) {
|
|||||||
settle(createC2SFailure(error.message, "Tunnel test failed"));
|
settle(createC2SFailure(error.message, "Tunnel test failed"));
|
||||||
});
|
});
|
||||||
ws.on("close", () => {
|
ws.on("close", () => {
|
||||||
|
if (runtime && c2sTunnelRuntimes.get(tunnel.name) === runtime) {
|
||||||
|
setC2STunnelError(tunnel.name, "Endpoint SSH connection closed");
|
||||||
|
}
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
settle({ success: false, error: "Tunnel test connection closed" });
|
settle({ success: false, error: "Tunnel test connection closed" });
|
||||||
});
|
});
|
||||||
@@ -2535,6 +2556,7 @@ async function startC2SRemoteTunnel(tunnel, index = 0, authToken) {
|
|||||||
relayUrl,
|
relayUrl,
|
||||||
getWebSocketOptions(relayUrl, { headers }),
|
getWebSocketOptions(relayUrl, { headers }),
|
||||||
);
|
);
|
||||||
|
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
|
||||||
const sockets = new Map();
|
const sockets = new Map();
|
||||||
let closed = false;
|
let closed = false;
|
||||||
|
|
||||||
@@ -2723,7 +2745,10 @@ async function startC2STunnel(tunnel, index = 0, authToken) {
|
|||||||
const resolvedTunnel = await resolveC2SRemoteSourceHost(tunnel);
|
const resolvedTunnel = await resolveC2SRemoteSourceHost(tunnel);
|
||||||
const mode = resolvedTunnel.mode || resolvedTunnel.tunnelType || "local";
|
const mode = resolvedTunnel.mode || resolvedTunnel.tunnelType || "local";
|
||||||
const tunnelName = getC2STunnelName(resolvedTunnel, index);
|
const tunnelName = getC2STunnelName(resolvedTunnel, index);
|
||||||
const tunnelConfig = normalizeC2STunnelAddresses(resolvedTunnel);
|
const tunnelConfig = {
|
||||||
|
...normalizeC2STunnelAddresses(resolvedTunnel),
|
||||||
|
sessionId: crypto.randomUUID(),
|
||||||
|
};
|
||||||
const bindHost = getC2SLocalAddress(tunnelConfig);
|
const bindHost = getC2SLocalAddress(tunnelConfig);
|
||||||
const sourcePort = Number(resolvedTunnel.sourcePort);
|
const sourcePort = Number(resolvedTunnel.sourcePort);
|
||||||
logToFile(`[c2s] starting tunnel ${tunnelName}`, {
|
logToFile(`[c2s] starting tunnel ${tunnelName}`, {
|
||||||
@@ -2774,6 +2799,10 @@ async function startC2STunnel(tunnel, index = 0, authToken) {
|
|||||||
|
|
||||||
const sockets = new Set();
|
const sockets = new Set();
|
||||||
const server = net.createServer((socket) => {
|
const server = net.createServer((socket) => {
|
||||||
|
if (!c2sTunnelRuntimes.get(tunnelName)?.status.connected) {
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
sockets.add(socket);
|
sockets.add(socket);
|
||||||
socket.on("close", () => sockets.delete(socket));
|
socket.on("close", () => sockets.delete(socket));
|
||||||
if (mode === "dynamic") {
|
if (mode === "dynamic") {
|
||||||
@@ -2816,33 +2845,38 @@ async function startC2STunnel(tunnel, index = 0, authToken) {
|
|||||||
reason: "Verifying endpoint SSH connection",
|
reason: "Verifying endpoint SSH connection",
|
||||||
});
|
});
|
||||||
|
|
||||||
const verifyTunnel =
|
const runtime = c2sTunnelRuntimes.get(tunnelName);
|
||||||
mode === "dynamic"
|
const verifyTunnel = testC2SRelay(
|
||||||
? testC2SRelay(
|
{ ...tunnelConfig, name: tunnelName, mode },
|
||||||
{ ...tunnel, name: `${tunnelName}::verify`, mode },
|
mode === "dynamic" ? undefined : getC2SRemoteAddress(tunnelConfig),
|
||||||
undefined,
|
mode === "dynamic" ? undefined : Number(tunnelConfig.endpointPort),
|
||||||
undefined,
|
authToken,
|
||||||
)
|
runtime,
|
||||||
: testC2SRelay(
|
);
|
||||||
{ ...tunnel, name: `${tunnelName}::verify`, mode },
|
|
||||||
tunnel.targetHost || "127.0.0.1",
|
|
||||||
Number(tunnel.endpointPort),
|
|
||||||
);
|
|
||||||
|
|
||||||
verifyTunnel.then((result) => {
|
verifyTunnel
|
||||||
if (!c2sTunnelRuntimes.has(tunnelName)) return;
|
.then((result) => {
|
||||||
if (result.success) {
|
if (c2sTunnelRuntimes.get(tunnelName) !== runtime) return;
|
||||||
setC2STunnelStatus(tunnelName, {
|
if (result.success) {
|
||||||
connected: true,
|
setC2STunnelStatus(tunnelName, {
|
||||||
status: "CONNECTED",
|
connected: true,
|
||||||
});
|
status: "CONNECTED",
|
||||||
} else {
|
});
|
||||||
setC2STunnelError(
|
} else {
|
||||||
tunnelName,
|
setC2STunnelError(
|
||||||
result.error || "Endpoint SSH connection failed",
|
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 });
|
resolve({ success: true, tunnelName });
|
||||||
});
|
});
|
||||||
@@ -2859,6 +2893,7 @@ async function stopC2STunnel(tunnelName) {
|
|||||||
connected: false,
|
connected: false,
|
||||||
status: "DISCONNECTING",
|
status: "DISCONNECTING",
|
||||||
});
|
});
|
||||||
|
runtime.authSocket?.close();
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (typeof runtime.close === "function") {
|
if (typeof runtime.close === "function") {
|
||||||
@@ -2883,6 +2918,7 @@ async function stopC2STunnel(tunnelName) {
|
|||||||
function stopAllC2STunnels() {
|
function stopAllC2STunnels() {
|
||||||
for (const [tunnelName, runtime] of c2sTunnelRuntimes.entries()) {
|
for (const [tunnelName, runtime] of c2sTunnelRuntimes.entries()) {
|
||||||
try {
|
try {
|
||||||
|
runtime.authSocket?.close();
|
||||||
if (typeof runtime.close === "function") {
|
if (typeof runtime.close === "function") {
|
||||||
runtime.close();
|
runtime.close();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
|
|
||||||
getSetting: (key) => ipcRenderer.invoke("get-setting", key),
|
getSetting: (key) => ipcRenderer.invoke("get-setting", key),
|
||||||
setSetting: (key, value) => ipcRenderer.invoke("set-setting", key, value),
|
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"),
|
getC2STunnelConfig: () => ipcRenderer.invoke("get-c2s-tunnel-config"),
|
||||||
saveC2STunnelConfig: (config) =>
|
saveC2STunnelConfig: (config) =>
|
||||||
ipcRenderer.invoke("save-c2s-tunnel-config", config),
|
ipcRenderer.invoke("save-c2s-tunnel-config", config),
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
* only inside the Termix desktop app; check isElectron before calling it.
|
* only inside the Termix desktop app; check isElectron before calling it.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { PluginSshPromptRequest } from "./backend.js";
|
||||||
|
|
||||||
export interface ServerConfig {
|
export interface ServerConfig {
|
||||||
serverUrl?: string;
|
serverUrl?: string;
|
||||||
allowInvalidCertificate?: boolean;
|
allowInvalidCertificate?: boolean;
|
||||||
@@ -65,6 +67,15 @@ export interface ElectronAPI {
|
|||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
allowInvalidCertificate?: boolean,
|
allowInvalidCertificate?: boolean,
|
||||||
) => Promise<ConnectionTestResult>;
|
) => Promise<ConnectionTestResult>;
|
||||||
|
answerC2SAuth: (id: string, answer: string | null) => Promise<boolean>;
|
||||||
|
onC2SAuthPrompt: (
|
||||||
|
callback: (prompt: {
|
||||||
|
id: string;
|
||||||
|
closed?: boolean;
|
||||||
|
tunnelName?: string;
|
||||||
|
request?: PluginSshPromptRequest;
|
||||||
|
}) => void,
|
||||||
|
) => () => void;
|
||||||
getC2STunnelConfig: () => Promise<unknown[]>;
|
getC2STunnelConfig: () => Promise<unknown[]>;
|
||||||
saveC2STunnelConfig: (
|
saveC2STunnelConfig: (
|
||||||
config: unknown[],
|
config: unknown[],
|
||||||
|
|||||||
@@ -148,7 +148,11 @@
|
|||||||
"remoteSessionExpired": "Remote Sync session expired. Reconnect Remote Sync and try again.",
|
"remoteSessionExpired": "Remote Sync session expired. Reconnect Remote Sync and try again.",
|
||||||
"activeTunnels": "Active Tunnels",
|
"activeTunnels": "Active Tunnels",
|
||||||
"start": "Start",
|
"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": {
|
"permissions": {
|
||||||
"use": {
|
"use": {
|
||||||
|
|||||||
@@ -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 }));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<WebSocket>;
|
||||||
|
source: Promise<PluginSshConnection<Client>>;
|
||||||
|
};
|
||||||
|
const entries = new Map<string, Entry>();
|
||||||
|
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<Client>(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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
import type { Duplex } from "node:stream";
|
import type { Duplex } from "node:stream";
|
||||||
import type { Client, ClientChannel } from "ssh2";
|
import type { ClientChannel } from "ssh2";
|
||||||
import type { WebSocket } from "ws";
|
import type { WebSocket } from "ws";
|
||||||
import type {
|
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||||
PluginContext,
|
import { createC2SConnections } from "./c2s-connections.js";
|
||||||
PluginSshConnection,
|
|
||||||
} from "@termix/plugin-sdk/backend";
|
|
||||||
import type { TunnelMode } from "./types.js";
|
import type { TunnelMode } from "./types.js";
|
||||||
import {
|
import {
|
||||||
bindForwardIn,
|
bindForwardIn,
|
||||||
@@ -16,6 +14,7 @@ import { getTunnelMode } from "./utils.js";
|
|||||||
/** What the desktop app sends in the first message on the relay socket. */
|
/** What the desktop app sends in the first message on the relay socket. */
|
||||||
export interface C2SRelayTunnel {
|
export interface C2SRelayTunnel {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
sessionId?: string;
|
||||||
mode?: TunnelMode;
|
mode?: TunnelMode;
|
||||||
tunnelType?: "local" | "remote";
|
tunnelType?: "local" | "remote";
|
||||||
localAddress?: string;
|
localAddress?: string;
|
||||||
@@ -30,6 +29,7 @@ export interface C2SRelayTunnel {
|
|||||||
|
|
||||||
export interface C2SOpenMessage {
|
export interface C2SOpenMessage {
|
||||||
type: "open" | "test";
|
type: "open" | "test";
|
||||||
|
keepAlive?: boolean;
|
||||||
tunnelConfig?: C2SRelayTunnel;
|
tunnelConfig?: C2SRelayTunnel;
|
||||||
targetHost?: string;
|
targetHost?: string;
|
||||||
targetPort?: number;
|
targetPort?: number;
|
||||||
@@ -151,6 +151,7 @@ function address(value: unknown, fallback: string): string {
|
|||||||
|
|
||||||
interface ResolvedRelay {
|
interface ResolvedRelay {
|
||||||
name: string;
|
name: string;
|
||||||
|
sessionId?: string;
|
||||||
hostId: number;
|
hostId: number;
|
||||||
mode: TunnelMode;
|
mode: TunnelMode;
|
||||||
remoteAddress: string;
|
remoteAddress: string;
|
||||||
@@ -165,6 +166,7 @@ interface ResolvedRelay {
|
|||||||
*/
|
*/
|
||||||
export function createC2SRelay(ctx: PluginContext) {
|
export function createC2SRelay(ctx: PluginContext) {
|
||||||
let streamCounter = 0;
|
let streamCounter = 0;
|
||||||
|
const connectSource = createC2SConnections(ctx);
|
||||||
|
|
||||||
async function findHostIdBySyncId(syncId: string): Promise<number | null> {
|
async function findHostIdBySyncId(syncId: string): Promise<number | null> {
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
@@ -188,6 +190,7 @@ export function createC2SRelay(ctx: PluginContext) {
|
|||||||
return {
|
return {
|
||||||
name: tunnel.name || `c2s:${hostId}`,
|
name: tunnel.name || `c2s:${hostId}`,
|
||||||
hostId,
|
hostId,
|
||||||
|
sessionId: tunnel.sessionId,
|
||||||
mode: getTunnelMode({
|
mode: getTunnelMode({
|
||||||
mode: tunnel.mode,
|
mode: tunnel.mode,
|
||||||
tunnelType: tunnel.tunnelType,
|
tunnelType: tunnel.tunnelType,
|
||||||
@@ -201,14 +204,6 @@ export function createC2SRelay(ctx: PluginContext) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectSource(hostId: number): Promise<PluginSshConnection<Client>> {
|
|
||||||
return ctx.ssh.connect<Client>(hostId, {
|
|
||||||
purpose: "tunnel",
|
|
||||||
profile: "forward",
|
|
||||||
timeoutMs: 60_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openRemote(ws: WebSocket, relay: ResolvedRelay) {
|
async function openRemote(ws: WebSocket, relay: ResolvedRelay) {
|
||||||
const bindHost = relay.remoteAddress;
|
const bindHost = relay.remoteAddress;
|
||||||
const bindPort = relay.sourcePort;
|
const bindPort = relay.sourcePort;
|
||||||
@@ -216,7 +211,7 @@ export function createC2SRelay(ctx: PluginContext) {
|
|||||||
throw new Error("Invalid remote port");
|
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;
|
const sourceClient = source.client;
|
||||||
let actualPort: number;
|
let actualPort: number;
|
||||||
try {
|
try {
|
||||||
@@ -226,6 +221,11 @@ export function createC2SRelay(ctx: PluginContext) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ws.readyState !== 1) {
|
||||||
|
unbindForwardIn(sourceClient, bindHost, actualPort, ctx.log);
|
||||||
|
source.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const streams = new Map<string, ClientChannel>();
|
const streams = new Map<string, ClientChannel>();
|
||||||
let closed = false;
|
let closed = false;
|
||||||
|
|
||||||
@@ -340,7 +340,7 @@ export function createC2SRelay(ctx: PluginContext) {
|
|||||||
throw new Error("Invalid client tunnel target");
|
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;
|
let outbound: ClientChannel;
|
||||||
try {
|
try {
|
||||||
outbound = await forwardOut(source.client, targetHost, targetPort);
|
outbound = await forwardOut(source.client, targetHost, targetPort);
|
||||||
@@ -349,6 +349,11 @@ export function createC2SRelay(ctx: PluginContext) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ws.readyState !== 1) {
|
||||||
|
outbound.destroy();
|
||||||
|
source.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const close = () => {
|
const close = () => {
|
||||||
try {
|
try {
|
||||||
outbound.destroy();
|
outbound.destroy();
|
||||||
@@ -384,7 +389,7 @@ export function createC2SRelay(ctx: PluginContext) {
|
|||||||
|
|
||||||
async function test(ws: WebSocket, message: C2SOpenMessage): Promise<void> {
|
async function test(ws: WebSocket, message: C2SOpenMessage): Promise<void> {
|
||||||
const relay = await resolve(message.tunnelConfig ?? {});
|
const relay = await resolve(message.tunnelConfig ?? {});
|
||||||
const source = await connectSource(relay.hostId);
|
const source = await connectSource(ws, relay.hostId, relay.sessionId);
|
||||||
try {
|
try {
|
||||||
if (relay.mode === "remote") {
|
if (relay.mode === "remote") {
|
||||||
const bindPort = relay.sourcePort;
|
const bindPort = relay.sourcePort;
|
||||||
@@ -410,7 +415,7 @@ export function createC2SRelay(ctx: PluginContext) {
|
|||||||
}
|
}
|
||||||
sendMessage(ws, { type: "ready" });
|
sendMessage(ws, { type: "ready" });
|
||||||
} finally {
|
} finally {
|
||||||
source.dispose();
|
if (!message.keepAlive) source.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<Challenge[]>([]);
|
||||||
|
const pending = useRef(new Set<string>());
|
||||||
|
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 ? (
|
||||||
|
<AuthDialog
|
||||||
|
key={current.id}
|
||||||
|
challenge={current}
|
||||||
|
onAnswer={(answer) => {
|
||||||
|
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 (
|
||||||
|
<Dialog
|
||||||
|
open
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) onAnswer(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("tunnels.authTitle")}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm break-all">{challenge.tunnelName}</p>
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onAnswer(answer);
|
||||||
|
}}
|
||||||
|
className="space-y-3"
|
||||||
|
>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">
|
||||||
|
{browser ? request.instructions : request.prompt}
|
||||||
|
</p>
|
||||||
|
{request.kind === "totp" && request.retry && (
|
||||||
|
<p role="alert">{t("tunnels.authRetry")}</p>
|
||||||
|
)}
|
||||||
|
{browser ? (
|
||||||
|
<>
|
||||||
|
<p>{request.code}</p>
|
||||||
|
{url && (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="break-all underline"
|
||||||
|
>
|
||||||
|
{url}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
aria-label={t("tunnels.authAnswer")}
|
||||||
|
autoComplete="off"
|
||||||
|
type={
|
||||||
|
request.kind === "input" && request.echo ? "text" : "password"
|
||||||
|
}
|
||||||
|
value={answer}
|
||||||
|
onChange={(event) => setAnswer(event.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onAnswer(null)}
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">{t("tunnels.authContinue")}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ComponentType } from "react";
|
import type { ComponentType } from "react";
|
||||||
import { Network, Plug } from "lucide-react";
|
import { Network, Plug } from "lucide-react";
|
||||||
import type { TabProps, TermixApp } from "@termix/plugin-sdk/frontend";
|
import type { TabProps, TermixApp } from "@termix/plugin-sdk/frontend";
|
||||||
|
import { TunnelAuthPrompts } from "./TunnelAuthPrompts";
|
||||||
import { TunnelTab } from "./TunnelTab";
|
import { TunnelTab } from "./TunnelTab";
|
||||||
import { HostTunnelsSection } from "./HostTunnelsSection";
|
import { HostTunnelsSection } from "./HostTunnelsSection";
|
||||||
import {
|
import {
|
||||||
@@ -27,6 +28,12 @@ export function activate(app: TermixApp): void {
|
|||||||
if (typeof window !== "undefined" && window.electronAPI?.setC2SRelayPath) {
|
if (typeof window !== "undefined" && window.electronAPI?.setC2SRelayPath) {
|
||||||
void window.electronAPI.setC2SRelayPath(C2S_RELAY_PATH);
|
void window.electronAPI.setC2SRelayPath(C2S_RELAY_PATH);
|
||||||
}
|
}
|
||||||
|
app.registerSlotContribution("shell.overlay", {
|
||||||
|
actionId: "tunnels.authPrompts",
|
||||||
|
titleKey: "tunnels.authTitle",
|
||||||
|
kind: "component",
|
||||||
|
component: TunnelAuthPrompts as ComponentType<Record<string, unknown>>,
|
||||||
|
});
|
||||||
// apiFor("remote") hands back app.api itself outside the desktop app.
|
// apiFor("remote") hands back app.api itself outside the desktop app.
|
||||||
const remote = app.apiFor("remote");
|
const remote = app.apiFor("remote");
|
||||||
setTunnelsApi(app.api, {
|
setTunnelsApi(app.api, {
|
||||||
|
|||||||
@@ -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<typeof vi.fn>;
|
||||||
|
}> = [];
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof window.electronAPI.onC2SAuthPrompt>[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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -120,6 +120,7 @@ export async function connectHost(
|
|||||||
} else {
|
} else {
|
||||||
({ jumpClient } = await openSshTransport(host, config, {
|
({ jumpClient } = await openSshTransport(host, config, {
|
||||||
log: options.log,
|
log: options.log,
|
||||||
|
prompt: options.prompt,
|
||||||
...options.transport,
|
...options.transport,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type {
|
|||||||
MutableConnectConfig,
|
MutableConnectConfig,
|
||||||
SshAuthLog,
|
SshAuthLog,
|
||||||
SshConnectHost,
|
SshConnectHost,
|
||||||
|
SshPromptChannel,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
function getHostSocks5Config(host: SshConnectHost): SOCKS5Config | null {
|
function getHostSocks5Config(host: SshConnectHost): SOCKS5Config | null {
|
||||||
@@ -48,6 +49,7 @@ class SshTransportError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenTransportOptions {
|
export interface OpenTransportOptions {
|
||||||
|
prompt?: SshPromptChannel;
|
||||||
/** Knock before connecting when the host has a sequence. Default true. */
|
/** Knock before connecting when the host has a sequence. Default true. */
|
||||||
portKnock?: boolean;
|
portKnock?: boolean;
|
||||||
/** Resolve DNS up front for a direct connection. Default true. */
|
/** Resolve DNS up front for a direct connection. Default true. */
|
||||||
@@ -111,7 +113,11 @@ export async function openSshTransport(
|
|||||||
|
|
||||||
const jumpUserId = host.userId || "";
|
const jumpUserId = host.userId || "";
|
||||||
if (host.jumpHosts && host.jumpHosts.length > 0 && jumpUserId) {
|
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) {
|
if (!jumpClient) {
|
||||||
throw new SshTransportError(
|
throw new SshTransportError(
|
||||||
"Failed to connect through jump hosts",
|
"Failed to connect through jump hosts",
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import { createSocks5Connection } from "../utils/socks5-helper.js";
|
|||||||
import { getErrorMessage } from "../utils/error-message.js";
|
import { getErrorMessage } from "../utils/error-message.js";
|
||||||
import { getJumpHostSocks5Config } from "./jump-host-proxy.js";
|
import { getJumpHostSocks5Config } from "./jump-host-proxy.js";
|
||||||
import { buildConnectConfig } from "./connect/build-connect-config.js";
|
import { buildConnectConfig } from "./connect/build-connect-config.js";
|
||||||
import { createAutoKeyboardInteractiveHandler } from "./connect/keyboard-interactive.js";
|
import {
|
||||||
import type { SshConnectHost } from "./connect/types.js";
|
createAutoKeyboardInteractiveHandler,
|
||||||
|
createPromptKeyboardInteractiveHandler,
|
||||||
|
} from "./connect/keyboard-interactive.js";
|
||||||
|
import type { SshConnectHost, SshPromptChannel } from "./connect/types.js";
|
||||||
import { resolveHostById } from "./host-resolver.js";
|
import { resolveHostById } from "./host-resolver.js";
|
||||||
|
|
||||||
type JumpHostConfig = {
|
type JumpHostConfig = {
|
||||||
@@ -61,6 +64,7 @@ class JumpHostChainError extends Error {
|
|||||||
export async function createJumpHostChain(
|
export async function createJumpHostChain(
|
||||||
jumpHosts: Array<{ hostId: number }>,
|
jumpHosts: Array<{ hostId: number }>,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
prompt?: SshPromptChannel,
|
||||||
): Promise<SSHClient | null> {
|
): Promise<SSHClient | null> {
|
||||||
if (!jumpHosts || jumpHosts.length === 0) {
|
if (!jumpHosts || jumpHosts.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -68,6 +72,12 @@ export async function createJumpHostChain(
|
|||||||
|
|
||||||
let currentClient: SSHClient | null = null;
|
let currentClient: SSHClient | null = null;
|
||||||
const clients: SSHClient[] = [];
|
const clients: SSHClient[] = [];
|
||||||
|
let closed = false;
|
||||||
|
const closeChain = () => {
|
||||||
|
if (closed) return;
|
||||||
|
closed = true;
|
||||||
|
for (const client of clients) client.end();
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const jumpHostConfigs: Array<Awaited<ReturnType<typeof resolveJumpHost>>> =
|
const jumpHostConfigs: Array<Awaited<ReturnType<typeof resolveJumpHost>>> =
|
||||||
@@ -87,7 +97,7 @@ export async function createJumpHostChain(
|
|||||||
hopIndex: i,
|
hopIndex: i,
|
||||||
totalHops,
|
totalHops,
|
||||||
});
|
});
|
||||||
clients.forEach((c) => c.end());
|
closeChain();
|
||||||
throw new JumpHostChainError(
|
throw new JumpHostChainError(
|
||||||
`Jump host ${i + 1} of ${totalHops} was not found`,
|
`Jump host ${i + 1} of ${totalHops} was not found`,
|
||||||
i,
|
i,
|
||||||
@@ -108,10 +118,12 @@ export async function createJumpHostChain(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < jumpHostConfigs.length; i++) {
|
for (let i = 0; i < jumpHostConfigs.length; i++) {
|
||||||
|
if (closed) throw new Error("Jump host chain closed");
|
||||||
const jumpHostConfig = jumpHostConfigs[i]!;
|
const jumpHostConfig = jumpHostConfigs[i]!;
|
||||||
|
|
||||||
const jumpClient = new SSHClient();
|
const jumpClient = new SSHClient();
|
||||||
clients.push(jumpClient);
|
clients.push(jumpClient);
|
||||||
|
jumpClient.once("close", closeChain);
|
||||||
|
|
||||||
let lastError: Error | null = null;
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
@@ -128,6 +140,11 @@ export async function createJumpHostChain(
|
|||||||
jumpClient.end();
|
jumpClient.end();
|
||||||
}, readyTimeoutMs + 5000);
|
}, readyTimeoutMs + 5000);
|
||||||
|
|
||||||
|
jumpClient.once("close", () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
lastError = new Error("Jump host connection closed");
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
jumpClient.on("ready", () => {
|
jumpClient.on("ready", () => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
resolve(true);
|
resolve(true);
|
||||||
@@ -161,8 +178,20 @@ export async function createJumpHostChain(
|
|||||||
// every auth type, not just password, key and agent.
|
// every auth type, not just password, key and agent.
|
||||||
const built = await buildConnectConfig(
|
const built = await buildConnectConfig(
|
||||||
jumpHostConfig as unknown as SshConnectHost,
|
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") {
|
if (built.outcome.status !== "ready") {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
lastError = new Error(
|
lastError = new Error(
|
||||||
@@ -176,9 +205,28 @@ export async function createJumpHostChain(
|
|||||||
|
|
||||||
jumpClient.on(
|
jumpClient.on(
|
||||||
"keyboard-interactive",
|
"keyboard-interactive",
|
||||||
createAutoKeyboardInteractiveHandler(
|
prompt
|
||||||
jumpHostConfig as unknown as SshConnectHost,
|
? 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) {
|
if (currentClient) {
|
||||||
@@ -207,7 +255,7 @@ export async function createJumpHostChain(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
clients.forEach((c) => c.end());
|
closeChain();
|
||||||
throw new JumpHostChainError(
|
throw new JumpHostChainError(
|
||||||
getErrorMessage(
|
getErrorMessage(
|
||||||
lastError,
|
lastError,
|
||||||
@@ -227,7 +275,7 @@ export async function createJumpHostChain(
|
|||||||
fileLogger.error("Failed to create jump host chain", error, {
|
fileLogger.error("Failed to create jump host chain", error, {
|
||||||
operation: "jump_host_chain",
|
operation: "jump_host_chain",
|
||||||
});
|
});
|
||||||
clients.forEach((c) => c.end());
|
closeChain();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -518,6 +518,89 @@ describe("connectHost", () => {
|
|||||||
dispose();
|
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 () => {
|
it("goes through the jump chain and forwards to the target", async () => {
|
||||||
const jumpClient = new FakeSshClient();
|
const jumpClient = new FakeSshClient();
|
||||||
mocks.createJumpHostChain.mockResolvedValueOnce(jumpClient);
|
mocks.createJumpHostChain.mockResolvedValueOnce(jumpClient);
|
||||||
@@ -528,6 +611,7 @@ describe("connectHost", () => {
|
|||||||
expect(mocks.createJumpHostChain).toHaveBeenCalledWith(
|
expect(mocks.createJumpHostChain).toHaveBeenCalledWith(
|
||||||
[{ hostId: 2 }, { hostId: 3 }],
|
[{ hostId: 2 }, { hostId: 3 }],
|
||||||
"owner-1",
|
"owner-1",
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
expect(jumpClient.forwardOut).toHaveBeenCalledWith(
|
expect(jumpClient.forwardOut).toHaveBeenCalledWith(
|
||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
|
|||||||
Reference in new issue
Block a user