f8ead215d8
build-windows / build-hello-agent-x64 (push) Successful in 5m41s
A single-binary, Flutter-free remote-support agent that speaks the stock
RustDesk wire protocol. Designed for one-line MDM deployment against a
self-hosted rustdesk-server: a supporter using the unmodified rustdesk.exe
client connects, the controlled-side user gets a native Win32 approval
prompt, click Yes / No.
CLI surface
hello-agent.exe --install # register + start service
hello-agent.exe --uninstall # stop, delete, clean up
hello-agent.exe --config <BLOB> # admin-UI deploy string
hello-agent.exe --install --config <BLOB> # MDM one-liner
--config accepts both forms emitted by the rustdesk-server admin UI: the
reversed-base64 deploy string and the host=,key=,api=,relay= filename
form. Decoded via the upstream custom_server module, persisted via
hbb_common::config::Config::set_option.
Architecture
--service runs as a Session 0 LocalSystem service. It polls
WTSGetActiveConsoleSessionId and (re)spawns hello-agent.exe --server
into the active console session via librustdesk::platform::run_as_user,
handling the Session 0 → user-session token impersonation.
--server is the worker. It boots three concurrent components:
1. cm_popup: an IPC listener on the rustdesk `_cm` named pipe
2. librustdesk::start_server(true, false): the upstream protocol
stack — rendezvous mediator, NAT punch, IPC server, screen
capture, login validation, hbbs_http heartbeat / sysinfo sync
3. (implicit) ApproveMode::Click is pinned in config, so every
incoming connection routes through cm_popup
The popup mechanism reuses an existing upstream contract without any
patches to the protocol code: when a peer connects with no password,
Connection::start in the upstream code calls try_start_cm_ipc, which
ipc::connect-s the `_cm` pipe before falling back to spawning a Flutter
CM child. Since cm_popup is up first, step 1 succeeds; we read the
Data::Login{authorized:false} frame, show MessageBoxTimeoutW (Yes/No,
60s, top-most, system-modal), and reply Data::Authorize or Data::Close.
Source tree
src/main.rs CLI dispatcher + run_server() composition
src/cli.rs hand-rolled argv parser + unit tests
src/service.rs windows-service install/uninstall/dispatcher
src/config_import.rs --config blob decoding + persistence
src/cm_popup.rs _cm IPC listener + Win32 approval dialog
Vendoring
The upstream RustDesk crate is vendored under vendor/rustdesk/ — full
workspace including libs/{hbb_common, scrap, enigo, clipboard,
virtual_display, remote_printer}. This makes the build self-contained
(no submodules, no sibling-repo checkout in CI) and gives us freedom to
fork in a different direction later. Excluded from the vendor: .git,
target/, flutter/, appimage/, flatpak/, fastlane/, docs/, examples/,
ci/, build.py, Dockerfile, upstream README/CLAUDE/AGENTS/GEMINI.
One local divergence vs. upstream: vendor/rustdesk/src/lib.rs flips
`mod custom_server` → `pub mod custom_server` so config_import.rs can
call get_custom_server_from_string without going through the
ui_interface shim. Documented in README.md → "Re-syncing the vendored
copy".
CI
.gitea/workflows/build-windows.yml builds on a self-hosted Windows
runner with Rust 1.75, LLVM 15.0.6 (libclang for bindgen via libvpx-sys),
and a vcpkg cache. The vendored vcpkg.json drives x64-windows-static
deps. The workflow stages the resulting hello-agent.exe into
SignOutput\, reports authenticode signing status (warns on unsigned),
and uploads as artifact. ~15 min full build, faster on incremental.
Out of scope for this commit: Linux/macOS builds, code signing, MSI
packaging, coexistence with stock rustdesk on the same box (currently
shares the RustDesk APP_NAME and config dir).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
428 lines
15 KiB
Rust
428 lines
15 KiB
Rust
use clipboard::ClipboardFile;
|
|
use hbb_common::message_proto::*;
|
|
|
|
pub fn clip_2_msg(clip: ClipboardFile) -> Message {
|
|
match clip {
|
|
ClipboardFile::NotifyCallback {
|
|
r#type,
|
|
title,
|
|
text,
|
|
} => Message {
|
|
union: Some(message::Union::MessageBox(MessageBox {
|
|
msgtype: r#type,
|
|
title,
|
|
text,
|
|
link: "".to_string(),
|
|
..Default::default()
|
|
})),
|
|
..Default::default()
|
|
},
|
|
ClipboardFile::MonitorReady => Message {
|
|
union: Some(message::Union::Cliprdr(Cliprdr {
|
|
union: Some(cliprdr::Union::Ready(CliprdrMonitorReady {
|
|
..Default::default()
|
|
})),
|
|
..Default::default()
|
|
})),
|
|
..Default::default()
|
|
},
|
|
ClipboardFile::FormatList { format_list } => {
|
|
let mut formats: Vec<CliprdrFormat> = Vec::new();
|
|
for v in format_list.iter() {
|
|
formats.push(CliprdrFormat {
|
|
id: v.0,
|
|
format: v.1.clone(),
|
|
..Default::default()
|
|
});
|
|
}
|
|
Message {
|
|
union: Some(message::Union::Cliprdr(Cliprdr {
|
|
union: Some(cliprdr::Union::FormatList(CliprdrServerFormatList {
|
|
formats,
|
|
..Default::default()
|
|
})),
|
|
..Default::default()
|
|
})),
|
|
..Default::default()
|
|
}
|
|
}
|
|
ClipboardFile::FormatListResponse { msg_flags } => Message {
|
|
union: Some(message::Union::Cliprdr(Cliprdr {
|
|
union: Some(cliprdr::Union::FormatListResponse(
|
|
CliprdrServerFormatListResponse {
|
|
msg_flags,
|
|
..Default::default()
|
|
},
|
|
)),
|
|
..Default::default()
|
|
})),
|
|
..Default::default()
|
|
},
|
|
ClipboardFile::FormatDataRequest {
|
|
requested_format_id,
|
|
} => Message {
|
|
union: Some(message::Union::Cliprdr(Cliprdr {
|
|
union: Some(cliprdr::Union::FormatDataRequest(
|
|
CliprdrServerFormatDataRequest {
|
|
requested_format_id,
|
|
..Default::default()
|
|
},
|
|
)),
|
|
..Default::default()
|
|
})),
|
|
..Default::default()
|
|
},
|
|
ClipboardFile::FormatDataResponse {
|
|
msg_flags,
|
|
format_data,
|
|
} => Message {
|
|
union: Some(message::Union::Cliprdr(Cliprdr {
|
|
union: Some(cliprdr::Union::FormatDataResponse(
|
|
CliprdrServerFormatDataResponse {
|
|
msg_flags,
|
|
format_data: format_data.into(),
|
|
..Default::default()
|
|
},
|
|
)),
|
|
..Default::default()
|
|
})),
|
|
..Default::default()
|
|
},
|
|
ClipboardFile::FileContentsRequest {
|
|
stream_id,
|
|
list_index,
|
|
dw_flags,
|
|
n_position_low,
|
|
n_position_high,
|
|
cb_requested,
|
|
have_clip_data_id,
|
|
clip_data_id,
|
|
} => Message {
|
|
union: Some(message::Union::Cliprdr(Cliprdr {
|
|
union: Some(cliprdr::Union::FileContentsRequest(
|
|
CliprdrFileContentsRequest {
|
|
stream_id,
|
|
list_index,
|
|
dw_flags,
|
|
n_position_low,
|
|
n_position_high,
|
|
cb_requested,
|
|
have_clip_data_id,
|
|
clip_data_id,
|
|
..Default::default()
|
|
},
|
|
)),
|
|
..Default::default()
|
|
})),
|
|
..Default::default()
|
|
},
|
|
ClipboardFile::FileContentsResponse {
|
|
msg_flags,
|
|
stream_id,
|
|
requested_data,
|
|
} => Message {
|
|
union: Some(message::Union::Cliprdr(Cliprdr {
|
|
union: Some(cliprdr::Union::FileContentsResponse(
|
|
CliprdrFileContentsResponse {
|
|
msg_flags,
|
|
stream_id,
|
|
requested_data: requested_data.into(),
|
|
..Default::default()
|
|
},
|
|
)),
|
|
..Default::default()
|
|
})),
|
|
..Default::default()
|
|
},
|
|
ClipboardFile::TryEmpty => Message {
|
|
union: Some(message::Union::Cliprdr(Cliprdr {
|
|
union: Some(cliprdr::Union::TryEmpty(CliprdrTryEmpty {
|
|
..Default::default()
|
|
})),
|
|
..Default::default()
|
|
})),
|
|
..Default::default()
|
|
},
|
|
ClipboardFile::Files { files } => {
|
|
let files = files
|
|
.iter()
|
|
.filter_map(|(f, s)| {
|
|
if *s == 0 {
|
|
if let Ok(meta) = std::fs::metadata(f) {
|
|
Some(CliprdrFile {
|
|
name: f.to_owned(),
|
|
size: meta.len(),
|
|
..Default::default()
|
|
})
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
Some(CliprdrFile {
|
|
name: f.to_owned(),
|
|
size: *s,
|
|
..Default::default()
|
|
})
|
|
}
|
|
})
|
|
.collect::<Vec<_>>();
|
|
Message {
|
|
union: Some(message::Union::Cliprdr(Cliprdr {
|
|
union: Some(cliprdr::Union::Files(CliprdrFiles {
|
|
files,
|
|
..Default::default()
|
|
})),
|
|
..Default::default()
|
|
})),
|
|
..Default::default()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn msg_2_clip(msg: Cliprdr) -> Option<ClipboardFile> {
|
|
match msg.union {
|
|
Some(cliprdr::Union::Ready(_)) => Some(ClipboardFile::MonitorReady),
|
|
Some(cliprdr::Union::FormatList(data)) => {
|
|
let mut format_list: Vec<(i32, String)> = Vec::new();
|
|
for v in data.formats.iter() {
|
|
format_list.push((v.id, v.format.clone()));
|
|
}
|
|
Some(ClipboardFile::FormatList { format_list })
|
|
}
|
|
Some(cliprdr::Union::FormatListResponse(data)) => Some(ClipboardFile::FormatListResponse {
|
|
msg_flags: data.msg_flags,
|
|
}),
|
|
Some(cliprdr::Union::FormatDataRequest(data)) => Some(ClipboardFile::FormatDataRequest {
|
|
requested_format_id: data.requested_format_id,
|
|
}),
|
|
Some(cliprdr::Union::FormatDataResponse(data)) => Some(ClipboardFile::FormatDataResponse {
|
|
msg_flags: data.msg_flags,
|
|
format_data: data.format_data.into(),
|
|
}),
|
|
Some(cliprdr::Union::FileContentsRequest(data)) => {
|
|
Some(ClipboardFile::FileContentsRequest {
|
|
stream_id: data.stream_id,
|
|
list_index: data.list_index,
|
|
dw_flags: data.dw_flags,
|
|
n_position_low: data.n_position_low,
|
|
n_position_high: data.n_position_high,
|
|
cb_requested: data.cb_requested,
|
|
have_clip_data_id: data.have_clip_data_id,
|
|
clip_data_id: data.clip_data_id,
|
|
})
|
|
}
|
|
Some(cliprdr::Union::FileContentsResponse(data)) => {
|
|
Some(ClipboardFile::FileContentsResponse {
|
|
msg_flags: data.msg_flags,
|
|
stream_id: data.stream_id,
|
|
requested_data: data.requested_data.into(),
|
|
})
|
|
}
|
|
Some(cliprdr::Union::TryEmpty(_)) => Some(ClipboardFile::TryEmpty),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "unix-file-copy-paste")]
|
|
pub mod unix_file_clip {
|
|
use super::*;
|
|
#[cfg(target_os = "linux")]
|
|
use crate::clipboard::update_clipboard_files;
|
|
use crate::clipboard::{try_empty_clipboard_files, ClipboardSide};
|
|
#[cfg(target_os = "linux")]
|
|
use clipboard::platform::unix::fuse;
|
|
use clipboard::platform::unix::{
|
|
get_local_format, serv_files, FILECONTENTS_FORMAT_ID, FILECONTENTS_FORMAT_NAME,
|
|
FILEDESCRIPTORW_FORMAT_NAME, FILEDESCRIPTOR_FORMAT_ID,
|
|
};
|
|
use hbb_common::log;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
lazy_static::lazy_static! {
|
|
static ref CLIPBOARD_CTX: Arc<Mutex<Option<crate::clipboard::ClipboardContext>>> = Arc::new(Mutex::new(None));
|
|
}
|
|
|
|
pub fn get_format_list() -> ClipboardFile {
|
|
let fd_format_name = get_local_format(FILEDESCRIPTOR_FORMAT_ID)
|
|
.unwrap_or(FILEDESCRIPTORW_FORMAT_NAME.to_string());
|
|
let fc_format_name = get_local_format(FILECONTENTS_FORMAT_ID)
|
|
.unwrap_or(FILECONTENTS_FORMAT_NAME.to_string());
|
|
ClipboardFile::FormatList {
|
|
format_list: vec![
|
|
(FILEDESCRIPTOR_FORMAT_ID, fd_format_name),
|
|
(FILECONTENTS_FORMAT_ID, fc_format_name),
|
|
],
|
|
}
|
|
}
|
|
|
|
#[inline]
|
|
fn msg_resp_format_data_failure() -> Message {
|
|
clip_2_msg(ClipboardFile::FormatDataResponse {
|
|
msg_flags: 0x2,
|
|
format_data: vec![],
|
|
})
|
|
}
|
|
|
|
#[inline]
|
|
fn resp_file_contents_fail(stream_id: i32) -> Message {
|
|
clip_2_msg(ClipboardFile::FileContentsResponse {
|
|
msg_flags: 0x2,
|
|
stream_id,
|
|
requested_data: vec![],
|
|
})
|
|
}
|
|
|
|
pub fn serve_clip_messages(
|
|
side: ClipboardSide,
|
|
clip: ClipboardFile,
|
|
conn_id: i32,
|
|
) -> Vec<Message> {
|
|
log::debug!("got clipfile from client peer");
|
|
match clip {
|
|
ClipboardFile::MonitorReady => {
|
|
log::debug!("client is ready for clipboard");
|
|
}
|
|
ClipboardFile::FormatList { format_list } => {
|
|
if !format_list
|
|
.iter()
|
|
.find(|(_, name)| name == FILECONTENTS_FORMAT_NAME)
|
|
.map(|(id, _)| *id)
|
|
.is_some()
|
|
{
|
|
log::error!("no file contents format found");
|
|
return vec![];
|
|
};
|
|
let Some(file_descriptor_id) = format_list
|
|
.iter()
|
|
.find(|(_, name)| name == FILEDESCRIPTORW_FORMAT_NAME)
|
|
.map(|(id, _)| *id)
|
|
else {
|
|
log::error!("no file descriptor format found");
|
|
return vec![];
|
|
};
|
|
// sync file system from peer
|
|
let data = ClipboardFile::FormatDataRequest {
|
|
requested_format_id: file_descriptor_id,
|
|
};
|
|
return vec![clip_2_msg(data)];
|
|
}
|
|
ClipboardFile::FormatListResponse {
|
|
msg_flags: _msg_flags,
|
|
} => {}
|
|
ClipboardFile::FormatDataRequest {
|
|
requested_format_id: _requested_format_id,
|
|
} => {
|
|
log::debug!("requested format id: {}", _requested_format_id);
|
|
let format_data = serv_files::get_file_list_pdu();
|
|
if !format_data.is_empty() {
|
|
return vec![clip_2_msg(ClipboardFile::FormatDataResponse {
|
|
msg_flags: 1,
|
|
format_data,
|
|
})];
|
|
}
|
|
// empty file list, send failure message
|
|
return vec![msg_resp_format_data_failure()];
|
|
}
|
|
#[cfg(target_os = "linux")]
|
|
ClipboardFile::FormatDataResponse {
|
|
msg_flags,
|
|
format_data,
|
|
} => {
|
|
log::debug!("format data response: msg_flags: {}", msg_flags);
|
|
|
|
if msg_flags != 0x1 {
|
|
// return failure message?
|
|
}
|
|
|
|
log::debug!("parsing file descriptors");
|
|
if fuse::init_fuse_context(true).is_ok() {
|
|
match fuse::format_data_response_to_urls(
|
|
side == ClipboardSide::Client,
|
|
format_data,
|
|
conn_id,
|
|
) {
|
|
Ok(files) => {
|
|
update_clipboard_files(files, side);
|
|
}
|
|
Err(e) => {
|
|
log::error!("failed to parse file descriptors: {:?}", e);
|
|
}
|
|
}
|
|
} else {
|
|
// send error message to server
|
|
}
|
|
}
|
|
ClipboardFile::FileContentsRequest {
|
|
stream_id,
|
|
list_index,
|
|
dw_flags,
|
|
n_position_low,
|
|
n_position_high,
|
|
cb_requested,
|
|
..
|
|
} => {
|
|
log::debug!("file contents request: stream_id: {}, list_index: {}, dw_flags: {}, n_position_low: {}, n_position_high: {}, cb_requested: {}", stream_id, list_index, dw_flags, n_position_low, n_position_high, cb_requested);
|
|
return serv_files::read_file_contents(
|
|
conn_id,
|
|
stream_id,
|
|
list_index,
|
|
dw_flags,
|
|
n_position_low,
|
|
n_position_high,
|
|
cb_requested,
|
|
)
|
|
.into_iter()
|
|
.map(|res| match res {
|
|
Ok(data) => clip_2_msg(data),
|
|
Err(e) => {
|
|
log::error!("failed to read file contents: {:?}", e);
|
|
resp_file_contents_fail(stream_id)
|
|
}
|
|
})
|
|
.collect::<_>();
|
|
}
|
|
#[cfg(target_os = "linux")]
|
|
ClipboardFile::FileContentsResponse {
|
|
msg_flags,
|
|
stream_id,
|
|
..
|
|
} => {
|
|
log::debug!(
|
|
"file contents response: msg_flags: {}, stream_id: {}",
|
|
msg_flags,
|
|
stream_id,
|
|
);
|
|
if fuse::init_fuse_context(true).is_ok() {
|
|
hbb_common::allow_err!(fuse::handle_file_content_response(
|
|
side == ClipboardSide::Client,
|
|
clip
|
|
));
|
|
} else {
|
|
// send error message to server
|
|
}
|
|
}
|
|
ClipboardFile::NotifyCallback {
|
|
r#type,
|
|
title,
|
|
text,
|
|
} => {
|
|
// unreachable, but still log it
|
|
log::debug!(
|
|
"notify callback: type: {}, title: {}, text: {}",
|
|
r#type,
|
|
title,
|
|
text
|
|
);
|
|
}
|
|
ClipboardFile::TryEmpty => {
|
|
try_empty_clipboard_files(side, conn_id);
|
|
}
|
|
_ => {
|
|
log::error!("unsupported clipboard file type");
|
|
}
|
|
}
|
|
vec![]
|
|
}
|
|
}
|