Implement password handling for unattended access
build / build-linux-amd64 (push) Successful in 2m0s
build / build-linux-amd64 (push) Successful in 2m0s
This commit is contained in:
@@ -139,6 +139,7 @@ async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> {
|
|||||||
<th class="text-left font-medium px-3 py-2">Owner</th>
|
<th class="text-left font-medium px-3 py-2">Owner</th>
|
||||||
<th class="text-left font-medium px-3 py-2">Hostname</th>
|
<th class="text-left font-medium px-3 py-2">Hostname</th>
|
||||||
<th class="text-left font-medium px-3 py-2">User</th>
|
<th class="text-left font-medium px-3 py-2">User</th>
|
||||||
|
<th class="text-left font-medium px-3 py-2">Unattended pwd</th>
|
||||||
<th class="text-left font-medium px-3 py-2">OS</th>
|
<th class="text-left font-medium px-3 py-2">OS</th>
|
||||||
<th class="text-left font-medium px-3 py-2">Version</th>
|
<th class="text-left font-medium px-3 py-2">Version</th>
|
||||||
<th class="text-left font-medium px-3 py-2">Last heartbeat</th>
|
<th class="text-left font-medium px-3 py-2">Last heartbeat</th>
|
||||||
@@ -150,7 +151,7 @@ async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> {
|
|||||||
);
|
);
|
||||||
if devices.is_empty() {
|
if devices.is_empty() {
|
||||||
s.push_str(
|
s.push_str(
|
||||||
r##"<tr><td colspan="9" class="px-3 py-4 text-slate-500 text-center text-xs">No devices have heartbeated yet.</td></tr>"##,
|
r##"<tr><td colspan="10" class="px-3 py-4 text-slate-500 text-center text-xs">No devices have heartbeated yet.</td></tr>"##,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for d in &devices {
|
for d in &devices {
|
||||||
@@ -223,6 +224,25 @@ fn render_device_row(s: &mut String, d: &DashboardDeviceRow, now: chrono::DateTi
|
|||||||
format!("Offline — last heartbeat {} ago", fmt_age(age_secs)),
|
format!("Offline — last heartbeat {} ago", fmt_age(age_secs)),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
// Per-boot unattended-access password reported by hello-agent. Visible
|
||||||
|
// only when (a) the device is online (offline rows show stale data),
|
||||||
|
// (b) no interactive user is logged in (otherwise the supporter
|
||||||
|
// should be using the per-session approval popup, not the password),
|
||||||
|
// and (c) the agent has actually reported one (vanilla rustdesk
|
||||||
|
// never will). Otherwise show a neutral dash so the column lines up.
|
||||||
|
let unattended_pwd_cell = if is_online
|
||||||
|
&& active_user.is_empty()
|
||||||
|
&& !d.unattended_password.is_empty()
|
||||||
|
{
|
||||||
|
format!(
|
||||||
|
r##"<code class="font-mono text-xs text-amber-300 bg-slate-950 px-1.5 py-0.5 rounded border border-slate-800" title="Reported {set_at} UTC">{pw}</code>"##,
|
||||||
|
pw = html_escape(&d.unattended_password),
|
||||||
|
set_at = html_escape(&d.unattended_password_set_at),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
r##"<span class="text-slate-600">—</span>"##.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
let id_cell = format!(
|
let id_cell = format!(
|
||||||
r##"<td class="px-3 py-2 font-mono text-slate-200 whitespace-nowrap">
|
r##"<td class="px-3 py-2 font-mono text-slate-200 whitespace-nowrap">
|
||||||
<span class="inline-flex items-center gap-2" title="{tt}">
|
<span class="inline-flex items-center gap-2" title="{tt}">
|
||||||
@@ -241,6 +261,7 @@ fn render_device_row(s: &mut String, d: &DashboardDeviceRow, now: chrono::DateTi
|
|||||||
<td class="px-3 py-2 text-slate-300">{owner}</td>
|
<td class="px-3 py-2 text-slate-300">{owner}</td>
|
||||||
<td class="px-3 py-2 text-slate-400">{host}</td>
|
<td class="px-3 py-2 text-slate-400">{host}</td>
|
||||||
<td class="px-3 py-2 text-slate-300">{user}</td>
|
<td class="px-3 py-2 text-slate-300">{user}</td>
|
||||||
|
<td class="px-3 py-2 whitespace-nowrap">{unattended_pwd}</td>
|
||||||
<td class="px-3 py-2 text-slate-400">{os}</td>
|
<td class="px-3 py-2 text-slate-400">{os}</td>
|
||||||
<td class="px-3 py-2 text-slate-400 whitespace-nowrap">{ver}</td>
|
<td class="px-3 py-2 text-slate-400 whitespace-nowrap">{ver}</td>
|
||||||
<td class="px-3 py-2 text-slate-500 text-xs">{last}</td>
|
<td class="px-3 py-2 text-slate-500 text-xs">{last}</td>
|
||||||
@@ -285,6 +306,7 @@ fn render_device_row(s: &mut String, d: &DashboardDeviceRow, now: chrono::DateTi
|
|||||||
} else {
|
} else {
|
||||||
html_escape(&active_user)
|
html_escape(&active_user)
|
||||||
},
|
},
|
||||||
|
unattended_pwd = unattended_pwd_cell,
|
||||||
os = html_escape(&os),
|
os = html_escape(&os),
|
||||||
ver = html_escape(&version_label),
|
ver = html_escape(&version_label),
|
||||||
last = html_escape(&d.last_heartbeat_at),
|
last = html_escape(&d.last_heartbeat_at),
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ pub mod state;
|
|||||||
pub mod strategy;
|
pub mod strategy;
|
||||||
pub mod sysinfo;
|
pub mod sysinfo;
|
||||||
pub mod twofa;
|
pub mod twofa;
|
||||||
|
pub mod unattended;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
|
||||||
pub use state::AppState;
|
pub use state::AppState;
|
||||||
@@ -47,6 +48,10 @@ pub fn router(state: Arc<AppState>) -> Router {
|
|||||||
.route("/api/heartbeat", post(heartbeat::heartbeat))
|
.route("/api/heartbeat", post(heartbeat::heartbeat))
|
||||||
.route("/api/sysinfo_ver", post(sysinfo::sysinfo_ver))
|
.route("/api/sysinfo_ver", post(sysinfo::sysinfo_ver))
|
||||||
.route("/api/sysinfo", post(sysinfo::sysinfo))
|
.route("/api/sysinfo", post(sysinfo::sysinfo))
|
||||||
|
.route(
|
||||||
|
"/api/unattended-password",
|
||||||
|
post(unattended::unattended_password),
|
||||||
|
)
|
||||||
// M2: address book — modern (shared + personal)
|
// M2: address book — modern (shared + personal)
|
||||||
.route("/api/ab/settings", post(ab::settings::settings))
|
.route("/api/ab/settings", post(ab::settings::settings))
|
||||||
.route("/api/ab/personal", post(ab::profiles::personal))
|
.route("/api/ab/personal", post(ab::profiles::personal))
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
//! `POST /api/unattended-password` — agent-side reporting of the per-boot
|
||||||
|
//! "permanent password" used for unattended access (no logged-in user to
|
||||||
|
//! click the approval popup). hello-agent generates a random password
|
||||||
|
//! every time the service starts and posts it here so the admin UI can
|
||||||
|
//! surface it for support staff.
|
||||||
|
//!
|
||||||
|
//! Auth model mirrors `/api/sysinfo`: the request must carry the agent's
|
||||||
|
//! `(id, uuid)` and that pair must already correspond to a registered
|
||||||
|
//! peer in `peer`. There's no shared secret beyond that — same trust
|
||||||
|
//! boundary the existing sysinfo endpoint already operates under.
|
||||||
|
|
||||||
|
use crate::api::error::ApiError;
|
||||||
|
use crate::api::state::AppState;
|
||||||
|
use axum::extract::Extension;
|
||||||
|
use axum::Json;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Body: `{"id": "...", "uuid": "...", "password": "..."}`
|
||||||
|
/// Response (bare string, like sysinfo): `"OK"` or `"ID_NOT_FOUND"`.
|
||||||
|
pub async fn unattended_password(
|
||||||
|
Extension(state): Extension<Arc<AppState>>,
|
||||||
|
Json(payload): Json<Value>,
|
||||||
|
) -> Result<String, ApiError> {
|
||||||
|
let id = payload
|
||||||
|
.get("id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let uuid = payload
|
||||||
|
.get("uuid")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let password = payload
|
||||||
|
.get("password")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if id.is_empty() || uuid.is_empty() || password.is_empty() {
|
||||||
|
return Err(ApiError::BadRequest(
|
||||||
|
"id, uuid, and password are required".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let peer = state
|
||||||
|
.db
|
||||||
|
.get_peer(id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||||
|
if peer.is_none() {
|
||||||
|
return Ok("ID_NOT_FOUND".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.set_unattended_password(id, uuid, password)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||||
|
Ok("OK".to_string())
|
||||||
|
}
|
||||||
+47
-2
@@ -205,6 +205,13 @@ pub struct DashboardDeviceRow {
|
|||||||
pub last_heartbeat_at: String,
|
pub last_heartbeat_at: String,
|
||||||
pub sysinfo_payload: String,
|
pub sysinfo_payload: String,
|
||||||
pub conns_json: String,
|
pub conns_json: String,
|
||||||
|
/// Plaintext per-boot password reported by the agent for unattended
|
||||||
|
/// access. Empty when the agent hasn't reported one (vanilla rustdesk
|
||||||
|
/// or hello-agent that hasn't called the endpoint yet). The admin UI
|
||||||
|
/// only surfaces this when the row is online AND no interactive user
|
||||||
|
/// is logged in.
|
||||||
|
pub unattended_password: String,
|
||||||
|
pub unattended_password_set_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
@@ -368,7 +375,9 @@ impl Database {
|
|||||||
}
|
}
|
||||||
// Soft-ALTERs run after schema creation. SQLite < 3.35 lacks
|
// Soft-ALTERs run after schema creation. SQLite < 3.35 lacks
|
||||||
// `ADD COLUMN IF NOT EXISTS`; swallow the duplicate-column error
|
// `ADD COLUMN IF NOT EXISTS`; swallow the duplicate-column error
|
||||||
// so re-runs are idempotent.
|
// so re-runs are idempotent. Newly-added soft alters get appended
|
||||||
|
// to the same list — order doesn't matter beyond "after the table
|
||||||
|
// they touch exists in M*_SCHEMA".
|
||||||
for stmt in M2_SOFT_ALTERS {
|
for stmt in M2_SOFT_ALTERS {
|
||||||
self.try_alter(stmt).await;
|
self.try_alter(stmt).await;
|
||||||
}
|
}
|
||||||
@@ -604,7 +613,9 @@ impl Database {
|
|||||||
COALESCE(u.username, '') AS owner_username, \
|
COALESCE(u.username, '') AS owner_username, \
|
||||||
ds.last_heartbeat_at AS last_hb, \
|
ds.last_heartbeat_at AS last_hb, \
|
||||||
ds.payload AS payload, \
|
ds.payload AS payload, \
|
||||||
ds.conns AS conns \
|
ds.conns AS conns, \
|
||||||
|
COALESCE(ds.unattended_password, '') AS u_pw, \
|
||||||
|
COALESCE(ds.unattended_password_set_at, '') AS u_pw_at \
|
||||||
FROM device_sysinfo ds \
|
FROM device_sysinfo ds \
|
||||||
LEFT JOIN users u ON u.id = ds.user_id \
|
LEFT JOIN users u ON u.id = ds.user_id \
|
||||||
ORDER BY ds.last_heartbeat_at DESC LIMIT ? OFFSET ?",
|
ORDER BY ds.last_heartbeat_at DESC LIMIT ? OFFSET ?",
|
||||||
@@ -622,6 +633,8 @@ impl Database {
|
|||||||
last_heartbeat_at: r.try_get("last_hb").unwrap_or_default(),
|
last_heartbeat_at: r.try_get("last_hb").unwrap_or_default(),
|
||||||
sysinfo_payload: r.try_get("payload").unwrap_or_default(),
|
sysinfo_payload: r.try_get("payload").unwrap_or_default(),
|
||||||
conns_json: r.try_get("conns").unwrap_or_default(),
|
conns_json: r.try_get("conns").unwrap_or_default(),
|
||||||
|
unattended_password: r.try_get("u_pw").unwrap_or_default(),
|
||||||
|
unattended_password_set_at: r.try_get("u_pw_at").unwrap_or_default(),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Ok((total, data))
|
Ok((total, data))
|
||||||
@@ -1116,6 +1129,31 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Store the agent's per-boot unattended-access password. Upserts so a
|
||||||
|
/// device that's never sysinfo'd yet still gets a `device_sysinfo` row
|
||||||
|
/// to hang the password on. Caller is expected to have validated the
|
||||||
|
/// (id, uuid) pair against `peer` first — same gate as sysinfo_upsert.
|
||||||
|
pub async fn set_unattended_password(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
uuid: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> ResultType<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO device_sysinfo(id, uuid, unattended_password, unattended_password_set_at) \
|
||||||
|
VALUES(?, ?, ?, current_timestamp) \
|
||||||
|
ON CONFLICT(id, uuid) DO UPDATE SET \
|
||||||
|
unattended_password = excluded.unattended_password, \
|
||||||
|
unattended_password_set_at = current_timestamp",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(uuid)
|
||||||
|
.bind(password)
|
||||||
|
.execute(self.pool.get().await?.deref_mut())
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
// M2: address book / tags / device groups / accessibility
|
// M2: address book / tags / device groups / accessibility
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
@@ -2971,6 +3009,13 @@ const M2_SOFT_ALTERS: &[&str] = &[
|
|||||||
// login — promotion AND demotion at the IdP propagate.
|
// login — promotion AND demotion at the IdP propagate.
|
||||||
"ALTER TABLE oidc_providers ADD COLUMN admin_role TEXT",
|
"ALTER TABLE oidc_providers ADD COLUMN admin_role TEXT",
|
||||||
"ALTER TABLE oidc_providers ADD COLUMN roles_claim TEXT",
|
"ALTER TABLE oidc_providers ADD COLUMN roles_claim TEXT",
|
||||||
|
// Unattended-access password. Some agents (hello-agent) generate a
|
||||||
|
// random "permanent password" on every boot and report it back here
|
||||||
|
// so a supporter can reach the box when no user is logged in to
|
||||||
|
// approve a connection. Stored as plaintext on purpose: the admin UI
|
||||||
|
// displays it for the operator to read, and it rotates each boot.
|
||||||
|
"ALTER TABLE device_sysinfo ADD COLUMN unattended_password TEXT",
|
||||||
|
"ALTER TABLE device_sysinfo ADD COLUMN unattended_password_set_at DATETIME",
|
||||||
];
|
];
|
||||||
|
|
||||||
const M3_SCHEMA: &[&str] = &[
|
const M3_SCHEMA: &[&str] = &[
|
||||||
|
|||||||
Vendored
+8
@@ -80,6 +80,14 @@ html, body {
|
|||||||
}
|
}
|
||||||
.pw-form button:hover { background: #0369a1; }
|
.pw-form button:hover { background: #0369a1; }
|
||||||
|
|
||||||
|
/* Separator between "waiting for approval / cancel" and the unattended-
|
||||||
|
* password override on the awaiting-approval screen. */
|
||||||
|
.pw-divider {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
margin: 20px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.error-inline {
|
.error-inline {
|
||||||
background: rgba(220, 38, 38, 0.15);
|
background: rgba(220, 38, 38, 0.15);
|
||||||
border: 1px solid rgba(220, 38, 38, 0.4);
|
border: 1px solid rgba(220, 38, 38, 0.4);
|
||||||
|
|||||||
Vendored
+13
-7
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
+53
-8
@@ -99,11 +99,22 @@ function askPassword(cfg: CustomConfig, errMsg?: string): Promise<string> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the "waiting for approval" placeholder shown after the host
|
* Render the "waiting for approval" placeholder shown after the host
|
||||||
* emits LoginResponse{error:"No Password Access"}. The Cancel button
|
* emits LoginResponse{error:"No Password Access"} or accepts our
|
||||||
* reloads the page to abort cleanly — the websocket closes on unload,
|
* empty-password LoginRequest into its `try_start_cm` branch. Two
|
||||||
* which the host treats as a normal client disconnect.
|
* affordances:
|
||||||
|
* - Cancel: reload the page (closes the websocket, host sees a
|
||||||
|
* clean client disconnect).
|
||||||
|
* - Submit a password: tell the caller, which closes the in-flight
|
||||||
|
* session and restarts the connect loop with that password — used
|
||||||
|
* for unattended access when no user is logged in to click Accept.
|
||||||
*/
|
*/
|
||||||
function renderAwaitingApproval(cfg: CustomConfig): void {
|
interface AwaitingApprovalCallbacks {
|
||||||
|
onUsePassword: (pw: string) => void;
|
||||||
|
}
|
||||||
|
function renderAwaitingApproval(
|
||||||
|
cfg: CustomConfig,
|
||||||
|
callbacks: AwaitingApprovalCallbacks,
|
||||||
|
): void {
|
||||||
status(`
|
status(`
|
||||||
<h1>Waiting for approval</h1>
|
<h1>Waiting for approval</h1>
|
||||||
<p>The user on <code>${escapeHtml(cfg.peer_id)}</code> is being asked to allow this connection.</p>
|
<p>The user on <code>${escapeHtml(cfg.peer_id)}</code> is being asked to allow this connection.</p>
|
||||||
@@ -111,12 +122,26 @@ function renderAwaitingApproval(cfg: CustomConfig): void {
|
|||||||
<form class="pw-form" id="cancel-form">
|
<form class="pw-form" id="cancel-form">
|
||||||
<button type="submit">Cancel</button>
|
<button type="submit">Cancel</button>
|
||||||
</form>
|
</form>
|
||||||
|
<hr class="pw-divider" />
|
||||||
|
<p class="muted">Or connect with the unattended password:</p>
|
||||||
|
<form class="pw-form" id="override-form">
|
||||||
|
<input id="override-input" type="password" autocomplete="current-password" placeholder="Password" />
|
||||||
|
<button type="submit">Connect</button>
|
||||||
|
</form>
|
||||||
`);
|
`);
|
||||||
const form = document.getElementById("cancel-form") as HTMLFormElement | null;
|
const cancelForm = document.getElementById("cancel-form") as HTMLFormElement | null;
|
||||||
form?.addEventListener("submit", (ev) => {
|
cancelForm?.addEventListener("submit", (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
});
|
});
|
||||||
|
const overrideForm = document.getElementById("override-form") as HTMLFormElement | null;
|
||||||
|
const overrideInput = document.getElementById("override-input") as HTMLInputElement | null;
|
||||||
|
overrideForm?.addEventListener("submit", (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const pw = overrideInput?.value || "";
|
||||||
|
if (!pw) return;
|
||||||
|
callbacks.onUsePassword(pw);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -165,6 +190,12 @@ async function main(): Promise<void> {
|
|||||||
// ConnectionDeclinedError → "declined" placeholder with Try Again.
|
// ConnectionDeclinedError → "declined" placeholder with Try Again.
|
||||||
let password = "";
|
let password = "";
|
||||||
let pwErr: string | undefined;
|
let pwErr: string | undefined;
|
||||||
|
// When the user submits a password from the awaiting-approval screen,
|
||||||
|
// the renderer sets this and aborts the in-flight session. The catch
|
||||||
|
// block below sees the resulting error, picks up the password, and
|
||||||
|
// re-enters the connect loop — without going through askPassword
|
||||||
|
// (which would replace the awaiting UI with its own prompt mid-flow).
|
||||||
|
let overridePassword: string | null = null;
|
||||||
while (true) {
|
while (true) {
|
||||||
if (pwErr !== undefined) {
|
if (pwErr !== undefined) {
|
||||||
// Host has signalled it requires a password — collect one.
|
// Host has signalled it requires a password — collect one.
|
||||||
@@ -176,13 +207,27 @@ async function main(): Promise<void> {
|
|||||||
serverPk,
|
serverPk,
|
||||||
password,
|
password,
|
||||||
/*statusUI=*/true,
|
/*statusUI=*/true,
|
||||||
() => renderAwaitingApproval(cfg),
|
(abort) =>
|
||||||
|
renderAwaitingApproval(cfg, {
|
||||||
|
onUsePassword: (pw) => {
|
||||||
|
overridePassword = pw;
|
||||||
|
abort();
|
||||||
|
},
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
// First connect succeeded — hand off to the session loop, which
|
// First connect succeeded — hand off to the session loop, which
|
||||||
// owns reconnect.
|
// owns reconnect.
|
||||||
await runSession(cfg, serverPk, password, ready);
|
await runSession(cfg, serverPk, password, ready);
|
||||||
return;
|
return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (overridePassword !== null) {
|
||||||
|
// User chose the unattended-password path while waiting for
|
||||||
|
// approval. Restart the connect with that password.
|
||||||
|
password = overridePassword;
|
||||||
|
overridePassword = null;
|
||||||
|
pwErr = undefined;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (e instanceof ConnectionDeclinedError) {
|
if (e instanceof ConnectionDeclinedError) {
|
||||||
await renderDeclined(cfg, e.message);
|
await renderDeclined(cfg, e.message);
|
||||||
// Reset to empty-password attempt for the retry — the host's
|
// Reset to empty-password attempt for the retry — the host's
|
||||||
@@ -227,7 +272,7 @@ async function connectOnce(
|
|||||||
serverPk: Uint8Array,
|
serverPk: Uint8Array,
|
||||||
password: string,
|
password: string,
|
||||||
statusUI: boolean,
|
statusUI: boolean,
|
||||||
onAwaitingApproval?: () => void,
|
onAwaitingApproval?: (abort: () => void) => void,
|
||||||
): Promise<ConnectResult> {
|
): Promise<ConnectResult> {
|
||||||
const setStatus = (html: string): void => { if (statusUI) status(html); };
|
const setStatus = (html: string): void => { if (statusUI) status(html); };
|
||||||
|
|
||||||
|
|||||||
@@ -60,13 +60,18 @@ export interface SessionOptions {
|
|||||||
/** Random session id (uint64). Number value (≤ 53 bits) — opaque to peer. */
|
/** Random session id (uint64). Number value (≤ 53 bits) — opaque to peer. */
|
||||||
sessionId: number;
|
sessionId: number;
|
||||||
/**
|
/**
|
||||||
* Fired once if the host responds to LoginRequest with the interim
|
* Fired once when we enter the "waiting for the host's user to click
|
||||||
* "No Password Access" error — meaning it's now showing an approval
|
* Accept" state — either because the host emitted the interim
|
||||||
* popup to the user on its end and we should keep waiting for the
|
* `LOGIN_MSG_NO_PASSWORD_ACCESS` error, or because we sent an empty-
|
||||||
* real LoginResponse. Use this to swap the UI to "waiting for
|
* password LoginRequest (the host's `try_start_cm` branch is silent).
|
||||||
* approval" without aborting the connection.
|
*
|
||||||
|
* The renderer is given an `abort` callback that closes the relay,
|
||||||
|
* which causes the in-flight `recv()` to reject and bubbles out of
|
||||||
|
* `Session.open` as a generic error. Use it to wire UI affordances
|
||||||
|
* like "cancel" or "switch to password auth" without leaving a half-
|
||||||
|
* connected websocket dangling.
|
||||||
*/
|
*/
|
||||||
onAwaitingApproval?: () => void;
|
onAwaitingApproval?: (abort: () => void) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -166,11 +171,22 @@ export class Session {
|
|||||||
// UTF-8 bytes the way the Rust side reads them.
|
// UTF-8 bytes the way the Rust side reads them.
|
||||||
// pwd_hash = SHA256(password_text || salt_utf8_bytes)
|
// pwd_hash = SHA256(password_text || salt_utf8_bytes)
|
||||||
// password_response = SHA256(pwd_hash || challenge_utf8_bytes)
|
// password_response = SHA256(pwd_hash || challenge_utf8_bytes)
|
||||||
|
//
|
||||||
|
// Special case the empty password: the desktop client sends LITERAL
|
||||||
|
// empty bytes for `lr.password` when the user hasn't typed a
|
||||||
|
// password yet (client.rs:3472-3475 — "login without password, the
|
||||||
|
// remote side can click accept"). The host's connection.rs:2488
|
||||||
|
// branches on `lr.password.is_empty()` to start the cm_popup
|
||||||
|
// approval flow; if we send a SHA-256-derived 32-byte response
|
||||||
|
// instead, the host treats it as a wrong-password attempt and
|
||||||
|
// returns LOGIN_MSG_PASSWORD_WRONG, which collapses the
|
||||||
|
// attended-no-password approval path.
|
||||||
const enc = new TextEncoder();
|
const enc = new TextEncoder();
|
||||||
const saltBytes = enc.encode(salt);
|
const saltBytes = enc.encode(salt);
|
||||||
const challengeBytes = enc.encode(challenge);
|
const challengeBytes = enc.encode(challenge);
|
||||||
const pwdHash = sha256(concat(opts.password, saltBytes));
|
const passwordResp = opts.password.length === 0
|
||||||
const passwordResp = sha256(concat(pwdHash, challengeBytes));
|
? new Uint8Array(0)
|
||||||
|
: sha256(concat(sha256(concat(opts.password, saltBytes)), challengeBytes));
|
||||||
|
|
||||||
// -------- 8. Send Message{login_request} --------
|
// -------- 8. Send Message{login_request} --------
|
||||||
const loginReq = hbb.Message.create({
|
const loginReq = hbb.Message.create({
|
||||||
@@ -219,6 +235,21 @@ export class Session {
|
|||||||
// replay them into its main receive dispatch.
|
// replay them into its main receive dispatch.
|
||||||
const preloginExtras: hbb.Message[] = [];
|
const preloginExtras: hbb.Message[] = [];
|
||||||
let approvalNotified = false;
|
let approvalNotified = false;
|
||||||
|
// Empty-password path: when the host has a permanent password set
|
||||||
|
// AND a user is logged in, connection.rs:2488 just calls
|
||||||
|
// try_start_cm and stays silent (no LOGIN_MSG_NO_PASSWORD_ACCESS).
|
||||||
|
// The desktop client renders "Please wait for the remote side…" on
|
||||||
|
// its own as soon as it sends LoginRequest in that case; we do the
|
||||||
|
// same here so the UI doesn't sit on the "secure handshake + login"
|
||||||
|
// step indefinitely. The notification is idempotent — if the host
|
||||||
|
// *does* later send LOGIN_MSG_NO_PASSWORD_ACCESS, the existing
|
||||||
|
// branch below no-ops on `approvalNotified`.
|
||||||
|
if (passwordResp.length === 0) {
|
||||||
|
approvalNotified = true;
|
||||||
|
try {
|
||||||
|
opts.onAwaitingApproval?.(() => session.close());
|
||||||
|
} catch { /* swallow */ }
|
||||||
|
}
|
||||||
while (true) {
|
while (true) {
|
||||||
const respMsg = await session.recv();
|
const respMsg = await session.recv();
|
||||||
if (respMsg.test_delay) {
|
if (respMsg.test_delay) {
|
||||||
@@ -246,7 +277,9 @@ export class Session {
|
|||||||
if (lr.error === "No Password Access") {
|
if (lr.error === "No Password Access") {
|
||||||
if (!approvalNotified) {
|
if (!approvalNotified) {
|
||||||
approvalNotified = true;
|
approvalNotified = true;
|
||||||
try { opts.onAwaitingApproval?.(); } catch { /* swallow */ }
|
try {
|
||||||
|
opts.onAwaitingApproval?.(() => session.close());
|
||||||
|
} catch { /* swallow */ }
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,14 @@ html, body {
|
|||||||
}
|
}
|
||||||
.pw-form button:hover { background: #0369a1; }
|
.pw-form button:hover { background: #0369a1; }
|
||||||
|
|
||||||
|
/* Separator between "waiting for approval / cancel" and the unattended-
|
||||||
|
* password override on the awaiting-approval screen. */
|
||||||
|
.pw-divider {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
margin: 20px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.error-inline {
|
.error-inline {
|
||||||
background: rgba(220, 38, 38, 0.15);
|
background: rgba(220, 38, 38, 0.15);
|
||||||
border: 1px solid rgba(220, 38, 38, 0.4);
|
border: 1px solid rgba(220, 38, 38, 0.4);
|
||||||
|
|||||||
Reference in New Issue
Block a user