This commit is contained in:
Vendored
+19
-5
File diff suppressed because one or more lines are too long
Vendored
+3
-3
File diff suppressed because one or more lines are too long
+106
-14
@@ -14,7 +14,7 @@ import {
|
||||
relayUrl,
|
||||
} from "./transport/rendezvous.js";
|
||||
import { Relay } from "./transport/relay.js";
|
||||
import { Session } from "./transport/session.js";
|
||||
import { Session, ConnectionDeclinedError } from "./transport/session.js";
|
||||
import { VideoPipeline } from "./decode/video.js";
|
||||
import { AudioPipeline } from "./decode/audio.js";
|
||||
import { CanvasView } from "./ui/canvas.js";
|
||||
@@ -63,8 +63,9 @@ function utf8Bytes(s: string): Uint8Array {
|
||||
|
||||
/**
|
||||
* Render a password prompt and return the entered string when the user
|
||||
* submits. The prompt re-renders with `errMsg` when set (e.g. "Wrong
|
||||
* Password" from a previous attempt).
|
||||
* submits. Only used as a fallback when the host signals a password is
|
||||
* required (Wrong/Empty Password) — click-approve hosts (e.g. hello-agent
|
||||
* with `approve-mode = click`) skip this entirely.
|
||||
*/
|
||||
function askPassword(cfg: CustomConfig, errMsg?: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
@@ -79,10 +80,10 @@ function askPassword(cfg: CustomConfig, errMsg?: string): Promise<string> {
|
||||
root.innerHTML = `
|
||||
<div class="placeholder">
|
||||
<h1>Connect to <code>${escapeHtml(cfg.peer_id)}</code></h1>
|
||||
<p class="muted">If the host requires a password, enter it below. Leave blank for "accept without password" hosts.</p>
|
||||
<p class="muted">The host requires a password to connect.</p>
|
||||
${errBlock}
|
||||
<form id="pw-form" class="pw-form">
|
||||
<input id="pw-input" type="password" autocomplete="current-password" placeholder="Password (optional)" />
|
||||
<input id="pw-input" type="password" autocomplete="current-password" placeholder="Password" />
|
||||
<button type="submit">Connect</button>
|
||||
</form>
|
||||
</div>`;
|
||||
@@ -96,6 +97,53 @@ function askPassword(cfg: CustomConfig, errMsg?: string): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the "waiting for approval" placeholder shown after the host
|
||||
* emits LoginResponse{error:"No Password Access"}. The Cancel button
|
||||
* reloads the page to abort cleanly — the websocket closes on unload,
|
||||
* which the host treats as a normal client disconnect.
|
||||
*/
|
||||
function renderAwaitingApproval(cfg: CustomConfig): void {
|
||||
status(`
|
||||
<h1>Waiting for approval</h1>
|
||||
<p>The user on <code>${escapeHtml(cfg.peer_id)}</code> is being asked to allow this connection.</p>
|
||||
<p class="muted">This window will continue automatically once they accept.</p>
|
||||
<form class="pw-form" id="cancel-form">
|
||||
<button type="submit">Cancel</button>
|
||||
</form>
|
||||
`);
|
||||
const form = document.getElementById("cancel-form") as HTMLFormElement | null;
|
||||
form?.addEventListener("submit", (ev) => {
|
||||
ev.preventDefault();
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the "host declined" placeholder. Resolves when the user clicks
|
||||
* Try Again, so the caller can re-enter its connect loop.
|
||||
*/
|
||||
function renderDeclined(cfg: CustomConfig, reason: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const detail = reason && reason !== "Closed manually by the peer"
|
||||
? `<p class="error-inline">${escapeHtml(reason)}</p>`
|
||||
: "";
|
||||
status(`
|
||||
<h1>Connection declined</h1>
|
||||
<p>The user on <code>${escapeHtml(cfg.peer_id)}</code> did not accept the connection request.</p>
|
||||
${detail}
|
||||
<form class="pw-form" id="retry-form">
|
||||
<button type="submit">Try again</button>
|
||||
</form>
|
||||
`);
|
||||
const form = document.getElementById("retry-form") as HTMLFormElement | null;
|
||||
form?.addEventListener("submit", (ev) => {
|
||||
ev.preventDefault();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const cfg = readConfig();
|
||||
await sodiumReady;
|
||||
@@ -104,21 +152,52 @@ async function main(): Promise<void> {
|
||||
const serverPk = base64Decode(cfg.key);
|
||||
if (serverPk.length !== 32) throw new Error(`server pk wrong length ${serverPk.length}`);
|
||||
|
||||
// Retry loop: prompt for password, attempt connect, if "Wrong Password"
|
||||
// re-prompt and try again. Other errors break out and surface as fatal.
|
||||
let lastErr: string | undefined;
|
||||
// Connect loop. We start with an empty password and let the host drive
|
||||
// the next state:
|
||||
// - Click-approve hosts (e.g. hello-agent): emit "No Password Access"
|
||||
// → we render the waiting-for-approval placeholder and keep reading
|
||||
// on the same encrypted channel until the real LoginResponse or a
|
||||
// close_reason arrives.
|
||||
// - Password-required hosts: emit "Wrong Password" / "Empty Password"
|
||||
// → we drop into the password prompt and retry with the user's
|
||||
// input. (No password prompt is shown unless the host asks for one.)
|
||||
// - User clicks No on the host's popup: surfaces as
|
||||
// ConnectionDeclinedError → "declined" placeholder with Try Again.
|
||||
let password = "";
|
||||
let pwErr: string | undefined;
|
||||
while (true) {
|
||||
const password = await askPassword(cfg, lastErr);
|
||||
if (pwErr !== undefined) {
|
||||
// Host has signalled it requires a password — collect one.
|
||||
password = await askPassword(cfg, pwErr);
|
||||
}
|
||||
try {
|
||||
const ready = await connectOnce(cfg, serverPk, password, /*statusUI=*/true);
|
||||
const ready = await connectOnce(
|
||||
cfg,
|
||||
serverPk,
|
||||
password,
|
||||
/*statusUI=*/true,
|
||||
() => renderAwaitingApproval(cfg),
|
||||
);
|
||||
// First connect succeeded — hand off to the session loop, which
|
||||
// owns reconnect.
|
||||
await runSession(cfg, serverPk, password, ready);
|
||||
return;
|
||||
} catch (e) {
|
||||
if (e instanceof ConnectionDeclinedError) {
|
||||
await renderDeclined(cfg, e.message);
|
||||
// Reset to empty-password attempt for the retry — the host's
|
||||
// approve mode hasn't changed, just the operator's mind.
|
||||
password = "";
|
||||
pwErr = undefined;
|
||||
continue;
|
||||
}
|
||||
const msg = String(e);
|
||||
if (msg.includes("Wrong Password") || msg.includes("Bad password") || msg.toLowerCase().includes("password")) {
|
||||
lastErr = "Wrong password — try again.";
|
||||
if (msg.includes("Wrong Password")) {
|
||||
pwErr = "Wrong password — try again.";
|
||||
continue;
|
||||
}
|
||||
if (msg.includes("Empty Password")) {
|
||||
pwErr = "This host requires a password.";
|
||||
continue;
|
||||
}
|
||||
console.error("[rustdesk-web] fatal:", e);
|
||||
@@ -148,6 +227,7 @@ async function connectOnce(
|
||||
serverPk: Uint8Array,
|
||||
password: string,
|
||||
statusUI: boolean,
|
||||
onAwaitingApproval?: () => void,
|
||||
): Promise<ConnectResult> {
|
||||
const setStatus = (html: string): void => { if (statusUI) status(html); };
|
||||
|
||||
@@ -200,6 +280,7 @@ async function connectOnce(
|
||||
password: utf8Bytes(password),
|
||||
clientVersion: CLIENT_VERSION,
|
||||
sessionId: Math.floor(Math.random() * Number.MAX_SAFE_INTEGER),
|
||||
onAwaitingApproval,
|
||||
});
|
||||
(window as unknown as { __rdw: unknown }).__rdw = { relay, session: ready.session, peerInfo: ready.peerInfo, cfg };
|
||||
return ready;
|
||||
@@ -582,13 +663,24 @@ async function runSession(
|
||||
for (attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
showOverlay(`Reconnecting (${attempt}/${MAX_ATTEMPTS})…`);
|
||||
try {
|
||||
reconnected = await connectOnce(cfg, serverPk, password, /*statusUI=*/false);
|
||||
reconnected = await connectOnce(
|
||||
cfg, serverPk, password, /*statusUI=*/false,
|
||||
// On click-approve hosts, every reconnect re-triggers the
|
||||
// approval popup on the host's screen. Surface that in the
|
||||
// overlay so the user knows why the reconnect is taking time.
|
||||
() => showOverlay(`Reconnecting — waiting for the user on ${cfg.peer_id} to accept…`),
|
||||
);
|
||||
break;
|
||||
} catch (e) {
|
||||
if (e instanceof ConnectionDeclinedError) {
|
||||
showOverlay(`Disconnected. The host declined the reconnect request.`);
|
||||
audio.close();
|
||||
return;
|
||||
}
|
||||
const msg = String(e);
|
||||
// Auth errors won't fix on retry — the peer changed something
|
||||
// (password rotated, key changed). Bail with a clear message.
|
||||
if (msg.toLowerCase().includes("password") ||
|
||||
if (msg.includes("Wrong Password") || msg.includes("Empty Password") ||
|
||||
msg.toLowerCase().includes("signature verify")) {
|
||||
showOverlay(`Cannot reconnect: ${msg.slice(0, 100)}`);
|
||||
audio.close();
|
||||
|
||||
@@ -18,6 +18,15 @@
|
||||
// - empty password = SHA256(SHA256("" || salt) || challenge) on the desktop
|
||||
// Peer -> Us: Message{login_response: {peer_info or error}}
|
||||
//
|
||||
// Click-approve flow (host configured with `approve-mode = click`, e.g.
|
||||
// hello-agent): the host's connection.rs:2465-2476 path sends an interim
|
||||
// LoginResponse{error: "No Password Access"} BUT keeps the connection
|
||||
// open while it shows the approval popup on the user's desktop. On
|
||||
// approve, the host sends a SECOND LoginResponse with peer_info; on
|
||||
// deny, the host sends Misc{close_reason: "Closed manually by the peer"}
|
||||
// and closes the connection. We surface the wait via `onAwaitingApproval`
|
||||
// and translate denial into ConnectionDeclinedError.
|
||||
//
|
||||
// Sequence-counter rule (the bit that bites every reimplementation —
|
||||
// see libs/hbb_common/src/tcp.rs:296-344): SEND and RECV each maintain
|
||||
// their OWN counter starting at 0, only the secretbox-encrypted frames
|
||||
@@ -50,6 +59,27 @@ export interface SessionOptions {
|
||||
clientVersion: string;
|
||||
/** Random session id (uint64). Number value (≤ 53 bits) — opaque to peer. */
|
||||
sessionId: number;
|
||||
/**
|
||||
* Fired once if the host responds to LoginRequest with the interim
|
||||
* "No Password Access" error — meaning it's now showing an approval
|
||||
* popup to the user on its end and we should keep waiting for the
|
||||
* real LoginResponse. Use this to swap the UI to "waiting for
|
||||
* approval" without aborting the connection.
|
||||
*/
|
||||
onAwaitingApproval?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when the remote user explicitly declines the connection during
|
||||
* the click-approve flow (host emits Misc{close_reason} after the user
|
||||
* clicks No on the approval popup). Distinct from network/auth failures
|
||||
* so the caller can render a "denied" UI rather than a generic error.
|
||||
*/
|
||||
export class ConnectionDeclinedError extends Error {
|
||||
constructor(reason: string) {
|
||||
super(reason || "The remote user declined the connection.");
|
||||
this.name = "ConnectionDeclinedError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface SessionReady {
|
||||
@@ -188,6 +218,7 @@ export class Session {
|
||||
// every non-trivial message into preloginExtras so the caller can
|
||||
// replay them into its main receive dispatch.
|
||||
const preloginExtras: hbb.Message[] = [];
|
||||
let approvalNotified = false;
|
||||
while (true) {
|
||||
const respMsg = await session.recv();
|
||||
if (respMsg.test_delay) {
|
||||
@@ -204,11 +235,34 @@ export class Session {
|
||||
}
|
||||
if (respMsg.login_response) {
|
||||
const lr = respMsg.login_response;
|
||||
if (lr.error) throw new Error(`session: login refused: ${lr.error}`);
|
||||
if (lr.error) {
|
||||
// "No Password Access" is the host's signal that approve-mode
|
||||
// is Click (or Both without a valid password) — it has spawned
|
||||
// the approval popup and will send a second LoginResponse once
|
||||
// the user clicks Yes. Don't treat this as fatal; keep
|
||||
// receiving on the same encrypted channel.
|
||||
// See /Users/sn0/Desktop/rustdesk/src/server/connection.rs:2465-2476
|
||||
// and src/client.rs:118 (LOGIN_MSG_NO_PASSWORD_ACCESS).
|
||||
if (lr.error === "No Password Access") {
|
||||
if (!approvalNotified) {
|
||||
approvalNotified = true;
|
||||
try { opts.onAwaitingApproval?.(); } catch { /* swallow */ }
|
||||
}
|
||||
continue;
|
||||
}
|
||||
throw new Error(`session: login refused: ${lr.error}`);
|
||||
}
|
||||
if (!lr.peer_info) throw new Error("session: login_response missing peer_info");
|
||||
console.log(`[rustdesk-web] session: login OK, peer=${lr.peer_info.hostname}/${lr.peer_info.platform} v${lr.peer_info.version}`);
|
||||
return { session, peerInfo: lr.peer_info, preloginExtras };
|
||||
}
|
||||
if (respMsg.misc?.close_reason != null) {
|
||||
// The host sends close_reason when the operator clicks No on the
|
||||
// approval popup (CM → Authorize/Close → send_close_reason_no_retry).
|
||||
// Surface it as a typed error so the UI can show a "declined"
|
||||
// state rather than a generic connect failure.
|
||||
throw new ConnectionDeclinedError(respMsg.misc.close_reason);
|
||||
}
|
||||
// Stash the message for the caller to replay post-login.
|
||||
preloginExtras.push(respMsg);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user