mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +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
@@ -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": {
|
||||
|
||||
@@ -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 { 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<number | null> {
|
||||
/* 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<PluginSshConnection<Client>> {
|
||||
return ctx.ssh.connect<Client>(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<string, ClientChannel>();
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { 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<Record<string, unknown>>,
|
||||
});
|
||||
// apiFor("remote") hands back app.api itself outside the desktop app.
|
||||
const remote = app.apiFor("remote");
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user