feat(ssh): support JumpServer keyboard-interactive authentication

This commit is contained in:
Rson9 authored and GitHub committed 2026-08-04 14:23:44 +08:00
1 parent 8228c2af17
commit 947cc16d92
28 files changed
+873 -72

No files matched your search

@@ -309,6 +309,7 @@ function defaultSshTunnel(): SshTunnelConfig {
use_ssh_agent: false,
ssh_agent_sock_path: "",
auth_method: "password",
allow_exec_channel_proxy: false,
};
}
@@ -342,6 +343,7 @@ function normalizeSshTunnel(hop: Partial<SshTunnelConfig>): SshTunnelConfig {
use_ssh_agent: !!hop.use_ssh_agent,
ssh_agent_sock_path: hop.ssh_agent_sock_path || "",
auth_method: hop.auth_method || inferSshAuthMethod(hop),
allow_exec_channel_proxy: !!hop.allow_exec_channel_proxy,
profile_id: hop.profile_id || undefined,
};
}
@@ -6978,6 +6980,13 @@ function openExternalUrl(url: string) {
<span class="text-xs text-muted-foreground">{{ t("connection.sshExposeLan") }}</span>
</label>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<span />
<label class="col-span-3 flex items-start gap-2 cursor-pointer">
<input type="checkbox" v-model="selectedSshLayer.allow_exec_channel_proxy" class="mt-0.5 mr-0" :disabled="selectedSshLayer.enabled === false" />
<span class="text-xs text-muted-foreground">{{ t("connection.sshAllowExecChannelProxy") }}</span>
</label>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.sshConnectTimeout") }}</Label>
<Input v-model.number="selectedSshLayer.connect_timeout_secs" type="number" min="1" max="300" step="1" class="col-span-3" :disabled="selectedSshLayer.enabled === false" />
@@ -528,10 +528,10 @@ async function toggle() {
node.isExpanded = false;
if (!connectionStore.sidebarSearchQuery) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
connectionStore.cancelTreeNodeLoad(node.id);
emit("node-toggled", node, true);
emitNodeToggled(node, true, false);
} else {
node.isExpanded = true;
emit("node-toggled", node, false);
emitNodeToggled(node, false);
}
return;
}
@@ -541,13 +541,13 @@ async function toggle() {
if (node.type === "connection-group") {
node.isExpanded = !node.isExpanded;
connectionStore.toggleConnectionGroupCollapsed(node.id);
emit("node-toggled", node, wasExpanded);
emitNodeToggled(node, wasExpanded);
return;
}
if (node.type === "group-partitions") {
node.isExpanded = !node.isExpanded;
emit("node-toggled", node, wasExpanded);
emitNodeToggled(node, wasExpanded);
return;
}
@@ -558,27 +558,27 @@ async function toggle() {
if (databaseObjectGroup && connectionStore.canUseLoadedTreeNodeToggle(node)) {
node.isExpanded = !node.isExpanded;
if (wasExpanded && !connectionStore.sidebarSearchQuery) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
emit("node-toggled", node, wasExpanded);
emitNodeToggled(node, wasExpanded);
return;
}
if (node.type === "group-extensions" && connectionStore.canUseLoadedTreeNodeToggle(node)) {
node.isExpanded = !node.isExpanded;
if (wasExpanded && !connectionStore.sidebarSearchQuery) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
emit("node-toggled", node, wasExpanded);
emitNodeToggled(node, wasExpanded);
return;
}
if (node.isExpanded) {
node.isExpanded = false;
if (!connectionStore.sidebarSearchQuery) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
emit("node-toggled", node, wasExpanded);
emitNodeToggled(node, wasExpanded, false);
return;
}
try {
if (await loadSidebarObjectGroup(node, connectionStore)) {
emit("node-toggled", node, wasExpanded);
emitNodeToggled(node, wasExpanded);
return;
}
@@ -715,7 +715,7 @@ async function toggle() {
} else if (node.type === "group-extensions" && node.connectionId && hasTreeNodeDatabaseContext(node)) {
await connectionStore.refreshTreeNode(node);
}
emit("node-toggled", node, wasExpanded);
emitNodeToggled(node, wasExpanded);
} catch (e: any) {
if (!wasExpanded) node.isExpanded = false;
const errMsg = e?.message || String(e);
@@ -4673,6 +4673,15 @@ function activateRuntimeNode(node: TreeNode) {
activeNode.value = node;
}
// Async loaders can rebuild a connection node while awaiting the backend.
// Publish the live tree node so a stale rendered row cannot reset expansion.
function emitNodeToggled(node: TreeNode, wasExpanded: boolean, expandedOverride?: boolean) {
const liveNode = findSidebarActionTarget(connectionStore.treeNodes, createSidebarActionTarget(node)) ?? node;
if (expandedOverride !== undefined) liveNode.isExpanded = expandedOverride;
activeNode.value = liveNode;
emit("node-toggled", liveNode, wasExpanded);
}
function activateActionTarget(target: SidebarActionTarget) {
activeNode.value = findSidebarActionTarget(connectionStore.treeNodes, target) ?? target;
}
@@ -4,6 +4,14 @@ import { describe, expect, it } from "vitest";
const runtimeSource = readFileSync(new URL("../SidebarTreeRuntimeHost.vue", import.meta.url), "utf8");
describe("cross-database table paste", () => {
it("publishes the live node after async tree loads", () => {
expect(runtimeSource).toContain("function emitNodeToggled(node: TreeNode, wasExpanded: boolean, expandedOverride?: boolean)");
expect(runtimeSource).toContain("findSidebarActionTarget(connectionStore.treeNodes, createSidebarActionTarget(node)) ?? node");
expect(runtimeSource).toContain("emitNodeToggled(node, wasExpanded, false)");
expect(runtimeSource).toMatch(/await connectionStore\.loadMongoDatabases\(node\.connectionId\);[\s\S]*?emitNodeToggled\(node, wasExpanded\)/);
expect(runtimeSource).toContain("connectionStore.cancelTreeNodeLoad(node.id)");
});
it("routes a table clipboard from another context to data transfer", () => {
expect(runtimeSource).toContain("function canTransferTreeClipboardToCurrentNode()");
expect(runtimeSource).toContain("function openTransferFromTreeClipboard()");
@@ -17,6 +17,7 @@ interface SshPromptRequest {
key_type?: string | null;
fingerprint?: string | null;
prompt?: string | null;
echo?: boolean;
}
interface SshHostKeyNotice {
@@ -35,6 +36,7 @@ const { toast } = useToast();
// Using a queue keyed by request id means every prompt is shown and answered.
const queue = ref<SshPromptRequest[]>([]);
const current = computed<SshPromptRequest | null>(() => queue.value[0] ?? null);
const isSecretPrompt = computed(() => current.value?.kind === "SecretInput");
const visible = ref(false);
const remember = ref(true);
@@ -235,17 +237,19 @@ function submitSecret() {
</script>
<template>
<Dialog v-model:open="visible" :dismissible="false">
<DialogContent class="sm:max-w-[460px]">
<Dialog v-model:open="visible">
<DialogContent class="sm:max-w-[460px]" :show-close-button="false" @interact-outside.prevent @escape-key-down.prevent>
<DialogHeader>
<DialogTitle>{{ t("connection.sshHostKeyVerifyTitle") }}</DialogTitle>
<DialogTitle>
{{ t(isSecretPrompt ? "connection.sshInteractiveTitle" : "connection.sshHostKeyVerifyTitle") }}
</DialogTitle>
<DialogDescription class="text-muted-foreground">
{{ t("connection.sshHostKeyVerifyMessage", { host: current?.host ?? "", port: current?.port ?? "" }) }}
{{ t(isSecretPrompt ? "connection.sshInteractiveMessage" : "connection.sshHostKeyVerifyMessage", { host: current?.host ?? "", port: current?.port ?? "" }) }}
</DialogDescription>
</DialogHeader>
<div v-if="current" class="space-y-3 py-1">
<div class="rounded-md border border-border bg-muted/40 p-3 text-sm">
<div v-if="current.kind === 'HostKeyVerify'" class="rounded-md border border-border bg-muted/40 p-3 text-sm">
<div class="flex items-center justify-between gap-3">
<span class="text-muted-foreground">{{ t("connection.sshHostKeyVerifyKeyType") }}</span>
<span class="font-medium">{{ current.key_type || "—" }}</span>
@@ -262,18 +266,29 @@ function submitSecret() {
</label>
<div v-else-if="current.kind === 'SecretInput'" class="space-y-2">
<p class="text-sm text-muted-foreground">{{ current.prompt || "Enter verification code" }}</p>
<input v-model="secretCode" type="text" autocomplete="off" class="w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm outline-none focus:ring-2 focus:ring-ring" :placeholder="'••••••'" />
<p class="whitespace-pre-wrap text-sm text-muted-foreground">
{{ current.prompt || t("connection.sshInteractiveDefaultPrompt") }}
</p>
<input
v-model="secretCode"
:type="current.echo ? 'text' : 'password'"
autocomplete="one-time-code"
class="w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm outline-none focus:ring-2 focus:ring-ring"
:placeholder="t('connection.sshInteractivePlaceholder')"
@keydown.enter="submitSecret"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" :disabled="resolving" @click="reject">{{ t("connection.sshHostKeyVerifyReject") }}</Button>
<Button variant="outline" :disabled="resolving" @click="reject">
{{ t(isSecretPrompt ? "connection.sshInteractiveCancel" : "connection.sshHostKeyVerifyReject") }}
</Button>
<Button v-if="current?.kind !== 'SecretInput'" :disabled="resolving" @click="accept">
{{ t("connection.sshHostKeyVerifyAccept") }}
</Button>
<Button v-else :disabled="resolving || !secretCode" @click="submitSecret">
{{ t("connection.sshHostKeyVerifyAccept") }}
{{ t("connection.sshInteractiveSubmit") }}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,5 +1,7 @@
// @vitest-environment happy-dom
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { createApp, defineComponent, h, nextTick, type App } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
@@ -49,6 +51,8 @@ vi.mock("@/components/ui/button", async () => {
import SshHostKeyPromptDialog from "@/components/ssh/SshHostKeyPromptDialog.vue";
const dialogSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/ssh/SshHostKeyPromptDialog.vue"), "utf8");
class MockEventSource {
static instances: MockEventSource[] = [];
@@ -113,6 +117,13 @@ async function mountDialog() {
}
describe("SshHostKeyPromptDialog web bridge", () => {
it("requires an explicit answer for blocking SSH prompts", () => {
expect(dialogSource).toContain(':show-close-button="false"');
expect(dialogSource).toContain("@interact-outside.prevent");
expect(dialogSource).toContain("@escape-key-down.prevent");
expect(dialogSource).not.toContain(':dismissible="false"');
});
it("shows an SSE host-key prompt and posts the user's acceptance", async () => {
await mountDialog();
@@ -217,4 +228,44 @@ describe("SshHostKeyPromptDialog web bridge", () => {
eventSource?.error();
expect(setSpy).toHaveBeenCalledTimes(2);
});
it("submits a keyboard-interactive TOTP challenge as a secret response", async () => {
await mountDialog();
const eventSource = MockEventSource.instances[0];
eventSource?.emit({
type: "prompt",
request: {
id: "totp-1",
kind: "SecretInput",
host: "jump.example.test",
port: 2222,
prompt: "JumpServer\n\nOTP Code:",
echo: false,
},
});
await nextTick();
expect(document.body.textContent).toContain("SSH Verification Required");
expect(document.body.textContent).toContain("OTP Code:");
const input = document.body.querySelector<HTMLInputElement>("input");
expect(input?.type).toBe("password");
if (!input) throw new Error("TOTP input was not rendered");
input.value = "123456";
input.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
const buttons = document.body.querySelectorAll<HTMLButtonElement>("button");
buttons.item(buttons.length - 1).click();
await vi.waitFor(() => {
expect(resolveSshPromptMock).toHaveBeenCalledWith({
id: "totp-1",
action: "secret",
remember: undefined,
secret: "123456",
});
});
});
});