fix(web): support SSH host key prompts
This commit is contained in:
14 files changed
+509
-32
No files matched your search
@@ -5,6 +5,9 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { resolveSshPrompt } from "@/lib/backend/api";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { apiUrl } from "@/lib/common/webPath";
|
||||
|
||||
interface SshPromptRequest {
|
||||
id: string;
|
||||
@@ -38,15 +41,51 @@ const remember = ref(true);
|
||||
const secretCode = ref("");
|
||||
const resolving = ref(false);
|
||||
const unlisteners: Array<() => void> = [];
|
||||
let webEventSource: EventSource | null = null;
|
||||
let mounted = true;
|
||||
|
||||
onMounted(async () => {
|
||||
function enqueuePrompt(request: SshPromptRequest) {
|
||||
if (queue.value.some((prompt) => prompt.id === request.id)) return;
|
||||
queue.value.push(request);
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
function handleNotice(n: SshHostKeyNotice) {
|
||||
let message: string;
|
||||
if (n.kind === "Changed") {
|
||||
message = t("connection.sshHostKeyNoticeChanged", { host: n.host, port: n.port });
|
||||
} else if (n.kind === "Rejected") {
|
||||
message = t("connection.sshHostKeyNoticeRejected", { host: n.host, port: n.port });
|
||||
} else if (n.kind === "LearnFailed") {
|
||||
message = t("connection.sshHostKeyNoticeLearnFailed", { host: n.host, port: n.port });
|
||||
} else {
|
||||
message = n.message || t("connection.sshHostKeyNoticeGeneric");
|
||||
}
|
||||
toast(message, 6000);
|
||||
}
|
||||
|
||||
function handleWebEvent(data: string) {
|
||||
try {
|
||||
const event = JSON.parse(data) as { type: "sync" | "prompt" | "notice" | "dismiss"; pendingIds?: string[]; request?: SshPromptRequest; notice?: SshHostKeyNotice; id?: string };
|
||||
if (event.type === "sync" && event.pendingIds) {
|
||||
const currentId = queue.value[0]?.id;
|
||||
queue.value = queue.value.filter((prompt) => event.pendingIds?.includes(prompt.id));
|
||||
if (currentId && !queue.value.some((prompt) => prompt.id === currentId)) resetPromptState();
|
||||
visible.value = queue.value.length > 0;
|
||||
} else if (event.type === "prompt" && event.request) enqueuePrompt(event.request);
|
||||
else if (event.type === "notice" && event.notice) handleNotice(event.notice);
|
||||
else if (event.type === "dismiss" && event.id) dismissPrompt(event.id);
|
||||
} catch (error) {
|
||||
console.error("[DBX] invalid SSH prompt event:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function setupTauriPromptBridge() {
|
||||
try {
|
||||
const { listen } = await import("@tauri-apps/api/event");
|
||||
if (!mounted) return;
|
||||
const promptHandle = await listen<SshPromptRequest>("ssh-prompt", (event) => {
|
||||
queue.value.push(event.payload);
|
||||
visible.value = true;
|
||||
enqueuePrompt(event.payload);
|
||||
});
|
||||
if (!mounted) {
|
||||
promptHandle();
|
||||
@@ -55,18 +94,7 @@ onMounted(async () => {
|
||||
unlisteners.push(promptHandle);
|
||||
|
||||
const noticeHandle = await listen<SshHostKeyNotice>("ssh-host-key-notice", (event) => {
|
||||
const n = event.payload;
|
||||
let message: string;
|
||||
if (n.kind === "Changed") {
|
||||
message = t("connection.sshHostKeyNoticeChanged", { host: n.host, port: n.port });
|
||||
} else if (n.kind === "Rejected") {
|
||||
message = t("connection.sshHostKeyNoticeRejected", { host: n.host, port: n.port });
|
||||
} else if (n.kind === "LearnFailed") {
|
||||
message = t("connection.sshHostKeyNoticeLearnFailed", { host: n.host, port: n.port });
|
||||
} else {
|
||||
message = n.message || t("connection.sshHostKeyNoticeGeneric");
|
||||
}
|
||||
toast(message, 6000);
|
||||
handleNotice(event.payload);
|
||||
});
|
||||
if (!mounted) {
|
||||
noticeHandle();
|
||||
@@ -93,14 +121,29 @@ onMounted(async () => {
|
||||
void invoke("ssh_prompt_not_ready").catch(() => undefined);
|
||||
}
|
||||
} catch {
|
||||
// Not running inside Tauri (e.g. web preview) — no prompts to handle.
|
||||
// The desktop bridge is unavailable, so the connection remains fail-closed.
|
||||
}
|
||||
}
|
||||
|
||||
function setupWebPromptBridge() {
|
||||
webEventSource = new EventSource(apiUrl("/api/ssh/prompts"));
|
||||
webEventSource.onmessage = (event) => handleWebEvent(event.data);
|
||||
webEventSource.onerror = () => {
|
||||
// EventSource automatically retries. Do not close it while the app is mounted.
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isTauriRuntime()) void setupTauriPromptBridge();
|
||||
else setupWebPromptBridge();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
mounted = false;
|
||||
unlisteners.forEach((u) => u());
|
||||
void invoke("ssh_prompt_not_ready").catch(() => undefined);
|
||||
webEventSource?.close();
|
||||
webEventSource = null;
|
||||
if (isTauriRuntime()) void import("@tauri-apps/api/core").then(({ invoke }) => invoke("ssh_prompt_not_ready")).catch(() => undefined);
|
||||
});
|
||||
|
||||
async function resolve(action: "accept" | "reject" | "secret") {
|
||||
@@ -108,19 +151,14 @@ async function resolve(action: "accept" | "reject" | "secret") {
|
||||
if (!prompt || resolving.value) return;
|
||||
resolving.value = true;
|
||||
try {
|
||||
await invoke("resolve_ssh_prompt", {
|
||||
resolution: {
|
||||
id: prompt.id,
|
||||
action,
|
||||
remember: action === "accept" ? remember.value : undefined,
|
||||
secret: action === "secret" ? secretCode.value : undefined,
|
||||
},
|
||||
await resolveSshPrompt({
|
||||
id: prompt.id,
|
||||
action,
|
||||
remember: action === "accept" ? remember.value : undefined,
|
||||
secret: action === "secret" ? secretCode.value : undefined,
|
||||
});
|
||||
// Advance to the next queued prompt (if any) and reset per-prompt state.
|
||||
queue.value.shift();
|
||||
remember.value = true;
|
||||
secretCode.value = "";
|
||||
if (queue.value.length === 0) visible.value = false;
|
||||
dismissPrompt(prompt.id);
|
||||
} catch (e) {
|
||||
console.error("[DBX] resolve_ssh_prompt failed:", e);
|
||||
// "No pending" / "already cancelled" mean the backend already dropped this
|
||||
@@ -138,13 +176,15 @@ function dismissPrompt(id: string) {
|
||||
const idx = queue.value.findIndex((p) => p.id === id);
|
||||
if (idx === -1) return;
|
||||
queue.value.splice(idx, 1);
|
||||
if (idx === 0) {
|
||||
remember.value = true;
|
||||
secretCode.value = "";
|
||||
}
|
||||
if (idx === 0) resetPromptState();
|
||||
if (queue.value.length === 0) visible.value = false;
|
||||
}
|
||||
|
||||
function resetPromptState() {
|
||||
remember.value = true;
|
||||
secretCode.value = "";
|
||||
}
|
||||
|
||||
function accept() {
|
||||
void resolve("accept");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, type App } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
|
||||
const { resolveSshPromptMock } = vi.hoisted(() => ({
|
||||
resolveSshPromptMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({ resolveSshPrompt: resolveSshPromptMock }));
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
|
||||
|
||||
vi.mock("@/components/ui/dialog", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const passthrough = defineComponent({
|
||||
setup(_props, { slots }) {
|
||||
return () => h("div", slots.default?.());
|
||||
},
|
||||
});
|
||||
const dialog = defineComponent({
|
||||
props: { open: Boolean },
|
||||
setup(props, { slots }) {
|
||||
return () => (props.open ? h("div", slots.default?.()) : null);
|
||||
},
|
||||
});
|
||||
return {
|
||||
Dialog: dialog,
|
||||
DialogContent: passthrough,
|
||||
DialogDescription: passthrough,
|
||||
DialogFooter: passthrough,
|
||||
DialogHeader: passthrough,
|
||||
DialogTitle: passthrough,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/button", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
Button: defineComponent({
|
||||
inheritAttrs: false,
|
||||
setup(_props, { attrs, slots }) {
|
||||
return () => h("button", attrs, slots.default?.());
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import SshHostKeyPromptDialog from "@/components/ssh/SshHostKeyPromptDialog.vue";
|
||||
|
||||
class MockEventSource {
|
||||
static instances: MockEventSource[] = [];
|
||||
|
||||
readonly url: string;
|
||||
onmessage: ((event: MessageEvent<string>) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
closed = false;
|
||||
|
||||
constructor(url: string | URL) {
|
||||
this.url = String(url);
|
||||
MockEventSource.instances.push(this);
|
||||
}
|
||||
|
||||
emit(data: unknown) {
|
||||
this.onmessage?.(new MessageEvent("message", { data: JSON.stringify(data) }));
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
resolveSshPromptMock.mockReset().mockResolvedValue(undefined);
|
||||
MockEventSource.instances = [];
|
||||
vi.stubGlobal("EventSource", MockEventSource as unknown as typeof EventSource);
|
||||
i18n.global.locale.value = "en";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function mountDialog() {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => h(SshHostKeyPromptDialog);
|
||||
},
|
||||
}),
|
||||
);
|
||||
mountedApps.push(app);
|
||||
app.use(i18n);
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
describe("SshHostKeyPromptDialog web bridge", () => {
|
||||
it("shows an SSE host-key prompt and posts the user's acceptance", async () => {
|
||||
await mountDialog();
|
||||
|
||||
const eventSource = MockEventSource.instances[0];
|
||||
expect(eventSource?.url).toBe("/api/ssh/prompts");
|
||||
|
||||
eventSource?.emit({
|
||||
type: "prompt",
|
||||
request: {
|
||||
id: "prompt-1",
|
||||
kind: "HostKeyVerify",
|
||||
host: "ssh.example.test",
|
||||
port: 22,
|
||||
key_type: "ssh-ed25519",
|
||||
fingerprint: "SHA256:test-fingerprint",
|
||||
},
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(document.body.textContent).toContain("ssh.example.test:22");
|
||||
expect(document.body.textContent).toContain("SHA256:test-fingerprint");
|
||||
|
||||
const buttons = document.body.querySelectorAll<HTMLButtonElement>("button");
|
||||
buttons.item(buttons.length - 1).click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(resolveSshPromptMock).toHaveBeenCalledWith({
|
||||
id: "prompt-1",
|
||||
action: "accept",
|
||||
remember: true,
|
||||
secret: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("clears prompts that are no longer pending after an SSE reconnect snapshot", async () => {
|
||||
await mountDialog();
|
||||
|
||||
const eventSource = MockEventSource.instances[0];
|
||||
eventSource?.emit({
|
||||
type: "prompt",
|
||||
request: {
|
||||
id: "prompt-2",
|
||||
kind: "HostKeyVerify",
|
||||
host: "stale.example.test",
|
||||
port: 22,
|
||||
key_type: "ssh-ed25519",
|
||||
fingerprint: "SHA256:stale",
|
||||
},
|
||||
});
|
||||
await nextTick();
|
||||
expect(document.body.textContent).toContain("stale.example.test:22");
|
||||
|
||||
eventSource?.emit({ type: "sync", pendingIds: [] });
|
||||
await nextTick();
|
||||
expect(document.body.textContent).not.toContain("stale.example.test:22");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("web SSH prompt API", () => {
|
||||
it("posts the host-key resolution to the authenticated web endpoint", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(null),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { resolveSshPrompt } = await import("@/lib/backend/http");
|
||||
await resolveSshPrompt({ id: "prompt-1", action: "accept", remember: true });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/ssh/prompts/resolve", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: "prompt-1", action: "accept", remember: true }),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -67,6 +67,7 @@ export const loadConnections = forward("loadConnections");
|
||||
export const loadTunnelProfiles = forward("loadTunnelProfiles");
|
||||
export const saveTunnelProfiles = forward("saveTunnelProfiles");
|
||||
export const testTunnelProfile = forward("testTunnelProfile");
|
||||
export const resolveSshPrompt = forward("resolveSshPrompt");
|
||||
export const readKeychainPassword = forward("readKeychainPassword");
|
||||
export const readKeychainPasswords = forward("readKeychainPasswords");
|
||||
export const decryptConfig = forward("decryptConfig");
|
||||
|
||||
@@ -121,6 +121,7 @@ import type {
|
||||
MongoGridFsFileInfo,
|
||||
AppSupportInfo,
|
||||
PromptTemplate,
|
||||
SshPromptResolution,
|
||||
} from "@/lib/backend/tauri";
|
||||
import type { QueryEditability } from "@/lib/sql/sqlAnalysis";
|
||||
import { isTerminalTransferProgress } from "@/lib/backend/transferProgress";
|
||||
@@ -320,6 +321,10 @@ export async function testTunnelProfile(profile: TunnelProfile): Promise<string>
|
||||
return post("/api/tunnel-profiles/test", profile);
|
||||
}
|
||||
|
||||
export async function resolveSshPrompt(resolution: SshPromptResolution): Promise<void> {
|
||||
await post("/api/ssh/prompts/resolve", resolution);
|
||||
}
|
||||
|
||||
export async function readKeychainPassword(_service: string): Promise<string> {
|
||||
return ""; // Not available in web backend
|
||||
}
|
||||
|
||||
@@ -74,6 +74,13 @@ import type { CreateDatabaseSqlOptions } from "@/lib/database/createDatabaseSql"
|
||||
import type { DatabaseNameSqlOptions, DatabasePropertyEditSqlOptions, DropTableChildObjectSqlOptions, DropObjectSqlOptions, DuplicateTableStructureSqlOptions, CopyTableDataSqlOptions, SchemaNameSqlOptions, TableAdminSqlOptions } from "@/lib/database/dbAdminSql";
|
||||
import type { BuildDatabaseSqlExportOptions, BuildExportInsertStatementsOptions } from "@/lib/export/databaseExport";
|
||||
|
||||
export interface SshPromptResolution {
|
||||
id: string;
|
||||
action: "accept" | "reject" | "secret";
|
||||
remember?: boolean;
|
||||
secret?: string;
|
||||
}
|
||||
|
||||
export interface AgentDriverInfo {
|
||||
db_type: string;
|
||||
label: string;
|
||||
@@ -1280,6 +1287,10 @@ export async function testTunnelProfile(profile: TunnelProfile): Promise<string>
|
||||
return invoke("test_tunnel_profile", { profile });
|
||||
}
|
||||
|
||||
export async function resolveSshPrompt(resolution: SshPromptResolution): Promise<void> {
|
||||
await invoke("resolve_ssh_prompt", { resolution });
|
||||
}
|
||||
|
||||
export async function readKeychainPassword(service: string): Promise<string> {
|
||||
return invoke("read_keychain_password", { service, account: null });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user