feat(ssh): support JumpServer keyboard-interactive authentication
This commit is contained in:
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user