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>
109 lines
4.1 KiB
Python
Executable File
109 lines
4.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
import os
|
|
import optparse
|
|
from hashlib import md5
|
|
import brotli
|
|
import datetime
|
|
|
|
# 4GB maximum
|
|
length_count = 4
|
|
# encoding
|
|
encoding = 'utf-8'
|
|
|
|
# output: {path: (compressed_data, file_md5)}
|
|
|
|
|
|
def generate_md5_table(folder: str, level) -> dict:
|
|
res: dict = dict()
|
|
curdir = os.curdir
|
|
os.chdir(folder)
|
|
for root, _, files in os.walk('.'):
|
|
# remove ./
|
|
for f in files:
|
|
md5_generator = md5()
|
|
full_path = os.path.join(root, f)
|
|
print(f"Processing {full_path}...")
|
|
f = open(full_path, "rb")
|
|
content = f.read()
|
|
content_compressed = brotli.compress(
|
|
content, quality=level)
|
|
md5_generator.update(content)
|
|
md5_code = md5_generator.hexdigest().encode(encoding=encoding)
|
|
res[full_path] = (content_compressed, md5_code)
|
|
os.chdir(curdir)
|
|
return res
|
|
|
|
|
|
def write_package_metadata(md5_table: dict, output_folder: str, exe: str):
|
|
output_path = os.path.join(output_folder, "data.bin")
|
|
with open(output_path, "wb") as f:
|
|
f.write("rustdesk".encode(encoding=encoding))
|
|
for path in md5_table.keys():
|
|
(compressed_data, md5_code) = md5_table[path]
|
|
data_length = len(compressed_data)
|
|
path = path.encode(encoding=encoding)
|
|
# path length & path
|
|
f.write((len(path)).to_bytes(length=length_count, byteorder='big'))
|
|
f.write(path)
|
|
# data length & compressed data
|
|
f.write(data_length.to_bytes(
|
|
length=length_count, byteorder='big'))
|
|
f.write(compressed_data)
|
|
# md5 code
|
|
f.write(md5_code)
|
|
# end
|
|
f.write("rustdesk".encode(encoding=encoding))
|
|
# executable
|
|
f.write(exe.encode(encoding='utf-8'))
|
|
print(f"Metadata has been written to {output_path}")
|
|
|
|
def write_app_metadata(output_folder: str):
|
|
output_path = os.path.join(output_folder, "app_metadata.toml")
|
|
with open(output_path, "w") as f:
|
|
f.write(f"timestamp = {int(datetime.datetime.now().timestamp() * 1000)}\n")
|
|
print(f"App metadata has been written to {output_path}")
|
|
|
|
def build_portable(output_folder: str, target: str):
|
|
os.chdir(output_folder)
|
|
if target:
|
|
os.system("cargo build --release --target " + target)
|
|
else:
|
|
os.system("cargo build --release")
|
|
|
|
# Linux: python3 generate.py -f ../rustdesk-portable-packer/test -o . -e ./test/main.py
|
|
# Windows: python3 .\generate.py -f ..\rustdesk\flutter\build\windows\runner\Debug\ -o . -e ..\rustdesk\flutter\build\windows\runner\Debug\rustdesk.exe
|
|
|
|
|
|
if __name__ == '__main__':
|
|
parser = optparse.OptionParser()
|
|
parser.add_option("-f", "--folder", dest="folder",
|
|
help="folder to compress")
|
|
parser.add_option("-o", "--output", dest="output_folder",
|
|
help="the root of portable packer project, default is './'")
|
|
parser.add_option("-e", "--executable", dest="executable",
|
|
help="specify startup file in --folder, default is rustdesk.exe")
|
|
parser.add_option("-t", "--target", dest="target",
|
|
help="the target used by cargo")
|
|
parser.add_option("-l", "--level", dest="level", type="int",
|
|
help="compression level, default is 11, highest", default=11)
|
|
(options, args) = parser.parse_args()
|
|
folder = options.folder or './rustdesk'
|
|
output_folder = os.path.abspath(options.output_folder or './')
|
|
|
|
if not options.executable:
|
|
options.executable = 'rustdesk.exe'
|
|
if not options.executable.startswith(folder):
|
|
options.executable = folder + '/' + options.executable
|
|
exe: str = os.path.abspath(options.executable)
|
|
if not exe.startswith(os.path.abspath(folder)):
|
|
print("The executable must locate in source folder")
|
|
exit(-1)
|
|
exe = '.' + exe[len(os.path.abspath(folder)):]
|
|
print("Executable path: " + exe)
|
|
print("Compression level: " + str(options.level))
|
|
md5_table = generate_md5_table(folder, options.level)
|
|
write_package_metadata(md5_table, output_folder, exe)
|
|
write_app_metadata(output_folder)
|
|
build_portable(output_folder, options.target)
|