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>
375 lines
12 KiB
Python
Executable File
375 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
import requests
|
|
import argparse
|
|
import json
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
|
|
def format_timestamp(timestamp):
|
|
"""Convert Unix timestamp to readable local datetime"""
|
|
if timestamp is None:
|
|
return None
|
|
try:
|
|
# Convert to local time
|
|
local_dt = datetime.fromtimestamp(timestamp)
|
|
return local_dt.strftime("%Y-%m-%d %H:%M:%S")
|
|
except (ValueError, TypeError):
|
|
return timestamp
|
|
|
|
|
|
def parse_local_time_to_utc_string(time_str):
|
|
"""Parse local time string to UTC time string for API filtering"""
|
|
try:
|
|
# Parse the local time string
|
|
local_dt = datetime.strptime(time_str, "%Y-%m-%d %H:%M:%S.%f")
|
|
# Make the datetime object timezone-aware using system's local timezone
|
|
local_dt = local_dt.replace(tzinfo=datetime.now().astimezone().tzinfo)
|
|
utc_dt = local_dt.astimezone(timezone.utc)
|
|
return utc_dt.strftime("%Y-%m-%d %H:%M:%S.000")
|
|
except ValueError:
|
|
try:
|
|
# Try without microseconds
|
|
local_dt = datetime.strptime(time_str, "%Y-%m-%d %H:%M:%S")
|
|
# Make the datetime object timezone-aware using system's local timezone
|
|
local_dt = local_dt.replace(tzinfo=datetime.now().astimezone().tzinfo)
|
|
utc_dt = local_dt.astimezone(timezone.utc)
|
|
return utc_dt.strftime("%Y-%m-%d %H:%M:%S.000")
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def get_connection_type_name(conn_type):
|
|
"""Convert connection type number to readable name"""
|
|
type_map = {
|
|
0: "Remote Desktop",
|
|
1: "File Transfer",
|
|
2: "Port Transfer",
|
|
3: "View Camera",
|
|
4: "Terminal"
|
|
}
|
|
return type_map.get(conn_type, f"Unknown ({conn_type})")
|
|
|
|
|
|
def get_console_type_name(console_type):
|
|
"""Convert console audit type number to readable name"""
|
|
type_map = {
|
|
0: "Group Management",
|
|
1: "User Management",
|
|
2: "Device Management",
|
|
3: "Address Book Management"
|
|
}
|
|
return type_map.get(console_type, f"Unknown ({console_type})")
|
|
|
|
|
|
def get_console_operation_name(operation_code):
|
|
"""Convert console operation code to readable name"""
|
|
operation_map = {
|
|
0: "User Login",
|
|
1: "Add Group",
|
|
2: "Add User",
|
|
3: "Add Device",
|
|
4: "Delete Groups",
|
|
5: "Disconnect Device",
|
|
6: "Enable Users",
|
|
7: "Disable Users",
|
|
8: "Enable Devices",
|
|
9: "Disable Devices",
|
|
10: "Update Group",
|
|
11: "Update User",
|
|
12: "Update Device",
|
|
13: "Delete User",
|
|
14: "Delete Device",
|
|
15: "Add Address Book",
|
|
16: "Delete Address Book",
|
|
17: "Change Address Book Name",
|
|
18: "Delete Devices in the Address Book Recycle Bin",
|
|
19: "Empty Address Book Recycle Bin",
|
|
20: "Add Address Book Permission",
|
|
21: "Delete Address Book Permission",
|
|
22: "Update Address Book Permission"
|
|
}
|
|
return operation_map.get(operation_code, f"Unknown ({operation_code})")
|
|
|
|
|
|
def get_alarm_type_name(alarm_type):
|
|
"""Convert alarm type number to readable name"""
|
|
type_map = {
|
|
0: "Access attempt outside the IP whitelist",
|
|
1: "Over 30 consecutive access attempts",
|
|
2: "Multiple access attempts within one minute",
|
|
3: "Over 30 consecutive login attempts",
|
|
4: "Multiple login attempts within one minute",
|
|
5: "Multiple login attempts within one hour"
|
|
}
|
|
return type_map.get(alarm_type, f"Unknown ({alarm_type})")
|
|
|
|
|
|
def enhance_audit_data(data, audit_type):
|
|
"""Enhance audit data with readable formats"""
|
|
if not data:
|
|
return data
|
|
|
|
enhanced_data = []
|
|
for item in data:
|
|
enhanced_item = item.copy()
|
|
|
|
# Convert timestamps - replace original values
|
|
if 'created_at' in enhanced_item:
|
|
enhanced_item['created_at'] = format_timestamp(enhanced_item['created_at'])
|
|
if 'end_time' in enhanced_item:
|
|
enhanced_item['end_time'] = format_timestamp(enhanced_item['end_time'])
|
|
|
|
# Add type-specific enhancements - replace original values
|
|
if audit_type == 'conn':
|
|
if 'conn_type' in enhanced_item:
|
|
enhanced_item['conn_type'] = get_connection_type_name(enhanced_item['conn_type'])
|
|
else:
|
|
enhanced_item['conn_type'] = "Not Logged In"
|
|
|
|
elif audit_type == 'console':
|
|
if 'typ' in enhanced_item:
|
|
# Replace typ field with type and convert to readable name
|
|
enhanced_item['type'] = get_console_type_name(enhanced_item['typ'])
|
|
del enhanced_item['typ']
|
|
if 'iop' in enhanced_item:
|
|
# Replace iop field with operation and convert to readable name
|
|
enhanced_item['operation'] = get_console_operation_name(enhanced_item['iop'])
|
|
del enhanced_item['iop']
|
|
|
|
elif audit_type == 'alarm' and 'typ' in enhanced_item:
|
|
# Replace typ field with type and convert to readable name
|
|
enhanced_item['type'] = get_alarm_type_name(enhanced_item['typ'])
|
|
del enhanced_item['typ']
|
|
|
|
enhanced_data.append(enhanced_item)
|
|
|
|
return enhanced_data
|
|
|
|
|
|
def check_response(response):
|
|
"""Check API response and return result"""
|
|
if response.status_code != 200:
|
|
print(f"Error: HTTP {response.status_code} - {response.text}")
|
|
exit(1)
|
|
|
|
try:
|
|
response_json = response.json()
|
|
if "error" in response_json:
|
|
print(f"Error: {response_json['error']}")
|
|
exit(1)
|
|
return response_json
|
|
except ValueError:
|
|
return response.text or "Success"
|
|
|
|
|
|
def view_audits_common(url, token, endpoint, filters=None, page_size=None, current=None,
|
|
created_at=None, days_ago=None, non_wildcard_fields=None):
|
|
"""Common function for viewing audits"""
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
|
|
# Set default page size and current page
|
|
if page_size is None:
|
|
page_size = 10
|
|
if current is None:
|
|
current = 1
|
|
|
|
params = {
|
|
"pageSize": page_size,
|
|
"current": current
|
|
}
|
|
|
|
# Add filter parameters if provided
|
|
if filters:
|
|
for key, value in filters.items():
|
|
if value is not None:
|
|
params[key] = value
|
|
|
|
# Handle time filters
|
|
if days_ago is not None:
|
|
# Calculate datetime from days ago
|
|
target_time = datetime.now() - timedelta(days=days_ago)
|
|
# Convert to UTC time string using system timezone
|
|
utc_timestamp = target_time.timestamp()
|
|
utc_dt = datetime.fromtimestamp(utc_timestamp, timezone.utc)
|
|
params["created_at"] = utc_dt.strftime("%Y-%m-%d %H:%M:%S.000")
|
|
elif created_at:
|
|
# Parse local time string and convert to UTC time string
|
|
utc_time_str = parse_local_time_to_utc_string(created_at)
|
|
if utc_time_str is not None:
|
|
params["created_at"] = utc_time_str
|
|
else:
|
|
# If parsing fails, pass the original value
|
|
params["created_at"] = created_at
|
|
|
|
# Apply wildcard patterns for string fields (excluding specific fields)
|
|
if non_wildcard_fields is None:
|
|
non_wildcard_fields = set()
|
|
|
|
# Always exclude these fields from wildcard treatment
|
|
non_wildcard_fields.update(["created_at", "pageSize", "current"])
|
|
|
|
string_params = {}
|
|
for k, v in params.items():
|
|
if isinstance(v, str) and k not in non_wildcard_fields:
|
|
if v != "-" and "%" not in v:
|
|
string_params[k] = "%" + v + "%"
|
|
else:
|
|
string_params[k] = v
|
|
else:
|
|
string_params[k] = v
|
|
|
|
response = requests.get(f"{url}/api/audits/{endpoint}", headers=headers, params=string_params)
|
|
response_json = check_response(response)
|
|
|
|
# Enhance the data with readable formats
|
|
data = enhance_audit_data(response_json.get("data", []), endpoint)
|
|
|
|
return {
|
|
"data": data,
|
|
"total": response_json.get("total", 0),
|
|
"current": current,
|
|
"pageSize": page_size
|
|
}
|
|
|
|
|
|
def view_conn_audits(url, token, remote=None, conn_type=None,
|
|
page_size=None, current=None, created_at=None, days_ago=None):
|
|
"""View connection audits"""
|
|
filters = {
|
|
"remote": remote,
|
|
"conn_type": conn_type
|
|
}
|
|
non_wildcard_fields = {"conn_type"}
|
|
|
|
return view_audits_common(
|
|
url, token, "conn", filters, page_size, current, created_at, days_ago, non_wildcard_fields
|
|
)
|
|
|
|
|
|
def view_file_audits(url, token, remote=None,
|
|
page_size=None, current=None, created_at=None, days_ago=None):
|
|
"""View file audits"""
|
|
filters = {
|
|
"remote": remote
|
|
}
|
|
non_wildcard_fields = set()
|
|
|
|
return view_audits_common(
|
|
url, token, "file", filters, page_size, current, created_at, days_ago, non_wildcard_fields
|
|
)
|
|
|
|
|
|
def view_alarm_audits(url, token, device=None,
|
|
page_size=None, current=None, created_at=None, days_ago=None):
|
|
"""View alarm audits"""
|
|
filters = {
|
|
"device": device
|
|
}
|
|
non_wildcard_fields = set()
|
|
|
|
return view_audits_common(
|
|
url, token, "alarm", filters, page_size, current, created_at, days_ago, non_wildcard_fields
|
|
)
|
|
|
|
|
|
def view_console_audits(url, token, operator=None,
|
|
page_size=None, current=None, created_at=None, days_ago=None):
|
|
"""View console audits"""
|
|
filters = {
|
|
"operator": operator
|
|
}
|
|
non_wildcard_fields = set()
|
|
|
|
return view_audits_common(
|
|
url, token, "console", filters, page_size, current, created_at, days_ago, non_wildcard_fields
|
|
)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Audits manager")
|
|
parser.add_argument(
|
|
"command",
|
|
choices=["view-conn", "view-file", "view-alarm", "view-console"],
|
|
help="Command to execute",
|
|
)
|
|
parser.add_argument("--url", required=True, help="URL of the API")
|
|
parser.add_argument("--token", required=True, help="Bearer token for authentication")
|
|
|
|
# Pagination parameters
|
|
parser.add_argument("--page-size", type=int, default=10, help="Number of records per page (default: 10)")
|
|
parser.add_argument("--current", type=int, default=1, help="Current page number (default: 1)")
|
|
|
|
# Time filtering parameters
|
|
parser.add_argument("--created-at", help="Filter by creation time in local time (format: 2025-09-16 14:15:57 or 2025-09-16 14:15:57.000)")
|
|
parser.add_argument("--days-ago", type=int, help="Filter by days ago (e.g., 7 for last 7 days)")
|
|
|
|
# Audit filters (simplified)
|
|
parser.add_argument("--remote", help="Remote peer ID filter (for conn/file audits)")
|
|
parser.add_argument("--device", help="Device ID filter (for alarm audits)")
|
|
parser.add_argument("--conn-type", type=int, help="Connection type filter (for conn audits only): 0=Remote Desktop, 1=File Transfer, 2=Port Transfer, 3=View Camera, 4=Terminal")
|
|
parser.add_argument("--operator", help="Operator filter (for console audits only)")
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Remove trailing slashes from URL
|
|
while args.url.endswith("/"):
|
|
args.url = args.url[:-1]
|
|
|
|
if args.command == "view-conn":
|
|
# View connection audits
|
|
result = view_conn_audits(
|
|
args.url,
|
|
args.token,
|
|
args.remote,
|
|
args.conn_type,
|
|
args.page_size,
|
|
args.current,
|
|
args.created_at,
|
|
args.days_ago
|
|
)
|
|
print(json.dumps(result, indent=2))
|
|
|
|
elif args.command == "view-file":
|
|
# View file audits
|
|
result = view_file_audits(
|
|
args.url,
|
|
args.token,
|
|
args.remote,
|
|
args.page_size,
|
|
args.current,
|
|
args.created_at,
|
|
args.days_ago
|
|
)
|
|
print(json.dumps(result, indent=2))
|
|
|
|
elif args.command == "view-alarm":
|
|
# View alarm audits
|
|
result = view_alarm_audits(
|
|
args.url,
|
|
args.token,
|
|
args.device,
|
|
args.page_size,
|
|
args.current,
|
|
args.created_at,
|
|
args.days_ago
|
|
)
|
|
print(json.dumps(result, indent=2))
|
|
|
|
elif args.command == "view-console":
|
|
# View console audits
|
|
result = view_console_audits(
|
|
args.url,
|
|
args.token,
|
|
args.operator,
|
|
args.page_size,
|
|
args.current,
|
|
args.created_at,
|
|
args.days_ago
|
|
)
|
|
print(json.dumps(result, indent=2))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|