fix(ssh): recover first-connect host-key prompts on web
This commit is contained in:
parent
db791b5aed
commit
7fbee89144
|
|
@ -42,6 +42,7 @@ const secretCode = ref("");
|
|||
const resolving = ref(false);
|
||||
const unlisteners: Array<() => void> = [];
|
||||
let webEventSource: EventSource | null = null;
|
||||
let pendingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let mounted = true;
|
||||
|
||||
function enqueuePrompt(request: SshPromptRequest) {
|
||||
|
|
@ -125,12 +126,43 @@ async function setupTauriPromptBridge() {
|
|||
}
|
||||
}
|
||||
|
||||
// Fallback delivery alongside the SSE stream. On first connect the dialog's
|
||||
// EventSource may not be open yet when the backend fires a host-key prompt, so
|
||||
// the SSE `Prompt` event is lost and (without this) the connection hangs until
|
||||
// its timeout. Polling the pending endpoint recovers any lost prompt so the
|
||||
// dialog still appears. `enqueuePrompt` dedupes by id, so repeated polls are
|
||||
// harmless.
|
||||
async function fetchPendingPrompts() {
|
||||
try {
|
||||
const res = await fetch(apiUrl("/api/ssh/prompts/pending"), { credentials: "include" });
|
||||
if (!res.ok) return;
|
||||
const pending = (await res.json()) as SshPromptRequest[];
|
||||
pending.forEach((req) => enqueuePrompt(req));
|
||||
} catch {
|
||||
// Transient failure — the next poll retries.
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
webEventSource.onopen = () => {
|
||||
// The SSE stream is live; its replay already carries any prompt that fired
|
||||
// before the stream opened, so stop polling to avoid needless requests.
|
||||
if (pendingTimer) {
|
||||
clearInterval(pendingTimer);
|
||||
pendingTimer = null;
|
||||
}
|
||||
};
|
||||
webEventSource.onerror = () => {
|
||||
// EventSource reconnects automatically. While disconnected, fall back to
|
||||
// polling so a prompt fired during the outage is not lost.
|
||||
if (!pendingTimer) pendingTimer = setInterval(fetchPendingPrompts, 2000);
|
||||
};
|
||||
// First-connect fallback: the stream may not be open yet when the backend
|
||||
// fires a host-key prompt, so poll until it is.
|
||||
void fetchPendingPrompts();
|
||||
if (!pendingTimer) pendingTimer = setInterval(fetchPendingPrompts, 2000);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
|
@ -143,6 +175,10 @@ onBeforeUnmount(() => {
|
|||
unlisteners.forEach((u) => u());
|
||||
webEventSource?.close();
|
||||
webEventSource = null;
|
||||
if (pendingTimer) {
|
||||
clearInterval(pendingTimer);
|
||||
pendingTimer = null;
|
||||
}
|
||||
if (isTauriRuntime()) void import("@tauri-apps/api/core").then(({ invoke }) => invoke("ssh_prompt_not_ready")).catch(() => undefined);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ class MockEventSource {
|
|||
|
||||
readonly url: string;
|
||||
onmessage: ((event: MessageEvent<string>) => void) | null = null;
|
||||
onopen: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
closed = false;
|
||||
|
||||
|
|
@ -66,6 +67,14 @@ class MockEventSource {
|
|||
this.onmessage?.(new MessageEvent("message", { data: JSON.stringify(data) }));
|
||||
}
|
||||
|
||||
open() {
|
||||
this.onopen?.();
|
||||
}
|
||||
|
||||
error() {
|
||||
this.onerror?.();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
|
|
@ -77,6 +86,7 @@ beforeEach(() => {
|
|||
resolveSshPromptMock.mockReset().mockResolvedValue(undefined);
|
||||
MockEventSource.instances = [];
|
||||
vi.stubGlobal("EventSource", MockEventSource as unknown as typeof EventSource);
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => [] } as Response));
|
||||
i18n.global.locale.value = "en";
|
||||
});
|
||||
|
||||
|
|
@ -160,4 +170,51 @@ describe("SshHostKeyPromptDialog web bridge", () => {
|
|||
await nextTick();
|
||||
expect(document.body.textContent).not.toContain("stale.example.test:22");
|
||||
});
|
||||
|
||||
it("recovers a pending host-key prompt via the polling fallback when the SSE event is missed", async () => {
|
||||
// Simulate a prompt that fired before the EventSource was open: the backend
|
||||
// has it pending, but the SSE `Prompt` event never reached the dialog. The
|
||||
// polling fallback must surface it so the user can still confirm.
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{
|
||||
id: "prompt-3",
|
||||
kind: "HostKeyVerify",
|
||||
host: "first.example.test",
|
||||
port: 22,
|
||||
key_type: "ssh-ed25519",
|
||||
fingerprint: "SHA256:first",
|
||||
},
|
||||
],
|
||||
} as Response);
|
||||
|
||||
await mountDialog();
|
||||
await nextTick();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.textContent).toContain("first.example.test:22");
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/ssh/prompts/pending", { credentials: "include" });
|
||||
});
|
||||
|
||||
it("stops polling once the SSE stream opens and resumes it after an error", async () => {
|
||||
const setSpy = vi.spyOn(globalThis, "setInterval");
|
||||
const clearSpy = vi.spyOn(globalThis, "clearInterval");
|
||||
|
||||
await mountDialog();
|
||||
const eventSource = MockEventSource.instances[0];
|
||||
|
||||
// Polling is armed on mount (first-connect fallback).
|
||||
expect(setSpy).toHaveBeenCalled();
|
||||
|
||||
// Opening the stream stops the poller (SSE replay now covers lost prompts).
|
||||
eventSource?.open();
|
||||
expect(clearSpy).toHaveBeenCalled();
|
||||
|
||||
// A disconnect re-arms polling so prompts are not lost during the outage.
|
||||
eventSource?.error();
|
||||
expect(setSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -312,6 +312,7 @@ async fn main() {
|
|||
.route("/system/fonts", get(routes::jdbc::list_system_fonts))
|
||||
.route("/ssh/config-hosts", get(routes::ssh_config::list_ssh_config_hosts))
|
||||
.route("/ssh/prompts", get(routes::ssh_prompt::stream_ssh_prompts))
|
||||
.route("/ssh/prompts/pending", get(routes::ssh_prompt::list_pending_ssh_prompts))
|
||||
.route("/ssh/prompts/resolve", post(routes::ssh_prompt::resolve_ssh_prompt))
|
||||
// Tunnel profiles
|
||||
.route("/tunnel-profiles/list", get(routes::tunnel_profiles::load_tunnel_profiles))
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use axum::extract::State;
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use axum::Json;
|
||||
use dbx_core::db::ssh_prompt::SshPromptAnswer;
|
||||
use dbx_core::db::ssh_prompt::{SshPromptAnswer, SshPromptRequest};
|
||||
use futures::Stream;
|
||||
use serde::Deserialize;
|
||||
use std::convert::Infallible;
|
||||
|
|
@ -54,6 +54,13 @@ pub async fn resolve_ssh_prompt(
|
|||
Ok(Json(()))
|
||||
}
|
||||
|
||||
/// Returns all currently-pending host-key prompts. The frontend polls this as a
|
||||
/// fallback to the SSE stream so a prompt that fired before the EventSource was
|
||||
/// open is still recovered (see the SSH host-key first-connect regression).
|
||||
pub async fn list_pending_ssh_prompts(State(state): State<Arc<WebState>>) -> Json<Vec<SshPromptRequest>> {
|
||||
Json(state.ssh_prompts.pending_requests())
|
||||
}
|
||||
|
||||
fn ssh_prompt_event(event: SshPromptEvent) -> Event {
|
||||
Event::default().data(serde_json::to_string(&event).expect("SSH prompt events must serialize"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,14 @@ impl SshPromptHub {
|
|||
(replay, receiver)
|
||||
}
|
||||
|
||||
/// Snapshot of all currently-pending prompt requests. The frontend uses this
|
||||
/// as a fallback alongside the SSE stream: an SSE `Prompt` event that was
|
||||
/// lost (e.g. the EventSource was not yet open when the prompt fired) is
|
||||
/// recovered by polling this endpoint. Duplicates are deduped by the caller.
|
||||
pub fn pending_requests(&self) -> Vec<SshPromptRequest> {
|
||||
self.pending.lock().unwrap().iter().map(|prompt| prompt.request.clone()).collect()
|
||||
}
|
||||
|
||||
pub fn resolve(&self, id: &str, answer: SshPromptAnswer) -> Result<(), String> {
|
||||
let pending = {
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
|
|
@ -159,4 +167,24 @@ mod tests {
|
|||
hub.resolve("prompt-2", SshPromptAnswer::Reject).unwrap();
|
||||
assert!(matches!(receiver.await.unwrap(), SshPromptAnswer::Reject));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_requests_snapshots_unresolved_prompts_and_drops_resolved() {
|
||||
let hub = SshPromptHub::new();
|
||||
let (responder_a, _ra) = tokio::sync::oneshot::channel();
|
||||
let (responder_b, _rb) = tokio::sync::oneshot::channel();
|
||||
hub.register(SshPromptEnvelope { request: request("prompt-a"), responder: responder_a });
|
||||
hub.register(SshPromptEnvelope { request: request("prompt-b"), responder: responder_b });
|
||||
|
||||
let pending = hub.pending_requests();
|
||||
assert_eq!(pending.len(), 2);
|
||||
assert_eq!(pending[0].id, "prompt-a");
|
||||
assert_eq!(pending[1].id, "prompt-b");
|
||||
|
||||
// A resolved prompt is removed from the pending snapshot.
|
||||
hub.resolve("prompt-a", SshPromptAnswer::Reject).unwrap();
|
||||
let pending = hub.pending_requests();
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].id, "prompt-b");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue