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>
302 lines
11 KiB
Python
Executable File
302 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
import requests
|
|
import argparse
|
|
import json
|
|
|
|
|
|
def check_response(response):
|
|
"""
|
|
Check API response and handle errors.
|
|
|
|
Two error cases:
|
|
1. Status code is not 200 -> exit with error
|
|
2. Response contains {"error": "xxx"} -> exit with error
|
|
"""
|
|
if response.status_code != 200:
|
|
print(f"Error: HTTP {response.status_code}: {response.text}")
|
|
exit(1)
|
|
|
|
# Check for {"error": "xxx"} in response
|
|
if response.text and response.text.strip():
|
|
try:
|
|
json_data = response.json()
|
|
if isinstance(json_data, dict) and "error" in json_data:
|
|
print(f"Error: {json_data['error']}")
|
|
exit(1)
|
|
return json_data
|
|
except ValueError:
|
|
return response.text
|
|
|
|
return None
|
|
|
|
|
|
def headers_with(token):
|
|
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
|
|
|
|
|
# ---------- Strategies APIs ----------
|
|
|
|
def list_strategies(url, token):
|
|
"""List all strategies"""
|
|
headers = headers_with(token)
|
|
r = requests.get(f"{url}/api/strategies", headers=headers)
|
|
return check_response(r)
|
|
|
|
|
|
def get_strategy_by_guid(url, token, guid):
|
|
"""Get strategy by GUID"""
|
|
headers = headers_with(token)
|
|
r = requests.get(f"{url}/api/strategies/{guid}", headers=headers)
|
|
return check_response(r)
|
|
|
|
|
|
def get_strategy_by_name(url, token, name):
|
|
"""Get strategy by name"""
|
|
strategies = list_strategies(url, token)
|
|
if not strategies:
|
|
return None
|
|
for s in strategies:
|
|
if str(s.get("name")) == name:
|
|
return s
|
|
return None
|
|
|
|
|
|
def enable_strategy(url, token, name):
|
|
"""Enable a strategy"""
|
|
headers = headers_with(token)
|
|
strategy = get_strategy_by_name(url, token, name)
|
|
if not strategy:
|
|
print(f"Error: Strategy '{name}' not found")
|
|
exit(1)
|
|
guid = strategy.get("guid")
|
|
r = requests.put(f"{url}/api/strategies/{guid}/status", headers=headers, json=True)
|
|
check_response(r)
|
|
return "Success"
|
|
|
|
|
|
def disable_strategy(url, token, name):
|
|
"""Disable a strategy"""
|
|
headers = headers_with(token)
|
|
strategy = get_strategy_by_name(url, token, name)
|
|
if not strategy:
|
|
print(f"Error: Strategy '{name}' not found")
|
|
exit(1)
|
|
guid = strategy.get("guid")
|
|
r = requests.put(f"{url}/api/strategies/{guid}/status", headers=headers, json=False)
|
|
check_response(r)
|
|
return "Success"
|
|
|
|
|
|
def get_device_guid_by_id(url, token, device_id):
|
|
"""Get device GUID by device ID (exact match)"""
|
|
headers = headers_with(token)
|
|
params = {"id": device_id, "pageSize": 50}
|
|
r = requests.get(f"{url}/api/devices", headers=headers, params=params)
|
|
res = check_response(r)
|
|
if not res:
|
|
return None
|
|
|
|
devices_data = res.get("data", []) if isinstance(res, dict) else res
|
|
for d in devices_data:
|
|
if d.get("id") == device_id:
|
|
return d.get("guid")
|
|
return None
|
|
|
|
|
|
def get_user_guid_by_name(url, token, name):
|
|
"""Get user GUID by exact name match"""
|
|
headers = headers_with(token)
|
|
params = {"name": name, "pageSize": 50}
|
|
r = requests.get(f"{url}/api/users", headers=headers, params=params)
|
|
res = check_response(r)
|
|
if not res:
|
|
return None
|
|
|
|
users_data = res.get("data", []) if isinstance(res, dict) else res
|
|
for u in users_data:
|
|
if u.get("name") == name:
|
|
return u.get("guid")
|
|
return None
|
|
|
|
|
|
def get_device_group_guid_by_name(url, token, name):
|
|
"""Get device group GUID by exact name match"""
|
|
headers = headers_with(token)
|
|
params = {"pageSize": 50, "name": name}
|
|
r = requests.get(f"{url}/api/device-groups", headers=headers, params=params)
|
|
res = check_response(r)
|
|
if not res:
|
|
return None
|
|
|
|
groups_data = res.get("data", []) if isinstance(res, dict) else res
|
|
for g in groups_data:
|
|
if g.get("name") == name:
|
|
return g.get("guid")
|
|
return None
|
|
|
|
|
|
def assign_strategy(url, token, strategy_name, peers=None, users=None, device_groups=None):
|
|
"""
|
|
Assign strategy to peers, users, or device groups
|
|
|
|
Args:
|
|
strategy_name: Name of the strategy (or None to unassign)
|
|
peers: List of device IDs or GUIDs
|
|
users: List of user names or GUIDs
|
|
device_groups: List of device group names or GUIDs
|
|
"""
|
|
headers = headers_with(token)
|
|
|
|
# Get strategy GUID if strategy_name is provided
|
|
strategy_guid = None
|
|
if strategy_name:
|
|
strategy = get_strategy_by_name(url, token, strategy_name)
|
|
if not strategy:
|
|
print(f"Error: Strategy '{strategy_name}' not found")
|
|
exit(1)
|
|
strategy_guid = strategy.get("guid")
|
|
|
|
# Convert device IDs to GUIDs
|
|
peer_guids = []
|
|
if peers:
|
|
for peer in peers:
|
|
# Check if it's already a GUID format
|
|
if len(peer) == 36 and peer.count('-') == 4:
|
|
peer_guids.append(peer)
|
|
else:
|
|
# Treat as device ID, look it up
|
|
guid = get_device_guid_by_id(url, token, peer)
|
|
if not guid:
|
|
print(f"Error: Device '{peer}' not found")
|
|
exit(1)
|
|
peer_guids.append(guid)
|
|
|
|
# Convert user names to GUIDs
|
|
user_guids = []
|
|
if users:
|
|
for user in users:
|
|
# Check if it's already a GUID format
|
|
if len(user) == 36 and user.count('-') == 4:
|
|
user_guids.append(user)
|
|
else:
|
|
# Treat as username, look it up
|
|
guid = get_user_guid_by_name(url, token, user)
|
|
if not guid:
|
|
print(f"Error: User '{user}' not found")
|
|
exit(1)
|
|
user_guids.append(guid)
|
|
|
|
# Convert device group names to GUIDs
|
|
device_group_guids = []
|
|
if device_groups:
|
|
for dg in device_groups:
|
|
# Check if it's already a GUID format
|
|
if len(dg) == 36 and dg.count('-') == 4:
|
|
device_group_guids.append(dg)
|
|
else:
|
|
# Treat as device group name, look it up
|
|
guid = get_device_group_guid_by_name(url, token, dg)
|
|
if not guid:
|
|
print(f"Error: Device group '{dg}' not found")
|
|
exit(1)
|
|
device_group_guids.append(guid)
|
|
|
|
# Build payload
|
|
payload = {}
|
|
if strategy_guid:
|
|
payload["strategy"] = strategy_guid
|
|
|
|
payload["peers"] = peer_guids
|
|
payload["users"] = user_guids
|
|
payload["groups"] = device_group_guids
|
|
|
|
r = requests.post(f"{url}/api/strategies/assign", headers=headers, json=payload)
|
|
check_response(r)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Strategy manager")
|
|
parser.add_argument("command", choices=[
|
|
"list", "view", "enable", "disable", "assign", "unassign"
|
|
])
|
|
parser.add_argument("--url", required=True, help="Server URL")
|
|
parser.add_argument("--token", required=True, help="API token")
|
|
|
|
parser.add_argument("--name", help="Strategy name (for view/enable/disable/assign commands)")
|
|
parser.add_argument("--guid", help="Strategy GUID (for view command, alternative to --name)")
|
|
|
|
# For assign/unassign commands
|
|
parser.add_argument("--peers", help="Comma separated device IDs or GUIDs (requires Device Permission:r)")
|
|
parser.add_argument("--users", help="Comma separated user names or GUIDs (requires User Permission:r)")
|
|
parser.add_argument("--device-groups", help="Comma separated device group names or GUIDs (requires Device Group Permission:r)")
|
|
|
|
args = parser.parse_args()
|
|
while args.url.endswith("/"): args.url = args.url[:-1]
|
|
|
|
if args.command == "list":
|
|
res = list_strategies(args.url, args.token)
|
|
print(json.dumps(res, indent=2))
|
|
|
|
elif args.command == "view":
|
|
if args.guid:
|
|
res = get_strategy_by_guid(args.url, args.token, args.guid)
|
|
print(json.dumps(res, indent=2))
|
|
elif args.name:
|
|
strategy = get_strategy_by_name(args.url, args.token, args.name)
|
|
if not strategy:
|
|
print(f"Error: Strategy '{args.name}' not found")
|
|
exit(1)
|
|
# Get full details by GUID
|
|
guid = strategy.get("guid")
|
|
res = get_strategy_by_guid(args.url, args.token, guid)
|
|
print(json.dumps(res, indent=2))
|
|
else:
|
|
print("Error: --name or --guid is required for view command")
|
|
exit(1)
|
|
|
|
elif args.command == "enable":
|
|
if not args.name:
|
|
print("Error: --name is required")
|
|
exit(1)
|
|
print(enable_strategy(args.url, args.token, args.name))
|
|
|
|
elif args.command == "disable":
|
|
if not args.name:
|
|
print("Error: --name is required")
|
|
exit(1)
|
|
print(disable_strategy(args.url, args.token, args.name))
|
|
|
|
elif args.command == "assign":
|
|
if not args.name:
|
|
print("Error: --name is required")
|
|
exit(1)
|
|
if not args.peers and not args.users and not args.device_groups:
|
|
print("Error: at least one of --peers, --users, or --device-groups is required")
|
|
exit(1)
|
|
|
|
peers = [x.strip() for x in args.peers.split(",") if x.strip()] if args.peers else None
|
|
users = [x.strip() for x in args.users.split(",") if x.strip()] if args.users else None
|
|
device_groups = [x.strip() for x in args.device_groups.split(",") if x.strip()] if args.device_groups else None
|
|
|
|
assign_strategy(args.url, args.token, args.name, peers=peers, users=users, device_groups=device_groups)
|
|
count = (len(peers) if peers else 0) + (len(users) if users else 0) + (len(device_groups) if device_groups else 0)
|
|
print(f"Success: Assigned strategy '{args.name}' to {count} target(s)")
|
|
|
|
elif args.command == "unassign":
|
|
if not args.peers and not args.users and not args.device_groups:
|
|
print("Error: at least one of --peers, --users, or --device-groups is required")
|
|
exit(1)
|
|
|
|
peers = [x.strip() for x in args.peers.split(",") if x.strip()] if args.peers else None
|
|
users = [x.strip() for x in args.users.split(",") if x.strip()] if args.users else None
|
|
device_groups = [x.strip() for x in args.device_groups.split(",") if x.strip()] if args.device_groups else None
|
|
|
|
assign_strategy(args.url, args.token, None, peers=peers, users=users, device_groups=device_groups)
|
|
count = (len(peers) if peers else 0) + (len(users) if users else 0) + (len(device_groups) if device_groups else 0)
|
|
print(f"Success: Unassigned strategy from {count} target(s)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|