Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6807fe2bc0 |
Generated
+1
-1
@@ -3197,7 +3197,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hello-agent"
|
||||
version = "0.1.3"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"env_logger 0.10.2",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hello-agent"
|
||||
version = "0.1.3"
|
||||
version = "0.1.4"
|
||||
edition = "2021"
|
||||
rust-version = "1.75"
|
||||
description = "Headless RustDesk-protocol-compatible support agent for Windows"
|
||||
|
||||
@@ -60,6 +60,11 @@ hello-agent.exe --server # user session, SYSTEM token
|
||||
│ └─ stamps `agent_name` / `agent_version` / `inventory`
|
||||
│ into each /api/sysinfo payload (re-uploads when the
|
||||
│ inventory collector below transitions empty → ready)
|
||||
│ └─ signs every request with the device's Ed25519 sk
|
||||
│ (same key rendezvous registers via RegisterPk).
|
||||
│ The server's first valid sig flips that peer to
|
||||
│ `managed=1` and unsigned posts get 401 from then on.
|
||||
│ Spec: rustdesk-server/docs/AGENT-API-AUTH.md
|
||||
├── inventory::collect_inventory (background thread)
|
||||
│ └─ PowerShell + WMI + wlanapi + ipify → `INVENTORY` global
|
||||
│ consumed by hbbs_http::sync above; one-shot, no retry
|
||||
@@ -115,6 +120,12 @@ inventory — keep it in sync when adding new patches.
|
||||
`--cm` process can plug a `MessageBoxW`-based `InvokeUiCM` into
|
||||
upstream's connection-manager IPC loop and inherit file-transfer,
|
||||
chat, and clipboard handling rather than re-implementing them.
|
||||
* `mod hbbs_http` → `pub mod hbbs_http` so hello-agent's
|
||||
`unattended_password::try_report` can reach
|
||||
`librustdesk::hbbs_http::sign::build_signed_headers` — without
|
||||
this the in-crate POST can't sign and the build fails with
|
||||
`E0603: module 'hbbs_http' is private`. Tightly coupled to the
|
||||
**Signed agent API** divergence below.
|
||||
2. **Build shape** — [`vendor/rustdesk/Cargo.toml`](vendor/rustdesk/Cargo.toml):
|
||||
`[lib] crate-type` reduced from `["cdylib", "staticlib", "rlib"]` to
|
||||
`["rlib"]`. We statically link the rlib into hello-agent.exe; the
|
||||
@@ -148,6 +159,44 @@ inventory — keep it in sync when adding new patches.
|
||||
[`src/main.rs`](vendor/rustdesk/src/main.rs) (`.author(...)`).
|
||||
Cosmetic, but they show through in the Windows EXE metadata and
|
||||
in-app error dialogs.
|
||||
6. **Signed agent API** — every `POST /api/heartbeat`,
|
||||
`POST /api/sysinfo`, and `POST /api/unattended-password` carries
|
||||
two extra headers (`X-RD-Device-Id`,
|
||||
`X-RD-Signature: v1.<ts>.<base64-ed25519-sig>`) so the server can
|
||||
bind the request to the device's existing rendezvous keypair
|
||||
instead of trusting the `id` + `uuid` body fields. Without this
|
||||
patch, anyone who knows a peer's id and uuid can inject inventory,
|
||||
heartbeats, and unattended-access passwords for it. Three patch
|
||||
sites in the vendor tree (plus one in the hello-agent crate):
|
||||
* New file
|
||||
[`src/hbbs_http/sign.rs`](vendor/rustdesk/src/hbbs_http/sign.rs) —
|
||||
the signer (`build_signed_headers`, `path_from_url`). Reads
|
||||
`Config::get_key_pair()` and `Config::get_id()`; uses the
|
||||
re-exported `hbb_common::sodiumoxide`.
|
||||
* [`src/hbbs_http.rs`](vendor/rustdesk/src/hbbs_http.rs) — adds
|
||||
`pub mod sign;` next to the existing module declarations.
|
||||
* [`src/common.rs`](vendor/rustdesk/src/common.rs) — the
|
||||
`post_request_` and `parse_simple_header` header-string parsers
|
||||
now accept a `\n`-separated list of `Name: Value` lines so we
|
||||
can pass both signing headers in one call. Old single-pair
|
||||
callers parse identically — there's no newline to split on.
|
||||
* [`src/hbbs_http/sync.rs`](vendor/rustdesk/src/hbbs_http/sync.rs)
|
||||
call sites (the sysinfo POST around the sysinfo-version
|
||||
comparison block, and the heartbeat POST a few dozen lines
|
||||
later) — both build a signed-headers string via
|
||||
`crate::hbbs_http::sign::build_signed_headers("POST",
|
||||
&path_from_url(&url), body.as_bytes()).unwrap_or_default()`
|
||||
and pass it to `post_request` instead of `""`.
|
||||
|
||||
And in the hello-agent crate proper (not the vendor tree, no
|
||||
re-sync concern):
|
||||
* [`src/unattended_password.rs`](src/unattended_password.rs) —
|
||||
`try_report` also signs its `POST /api/unattended-password`
|
||||
via `librustdesk::hbbs_http::sign::build_signed_headers`.
|
||||
|
||||
Matching server side: see rustdesk-server's
|
||||
[`docs/AGENT-API-AUTH.md`](https://github.com/cstudio-ch/rustdesk-server/blob/pro-features/docs/AGENT-API-AUTH.md)
|
||||
for the wire format and verification flow.
|
||||
|
||||
## Build
|
||||
|
||||
|
||||
@@ -111,7 +111,18 @@ async fn try_report(password: &str) -> Result<()> {
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let resp = librustdesk::common::post_request(url, body, "")
|
||||
// Same per-peer signature gate as heartbeat / sysinfo. Once this peer's
|
||||
// `managed` flag has flipped to 1 server-side, unsigned posts here
|
||||
// would be rejected — and we want unattended-password to keep landing
|
||||
// through the same TOFU lifecycle as the other endpoints.
|
||||
let headers = librustdesk::hbbs_http::sign::build_signed_headers(
|
||||
"POST",
|
||||
"/api/unattended-password",
|
||||
body.as_bytes(),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
|
||||
let resp = librustdesk::common::post_request(url, body, &headers)
|
||||
.await
|
||||
.map_err(|e| anyhow!("post: {e}"))?;
|
||||
let trimmed = resp.trim();
|
||||
|
||||
Vendored
+29
-12
@@ -1331,17 +1331,26 @@ async fn tcp_proxy_request(
|
||||
fn parse_simple_header(header: &str) -> Vec<HeaderEntry> {
|
||||
let mut entries = Vec::new();
|
||||
let mut has_content_type = false;
|
||||
// Accept a `\n`-separated list of `Name: Value` pairs. Single-pair input
|
||||
// (the historical shape) still parses correctly because there's no
|
||||
// newline to split on.
|
||||
if !header.is_empty() {
|
||||
let tmp: Vec<&str> = header.splitn(2, ": ").collect();
|
||||
if tmp.len() == 2 {
|
||||
if tmp[0].eq_ignore_ascii_case("Content-Type") {
|
||||
has_content_type = true;
|
||||
for line in header.split('\n') {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let tmp: Vec<&str> = line.splitn(2, ": ").collect();
|
||||
if tmp.len() == 2 {
|
||||
if tmp[0].eq_ignore_ascii_case("Content-Type") {
|
||||
has_content_type = true;
|
||||
}
|
||||
entries.push(HeaderEntry {
|
||||
name: tmp[0].into(),
|
||||
value: tmp[1].into(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
entries.push(HeaderEntry {
|
||||
name: tmp[0].into(),
|
||||
value: tmp[1].into(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
if !has_content_type {
|
||||
@@ -1499,10 +1508,18 @@ async fn post_request_(
|
||||
danger_accept_invalid_cert.unwrap_or(false),
|
||||
)
|
||||
.post(url);
|
||||
// `header` is a `\n`-separated list of `Name: Value` pairs. Single-pair
|
||||
// callers (the original shape) work unchanged. The signed-API path uses
|
||||
// this to pass both `X-RD-Device-Id` and `X-RD-Signature` at once.
|
||||
if !header.is_empty() {
|
||||
let tmp: Vec<&str> = header.split(": ").collect();
|
||||
if tmp.len() == 2 {
|
||||
req = req.header(tmp[0], tmp[1]);
|
||||
for line in header.split('\n') {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some((name, value)) = line.split_once(": ") {
|
||||
req = req.header(name, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
req = req.header("Content-Type", "application/json");
|
||||
|
||||
Vendored
+1
@@ -7,6 +7,7 @@ pub mod account;
|
||||
pub mod downloader;
|
||||
mod http_client;
|
||||
pub mod record_upload;
|
||||
pub mod sign;
|
||||
pub mod sync;
|
||||
pub use http_client::{
|
||||
create_http_client_async, create_http_client_async_with_url, create_http_client_with_url,
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
//! Sign agent → server HTTP requests with the device's existing Ed25519
|
||||
//! keypair (the same one rendezvous uses for `RegisterPk`). Producing the
|
||||
//! header pair below for any signed call:
|
||||
//!
|
||||
//! X-RD-Device-Id: <id>
|
||||
//! X-RD-Signature: v1.<unix_ts>.<base64(ed25519_sig)>
|
||||
//!
|
||||
//! Server verifier: `/Users/sn0/Desktop/rustdesk-server/src/api/device_auth.rs`.
|
||||
//!
|
||||
//! Signed message format (must match the server byte-for-byte):
|
||||
//! "rd-api-v1\n" || METHOD || "\n" || PATH || "\n" || TS || "\n" || sha256(body)
|
||||
|
||||
use hbb_common::config::Config;
|
||||
use hbb_common::sodiumoxide::crypto::{hash::sha256, sign};
|
||||
|
||||
/// Returns the two HTTP header lines joined by `\n`, ready to hand to
|
||||
/// `post_request`'s extended `header` parser. Returns `None` if the local
|
||||
/// keypair hasn't been generated yet (very early boot, before rendezvous) —
|
||||
/// the caller should fall back to an unsigned request in that case; the
|
||||
/// server's TOFU promote will still flip `managed=1` on the next signed
|
||||
/// request and any unsigned attempts after that flip will be rejected.
|
||||
pub fn build_signed_headers(method: &str, path: &str, body: &[u8]) -> Option<String> {
|
||||
let (sk_bytes, _pk_bytes) = Config::get_key_pair();
|
||||
if sk_bytes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let sk = sign::SecretKey::from_slice(&sk_bytes)?;
|
||||
let id = Config::get_id();
|
||||
if id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let ts = chrono::Utc::now().timestamp();
|
||||
|
||||
let body_sha = sha256::hash(body);
|
||||
let ts_s = ts.to_string();
|
||||
let mut msg = Vec::with_capacity(64 + method.len() + path.len());
|
||||
msg.extend_from_slice(b"rd-api-v1\n");
|
||||
msg.extend_from_slice(method.as_bytes());
|
||||
msg.push(b'\n');
|
||||
msg.extend_from_slice(path.as_bytes());
|
||||
msg.push(b'\n');
|
||||
msg.extend_from_slice(ts_s.as_bytes());
|
||||
msg.push(b'\n');
|
||||
msg.extend_from_slice(body_sha.as_ref());
|
||||
|
||||
let sig = sign::sign_detached(&msg, &sk);
|
||||
let sig_b64 = crate::encode64(sig.as_ref());
|
||||
|
||||
Some(format!(
|
||||
"X-RD-Device-Id: {}\nX-RD-Signature: v1.{}.{}",
|
||||
id, ts, sig_b64
|
||||
))
|
||||
}
|
||||
|
||||
/// Extract the `/path` portion of a full URL. Used to derive the signed
|
||||
/// path from sync.rs's `url` variable, which is always something like
|
||||
/// `https://server.example.com:21114/api/heartbeat`. Falls back to "/" if
|
||||
/// the URL doesn't parse — server-side verification will then fail, which
|
||||
/// is the right outcome (a malformed agent URL is a misconfiguration the
|
||||
/// operator should see).
|
||||
pub fn path_from_url(url: &str) -> String {
|
||||
// Manual parse to avoid pulling in the `url` crate for one call. The
|
||||
// structure is always scheme://host[:port]/path[?query]. Strip scheme,
|
||||
// then take from the first '/' onward, then drop any '?query'.
|
||||
let no_scheme = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url);
|
||||
let path_and_q = no_scheme.find('/').map(|i| &no_scheme[i..]).unwrap_or("/");
|
||||
let path = path_and_q
|
||||
.split_once('?')
|
||||
.map(|(p, _)| p)
|
||||
.unwrap_or(path_and_q);
|
||||
path.to_string()
|
||||
}
|
||||
+18
-2
@@ -258,7 +258,15 @@ async fn start_hbbs_sync_async() {
|
||||
}
|
||||
}
|
||||
}
|
||||
match crate::post_request(url.replace("heartbeat", "sysinfo"), v, "").await {
|
||||
let sysinfo_url = url.replace("heartbeat", "sysinfo");
|
||||
let sysinfo_path = crate::hbbs_http::sign::path_from_url(&sysinfo_url);
|
||||
let sysinfo_headers = crate::hbbs_http::sign::build_signed_headers(
|
||||
"POST",
|
||||
&sysinfo_path,
|
||||
v.as_bytes(),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
match crate::post_request(sysinfo_url, v, &sysinfo_headers).await {
|
||||
Ok(x) => {
|
||||
if x == "SYSINFO_UPDATED" {
|
||||
info_uploaded = InfoUploaded::uploaded(url.clone(), id.clone(), sys_username, had_inventory);
|
||||
@@ -292,7 +300,15 @@ async fn start_hbbs_sync_async() {
|
||||
}
|
||||
let modified_at = LocalConfig::get_option("strategy_timestamp").parse::<i64>().unwrap_or(0);
|
||||
v["modified_at"] = json!(modified_at);
|
||||
if let Ok(s) = crate::post_request(url.clone(), v.to_string(), "").await {
|
||||
let hb_body = v.to_string();
|
||||
let hb_path = crate::hbbs_http::sign::path_from_url(&url);
|
||||
let hb_headers = crate::hbbs_http::sign::build_signed_headers(
|
||||
"POST",
|
||||
&hb_path,
|
||||
hb_body.as_bytes(),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
if let Ok(s) = crate::post_request(url.clone(), hb_body, &hb_headers).await {
|
||||
if let Ok(mut rsp) = serde_json::from_str::<HashMap::<&str, Value>>(&s) {
|
||||
if rsp.remove("sysinfo").is_some() {
|
||||
info_uploaded.uploaded = false;
|
||||
|
||||
Vendored
+1
-1
@@ -72,7 +72,7 @@ pub mod ui_cm_interface;
|
||||
mod ui_interface;
|
||||
mod ui_session_interface;
|
||||
|
||||
mod hbbs_http;
|
||||
pub mod hbbs_http;
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
pub mod clipboard_file;
|
||||
|
||||
Reference in New Issue
Block a user