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>
322 lines
9.4 KiB
Python
Executable File
322 lines
9.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
import requests
|
|
import os
|
|
import time
|
|
import argparse
|
|
import logging
|
|
import shutil
|
|
import zipfile
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [%(levelname)s] %(message)s [%(filename)s:%(lineno)d]",
|
|
handlers=[logging.StreamHandler()],
|
|
)
|
|
|
|
# The URL of your Flask server
|
|
BASE_URL = os.getenv("BASE_URL") or "http://localhost:5000"
|
|
|
|
# The secret key for API authentication
|
|
SECRET_KEY = os.getenv("SECRET_KEY") or "worldpeace2024"
|
|
|
|
# The headers for API requests
|
|
HEADERS = {"Authorization": f"Bearer {SECRET_KEY}"}
|
|
|
|
SIGN_TIMEOUT = int(os.getenv("SIGN_TIMEOUT") or "30")
|
|
TIMEOUT = float(os.getenv("TIMEOUT") or "900")
|
|
|
|
|
|
def create(task_name, file_path=None):
|
|
if file_path is None:
|
|
response = requests.post(
|
|
f"{BASE_URL}/tasks/{task_name}", timeout=TIMEOUT, headers=HEADERS
|
|
)
|
|
else:
|
|
with open(file_path, "rb") as f:
|
|
files = {"file": f}
|
|
response = requests.post(
|
|
f"{BASE_URL}/tasks/{task_name}",
|
|
timeout=TIMEOUT,
|
|
headers=HEADERS,
|
|
files=files,
|
|
)
|
|
return get_json(response)
|
|
|
|
|
|
def upload_file(task_id, file_path):
|
|
with open(file_path, "rb") as f:
|
|
files = {"file": f}
|
|
response = requests.post(
|
|
f"{BASE_URL}/tasks/{task_id}/files",
|
|
timeout=TIMEOUT,
|
|
headers=HEADERS,
|
|
files=files,
|
|
)
|
|
return get_json(response)
|
|
|
|
|
|
def get_status(task_id):
|
|
response = requests.get(
|
|
f"{BASE_URL}/tasks/{task_id}/status", timeout=TIMEOUT, headers=HEADERS
|
|
)
|
|
return get_json(response)
|
|
|
|
|
|
def download_files(task_id, output_dir, fn=None):
|
|
response = requests.get(
|
|
f"{BASE_URL}/tasks/{task_id}/files",
|
|
timeout=TIMEOUT,
|
|
headers=HEADERS,
|
|
stream=True,
|
|
)
|
|
|
|
# Check if the request was successful
|
|
if fn is None:
|
|
fn = f"task_{task_id}_files.zip"
|
|
if response.status_code == 200:
|
|
# Save the file to the output directory
|
|
with open(os.path.join(output_dir, fn), "wb") as f:
|
|
for chunk in response.iter_content(chunk_size=1024):
|
|
if chunk:
|
|
f.write(chunk)
|
|
return response.ok
|
|
|
|
|
|
def download_one_file(task_id, file_id, output_dir):
|
|
response = requests.get(
|
|
f"{BASE_URL}/tasks/{task_id}/files/{file_id}",
|
|
timeout=TIMEOUT,
|
|
headers=HEADERS,
|
|
stream=True,
|
|
)
|
|
|
|
# Check if the request was successful
|
|
if response.status_code == 200:
|
|
# Save the file to the output directory
|
|
with open(os.path.join(output_dir, file_id), "wb") as f:
|
|
for chunk in response.iter_content(chunk_size=1024):
|
|
if chunk:
|
|
f.write(chunk)
|
|
return response.ok
|
|
|
|
|
|
def fetch(tag=None):
|
|
response = requests.get(
|
|
f"{BASE_URL}/tasks/fetch_task" + ("?tag=%s" % tag if tag else ""),
|
|
timeout=TIMEOUT,
|
|
headers=HEADERS,
|
|
)
|
|
return get_json(response)
|
|
|
|
|
|
def update_status(task_id, status):
|
|
response = requests.patch(
|
|
f"{BASE_URL}/tasks/{task_id}/status",
|
|
timeout=TIMEOUT,
|
|
headers=HEADERS,
|
|
json=status,
|
|
)
|
|
return get_json(response)
|
|
|
|
|
|
def delete_task(task_id):
|
|
response = requests.delete(
|
|
f"{BASE_URL}/tasks/{task_id}",
|
|
timeout=TIMEOUT,
|
|
headers=HEADERS,
|
|
)
|
|
return get_json(response)
|
|
|
|
|
|
def sign(file_path):
|
|
res = create("sign", file_path)
|
|
if res.ok:
|
|
task_id = res.task_id
|
|
|
|
# Poll the status every second
|
|
while True:
|
|
status = get_status(task_id)
|
|
if status["status"] == "done":
|
|
# Download the files
|
|
download_files(task_id, "output")
|
|
|
|
# Delete the task
|
|
delete_task(task_id)
|
|
|
|
break
|
|
|
|
time.sleep(1)
|
|
|
|
|
|
def sign_one_file(file_path):
|
|
logging.info(f"Signing {file_path}")
|
|
res = create("sign", file_path)
|
|
logging.info(f"Uploaded {file_path}")
|
|
task_id = res["id"]
|
|
n = 0
|
|
while True:
|
|
if n >= SIGN_TIMEOUT:
|
|
delete_task(task_id)
|
|
logging.error(f"Failed to sign {file_path}")
|
|
break
|
|
time.sleep(6)
|
|
n += 1
|
|
status = get_status(task_id)
|
|
if status and status.get("state") == "done":
|
|
download_one_file(
|
|
task_id, os.path.basename(file_path), os.path.dirname(file_path)
|
|
)
|
|
delete_task(task_id)
|
|
logging.info(f"Signed {file_path}")
|
|
return True
|
|
return False
|
|
|
|
|
|
def get_json(response):
|
|
try:
|
|
return response.json()
|
|
except Exception as e:
|
|
raise Exception(response.text)
|
|
|
|
|
|
SIGN_EXTENSIONS = [
|
|
".dll",
|
|
".exe",
|
|
".sys",
|
|
".vxd",
|
|
".msix",
|
|
".msixbundle",
|
|
".appx",
|
|
".appxbundle",
|
|
".msi",
|
|
".msp",
|
|
".msm",
|
|
".cab",
|
|
".ps1",
|
|
".psm1",
|
|
]
|
|
|
|
|
|
def sign_files(dir_path, only_ext=None):
|
|
if only_ext:
|
|
only_ext = only_ext.split(",")
|
|
for i in range(len(only_ext)):
|
|
if not only_ext[i].startswith("."):
|
|
only_ext[i] = "." + only_ext[i]
|
|
for root, dirs, files in os.walk(dir_path):
|
|
is_signed_dir = "RustDeskPrinterDriver" in root or "usbmmidd_v2" in root
|
|
for file in files:
|
|
file_path = os.path.join(root, file)
|
|
_, ext = os.path.splitext(file_path)
|
|
# only sign the exe files in signed dirs
|
|
if is_signed_dir and ext not in [".exe"]:
|
|
continue
|
|
if only_ext and ext not in only_ext:
|
|
continue
|
|
if ext in SIGN_EXTENSIONS:
|
|
if not sign_one_file(file_path):
|
|
logging.error(f"Failed to sign {file_path}")
|
|
break
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Command line interface for task operations."
|
|
)
|
|
subparsers = parser.add_subparsers(dest="command")
|
|
|
|
# Create a parser for the "sign_one_file" command
|
|
sign_one_file_parser = subparsers.add_parser(
|
|
"sign_one_file", help="Sign a single file."
|
|
)
|
|
sign_one_file_parser.add_argument("file_path", help="The path of the file to sign.")
|
|
|
|
# Create a parser for the "sign_files" command
|
|
sign_files_parser = subparsers.add_parser(
|
|
"sign_files", help="Sign all files in a directory."
|
|
)
|
|
sign_files_parser.add_argument(
|
|
"dir_path", help="The path of the directory containing the files to sign."
|
|
)
|
|
sign_files_parser.add_argument(
|
|
"only_ext", help="The file extension to sign.", default=None, nargs="?"
|
|
)
|
|
|
|
# Create a parser for the "fetch" command
|
|
fetch_parser = subparsers.add_parser("fetch", help="Fetch a task.")
|
|
|
|
# Create a parser for the "update_status" command
|
|
update_status_parser = subparsers.add_parser(
|
|
"update_status", help="Update the status of a task."
|
|
)
|
|
update_status_parser.add_argument("task_id", help="The ID of the task to update.")
|
|
update_status_parser.add_argument("status", help="The new status of the task.")
|
|
|
|
# Create a parser for the "delete_task" command
|
|
delete_task_parser = subparsers.add_parser("delete_task", help="Delete a task.")
|
|
delete_task_parser.add_argument("task_id", help="The ID of the task to delete.")
|
|
|
|
# Create a parser for the "create" command
|
|
create_parser = subparsers.add_parser("create", help="Create a task.")
|
|
create_parser.add_argument("task_name", help="The name of the task to create.")
|
|
create_parser.add_argument(
|
|
"file_path",
|
|
help="The path of the file for the task.",
|
|
default=None,
|
|
nargs="?",
|
|
)
|
|
|
|
# Create a parser for the "upload_file" command
|
|
upload_file_parser = subparsers.add_parser(
|
|
"upload_file", help="Upload a file to a task."
|
|
)
|
|
upload_file_parser.add_argument(
|
|
"task_id", help="The ID of the task to upload the file to."
|
|
)
|
|
upload_file_parser.add_argument("file_path", help="The path of the file to upload.")
|
|
|
|
# Create a parser for the "get_status" command
|
|
get_status_parser = subparsers.add_parser(
|
|
"get_status", help="Get the status of a task."
|
|
)
|
|
get_status_parser.add_argument(
|
|
"task_id", help="The ID of the task to get the status of."
|
|
)
|
|
|
|
# Create a parser for the "download_files" command
|
|
download_files_parser = subparsers.add_parser(
|
|
"download_files", help="Download files from a task."
|
|
)
|
|
download_files_parser.add_argument(
|
|
"task_id", help="The ID of the task to download files from."
|
|
)
|
|
download_files_parser.add_argument(
|
|
"output_dir", help="The directory to save the downloaded files to."
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.command == "sign_one_file":
|
|
sign_one_file(args.file_path)
|
|
elif args.command == "sign_files":
|
|
sign_files(args.dir_path, args.only_ext)
|
|
elif args.command == "fetch":
|
|
print(fetch())
|
|
elif args.command == "update_status":
|
|
print(update_status(args.task_id, args.status))
|
|
elif args.command == "delete_task":
|
|
print(delete_task(args.task_id))
|
|
elif args.command == "create":
|
|
print(create(args.task_name, args.file_path))
|
|
elif args.command == "upload_file":
|
|
print(upload_file(args.task_id, args.file_path))
|
|
elif args.command == "get_status":
|
|
print(get_status(args.task_id))
|
|
elif args.command == "download_files":
|
|
print(download_files(args.task_id, args.output_dir))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|