Files
hello-agent/vendor/rustdesk/res/device-groups.py
T
mike f8ead215d8
build-windows / build-hello-agent-x64 (push) Successful in 5m41s
Initial commit: hello-agent — headless RustDesk-protocol-compatible Windows agent
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>
2026-05-08 16:29:31 +02:00

275 lines
8.9 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"}
# ---------- Device Group APIs ----------
def list_groups(url, token, name=None, page_size=50):
headers = headers_with(token)
params = {"pageSize": page_size}
if name:
params["name"] = name
data, current = [], 0
while True:
current += 1
params["current"] = current
r = requests.get(f"{url}/api/device-groups", headers=headers, params=params)
if r.status_code != 200:
print(f"Error: HTTP {r.status_code} - {r.text}")
exit(1)
res = r.json()
if "error" in res:
print(f"Error: {res['error']}")
exit(1)
rows = res.get("data", [])
data.extend(rows)
total = res.get("total", 0)
if len(rows) < page_size or current * page_size >= total:
break
return data
def get_group_by_name(url, token, name):
groups = list_groups(url, token, name)
for g in groups:
if str(g.get("name")) == name:
return g
return None
def create_group(url, token, name, note=None, accessed_from=None):
headers = headers_with(token)
payload = {"name": name}
if note:
payload["note"] = note
if accessed_from:
payload["allowed_incomings"] = accessed_from
r = requests.post(f"{url}/api/device-groups", headers=headers, json=payload)
return check_response(r)
def update_group(url, token, name, new_name=None, note=None, accessed_from=None):
headers = headers_with(token)
g = get_group_by_name(url, token, name)
if not g:
print(f"Error: Group '{name}' not found")
exit(1)
guid = g.get("guid")
payload = {}
if new_name is not None:
payload["name"] = new_name
if note is not None:
payload["note"] = note
if accessed_from is not None:
payload["allowed_incomings"] = accessed_from
r = requests.patch(f"{url}/api/device-groups/{guid}", headers=headers, json=payload)
check_response(r)
return "Success"
def delete_groups(url, token, names):
headers = headers_with(token)
if isinstance(names, str):
names = [names]
for n in names:
g = get_group_by_name(url, token, n)
if not g:
print(f"Error: Group '{n}' not found")
exit(1)
guid = g.get("guid")
r = requests.delete(f"{url}/api/device-groups/{guid}", headers=headers)
check_response(r)
return "Success"
# ---------- Device group assign APIs (name -> guid) ----------
def view_devices(url, token, group_name=None, id=None, device_name=None,
user_name=None, device_username=None, page_size=50):
"""View devices in a device group with filters"""
headers = headers_with(token)
# Separate exact match and fuzzy match params
params = {}
fuzzy_params = {
"id": id,
"device_name": device_name,
"user_name": user_name,
"device_username": device_username,
}
# Add device_group_name without wildcard (exact match)
if group_name:
params["device_group_name"] = group_name
# Add wildcard for fuzzy search to other params
for k, v in fuzzy_params.items():
if v is not None:
params[k] = "%" + v + "%" if (v != "-" and "%" not in v) else v
params["pageSize"] = page_size
data, current = [], 0
while True:
current += 1
params["current"] = current
r = requests.get(f"{url}/api/devices", headers=headers, params=params)
if r.status_code != 200:
return check_response(r)
res = r.json()
rows = res.get("data", [])
data.extend(rows)
total = res.get("total", 0)
if len(rows) < page_size or current * page_size >= total:
break
return data
def add_devices(url, token, group_name, device_ids):
headers = headers_with(token)
g = get_group_by_name(url, token, group_name)
if not g:
return f"Group '{group_name}' not found"
guid = g.get("guid")
payload = device_ids if isinstance(device_ids, list) else [device_ids]
r = requests.post(f"{url}/api/device-groups/{guid}", headers=headers, json=payload)
return check_response(r)
def remove_devices(url, token, group_name, device_ids):
headers = headers_with(token)
g = get_group_by_name(url, token, group_name)
if not g:
return f"Group '{group_name}' not found"
guid = g.get("guid")
payload = device_ids if isinstance(device_ids, list) else [device_ids]
r = requests.delete(f"{url}/api/device-groups/{guid}/devices", headers=headers, json=payload)
return check_response(r)
def parse_rules(s):
if not s:
return None
try:
v = json.loads(s)
if isinstance(v, list):
# expect list of {"type": number, "name": string}
return v
except Exception:
pass
return None
def main():
parser = argparse.ArgumentParser(description="Device Group manager")
parser.add_argument("command", choices=[
"view", "add", "update", "delete",
"view-devices", "add-devices", "remove-devices"
], help=(
"Command to execute. "
"[view/add/update/delete/add-devices/remove-devices: require Device Group Permission] "
"[view-devices: require Device Permission]"
))
parser.add_argument("--url", required=True)
parser.add_argument("--token", required=True)
parser.add_argument("--name", help="Device group name (exact match)")
parser.add_argument("--new-name", help="New device group name (for update)")
parser.add_argument("--note", help="Note")
parser.add_argument("--accessed-from", help="JSON array: '[{\"type\":0|2,\"name\":\"...\"}]' (0=User Group, 2=User)")
parser.add_argument("--ids", help="Comma separated device IDs for add-devices/remove-devices")
# Filters for view-devices command
parser.add_argument("--id", help="Device ID filter (for view-devices)")
parser.add_argument("--device-name", help="Device name filter (for view-devices)")
parser.add_argument("--user-name", help="User name filter (owner of device, for view-devices)")
parser.add_argument("--device-username", help="Device username filter (logged in user on device, for view-devices)")
args = parser.parse_args()
while args.url.endswith("/"): args.url = args.url[:-1]
if args.command == "view":
res = list_groups(args.url, args.token, args.name)
print(json.dumps(res, indent=2))
elif args.command == "add":
if not args.name:
print("Error: --name is required")
exit(1)
print(create_group(
args.url, args.token, args.name, args.note,
parse_rules(args.accessed_from)
))
elif args.command == "update":
if not args.name:
print("Error: --name is required")
exit(1)
print(update_group(
args.url, args.token, args.name, args.new_name, args.note,
parse_rules(args.accessed_from)
))
elif args.command == "delete":
if not args.name:
print("Error: --name is required (supports comma separated)")
exit(1)
names = [x.strip() for x in args.name.split(",") if x.strip()]
print(delete_groups(args.url, args.token, names))
elif args.command == "view-devices":
res = view_devices(
args.url,
args.token,
group_name=args.name,
id=args.id,
device_name=args.device_name,
user_name=args.user_name,
device_username=args.device_username
)
print(json.dumps(res, indent=2))
elif args.command in ("add-devices", "remove-devices"):
if not args.name or not args.ids:
print("Error: --name and --ids are required for add/remove devices")
exit(1)
ids = [x.strip() for x in args.ids.split(",") if x.strip()]
if args.command == "add-devices":
print(add_devices(args.url, args.token, args.name, ids))
else:
print(remove_devices(args.url, args.token, args.name, ids))
if __name__ == "__main__":
main()