Initial commit: hello-agent — headless RustDesk-protocol-compatible Windows agent
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>
This commit is contained in:
2026-05-07 11:01:30 +02:00
commit f8ead215d8
479 changed files with 188052 additions and 0 deletions
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""Pack icon.png into a multi-size icon.ico used by the Windows resource compiler.
ICO format note:
Vista+ allows PNG-encoded frames inside ICO files, BUT the Microsoft
resource compiler (rc.exe) only reliably accepts PNG payloads for the
256x256 frame. PNG payloads for 16/32/48/64/128 are silently dropped
(or worse — rc.exe writes a resource section Explorer can't decode and
the EXE shows the generic icon). So we encode small sizes as DIB
(BITMAPINFOHEADER + BGRA pixels + AND-mask) and keep PNG only for 256.
Requires Pillow. We use it to decode the source PNG and resample.
python3 -m venv .venv && .venv/bin/pip install Pillow
.venv/bin/python3 resources/build_ico.py
"""
import io
import struct
import sys
from pathlib import Path
try:
from PIL import Image
except ImportError:
sys.exit("Pillow required: pip install Pillow")
DIB_SIZES = [16, 32, 48, 64, 128] # BITMAPINFOHEADER + BGRA
PNG_SIZES = [256] # PNG payload (preserves alpha cleanly at large size)
HERE = Path(__file__).parent
SRC = HERE / "icon.png"
def encode_dib(img: Image.Image, size: int) -> bytes:
"""Encode an RGBA image as a DIB-format ICO frame.
Layout: BITMAPINFOHEADER (40 bytes), then BGRA pixels bottom-up, then
a 1-bit AND-mask (also bottom-up, row-padded to 4 bytes). The header
declares double the actual height so Windows knows the AND-mask is
appended — this is the (counter-intuitive but mandatory) ICO
convention; the file is otherwise an ordinary 32bpp BMP minus the
14-byte BITMAPFILEHEADER.
"""
img = img.convert("RGBA").resize((size, size), Image.LANCZOS)
pixels = img.load()
# 32bpp + alpha — AND mask can be all zero (fully opaque-from-mask;
# the per-pixel alpha is what Windows actually composites). Still
# required structurally.
bgra_rows = []
for y in range(size - 1, -1, -1): # bottom-up
row = bytearray()
for x in range(size):
r, g, b, a = pixels[x, y]
row += bytes((b, g, r, a))
bgra_rows.append(bytes(row))
bgra = b"".join(bgra_rows)
mask_row_bytes = ((size + 31) // 32) * 4 # 4-byte aligned
mask = b"\x00" * (mask_row_bytes * size)
header = struct.pack(
"<IiiHHIIiiII",
40, # biSize
size, # biWidth
size * 2, # biHeight (double — XOR + AND mask)
1, # biPlanes
32, # biBitCount
0, # biCompression = BI_RGB
len(bgra) + len(mask), # biSizeImage
0, 0, 0, 0, # ppm, colors used, colors important
)
return header + bgra + mask
def encode_png(img: Image.Image, size: int) -> bytes:
img = img.convert("RGBA").resize((size, size), Image.LANCZOS)
buf = io.BytesIO()
img.save(buf, format="PNG", optimize=True)
return buf.getvalue()
def main() -> None:
if not SRC.exists():
sys.exit(f"missing {SRC}")
src = Image.open(SRC)
frames = []
for sz in DIB_SIZES:
frames.append((sz, "dib", encode_dib(src, sz)))
for sz in PNG_SIZES:
frames.append((sz, "png", encode_png(src, sz)))
out = bytearray()
# ICONDIR: reserved(2) + type=1(2) + count(2)
out += struct.pack("<HHH", 0, 1, len(frames))
header_size = 6 + 16 * len(frames)
offset = header_size
for sz, _kind, payload in frames:
w = 0 if sz == 256 else sz
h = 0 if sz == 256 else sz
out += struct.pack(
"<BBBBHHII",
w, h,
0, # colorCount
0, # reserved
1, # planes
32, # bitCount
len(payload),
offset,
)
offset += len(payload)
for _, _, payload in frames:
out += payload
(HERE / "icon.ico").write_bytes(out)
summary = ", ".join(f"{sz}{'·png' if k=='png' else ''}" for sz, k, _ in frames)
print(f"wrote {HERE / 'icon.ico'} ({len(out)} bytes; frames: {summary})")
if __name__ == "__main__":
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB