Initial commit: hello-agent — headless RustDesk-protocol-compatible Windows agent
build-windows / build-hello-agent-x64 (push) Successful in 5m41s
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:
Executable
+271
@@ -0,0 +1,271 @@
|
||||
#!/usr/bin/env bash
|
||||
# Provisions a Debian 13 (Trixie) container or VM as a Gitea Actions runner
|
||||
# that does Authenticode code signing for hello-agent via osslsigncode.
|
||||
#
|
||||
# Idempotent: safe to re-run. Does NOT generate or import the signing key —
|
||||
# operators do that out-of-band after provisioning. The script only sets up
|
||||
# the directory layout, ACLs, runner, and systemd sandbox.
|
||||
#
|
||||
# Designed for an unprivileged Incus/LXC container on a hardened host:
|
||||
# * No build toolchains. Smallest possible attack surface.
|
||||
# * Service unit is heavily sandboxed (Read*Only*Paths, NoNewPrivileges, …).
|
||||
# * Outbound network restriction is the LXC HOST's responsibility — the
|
||||
# container itself can't enforce it because nothing inside the namespace
|
||||
# is privileged enough to load nf_tables. Configure on the host.
|
||||
#
|
||||
# Usage:
|
||||
# sudo ./provision.sh \
|
||||
# --gitea-url https://gitea.example.com \
|
||||
# --runner-token <token>
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---- pinned versions (mirror .gitea/workflows/build-windows.yml where they overlap) ----
|
||||
RUNNER_VERSION="0.2.11"
|
||||
NODE_MAJOR="20" # act_runner spawns Node for JS actions (upload/download-artifact)
|
||||
|
||||
# ---- defaults ----
|
||||
RUNNER_NAME="$(hostname)-helloagent-sign"
|
||||
RUNNER_LABELS="self-hosted,linux,signing"
|
||||
SERVICE_USER="hello-signer"
|
||||
PKI_DIR="/etc/pki/hello-agent"
|
||||
GITEA_URL=""
|
||||
RUNNER_TOKEN=""
|
||||
|
||||
# ---- arg parse ----
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--gitea-url) GITEA_URL="$2"; shift 2 ;;
|
||||
--runner-token) RUNNER_TOKEN="$2"; shift 2 ;;
|
||||
--runner-name) RUNNER_NAME="$2"; shift 2 ;;
|
||||
--runner-labels) RUNNER_LABELS="$2"; shift 2 ;;
|
||||
--service-user) SERVICE_USER="$2"; shift 2 ;;
|
||||
-h|--help)
|
||||
sed -n '2,20p' "$0"
|
||||
exit 0 ;;
|
||||
*) echo "Unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ "$EUID" -eq 0 ]] || { echo "Run as root (use sudo)." >&2; exit 1; }
|
||||
[[ -n "$GITEA_URL" && -n "$RUNNER_TOKEN" ]] \
|
||||
|| { echo "Missing --gitea-url or --runner-token" >&2; exit 2; }
|
||||
|
||||
. /etc/os-release
|
||||
case "${ID}-${VERSION_ID:-}" in
|
||||
debian-13|debian-trixie) ;;
|
||||
*)
|
||||
echo "WARNING: tested only on Debian 13 (Trixie). You're on $PRETTY_NAME."
|
||||
sleep 3 ;;
|
||||
esac
|
||||
|
||||
log() { printf '\n==> %s\n' "$*"; }
|
||||
|
||||
# ---- 1. apt packages (deliberately minimal — no compilers on a signing host) ----
|
||||
log "Installing apt packages"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y --no-install-recommends \
|
||||
osslsigncode openssl ca-certificates \
|
||||
curl wget git \
|
||||
sudo gnupg
|
||||
|
||||
# Node.js (act_runner spawns node for JS actions like actions/download-artifact)
|
||||
if ! command -v node >/dev/null; then
|
||||
log "Installing Node.js ${NODE_MAJOR} LTS"
|
||||
curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash -
|
||||
apt-get install -y --no-install-recommends nodejs
|
||||
fi
|
||||
|
||||
# Sanity-check osslsigncode. Debian 13 ships 2.9, which has -ts (RFC 3161).
|
||||
ver="$(osslsigncode --version 2>&1 | awk '/^osslsigncode/ {print $2; exit}')"
|
||||
if [[ -z "$ver" ]]; then
|
||||
echo "could not parse osslsigncode version" >&2; exit 1
|
||||
fi
|
||||
log "osslsigncode $ver OK"
|
||||
|
||||
# ---- 2. dedicated runner user ----
|
||||
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
|
||||
log "Creating system user $SERVICE_USER"
|
||||
# No login shell on purpose: this user only runs systemd's exec, never logs in.
|
||||
useradd --system --create-home --shell /usr/sbin/nologin "$SERVICE_USER"
|
||||
fi
|
||||
RUNNER_HOME="$(getent passwd "$SERVICE_USER" | cut -d: -f6)"
|
||||
|
||||
# ---- 3. PKI directory ----
|
||||
# Layout:
|
||||
# /etc/pki/hello-agent/
|
||||
# chain.pem leaf || intermediate || root 0444 root:root
|
||||
# codesign.key PEM private key (or PKCS#11 stub) 0400 root:hello-signer
|
||||
#
|
||||
# Why root owns the key file but hello-signer can read it: prevents the
|
||||
# runner user from rewriting / deleting the key (rotate operations require
|
||||
# root), while still letting osslsigncode open it for signing.
|
||||
log "Preparing PKI directory at $PKI_DIR"
|
||||
install -d -m 0755 -o root -g root "$PKI_DIR"
|
||||
|
||||
# Touch stub files if they don't exist yet so systemd's ReadOnlyPaths
|
||||
# resolves cleanly on first start. Operator overwrites these post-provision.
|
||||
[[ -f "$PKI_DIR/chain.pem" ]] || install -m 0444 -o root -g root /dev/null "$PKI_DIR/chain.pem"
|
||||
[[ -f "$PKI_DIR/codesign.key" ]] || install -m 0400 -o root -g "$SERVICE_USER" /dev/null "$PKI_DIR/codesign.key"
|
||||
|
||||
# Re-assert ACLs unconditionally — defends against an operator copying files
|
||||
# in with overly-permissive umask.
|
||||
chmod 0755 "$PKI_DIR"
|
||||
chown root:root "$PKI_DIR/chain.pem"; chmod 0444 "$PKI_DIR/chain.pem"
|
||||
chown root:"$SERVICE_USER" "$PKI_DIR/codesign.key"; chmod 0400 "$PKI_DIR/codesign.key"
|
||||
|
||||
# ---- 4. act_runner ----
|
||||
RUNNER_DIR=/var/lib/gitea-runner
|
||||
mkdir -p "$RUNNER_DIR"
|
||||
chown -R "$SERVICE_USER:$SERVICE_USER" "$RUNNER_DIR"
|
||||
|
||||
if [[ ! -x "$RUNNER_DIR/act_runner" ]]; then
|
||||
log "Downloading act_runner $RUNNER_VERSION"
|
||||
case "$(uname -m)" in
|
||||
x86_64) arch_label="amd64" ;;
|
||||
aarch64) arch_label="arm64" ;;
|
||||
*) echo "Unsupported arch: $(uname -m)" >&2; exit 1 ;;
|
||||
esac
|
||||
curl -fsSL -o "$RUNNER_DIR/act_runner" \
|
||||
"https://gitea.com/gitea/act_runner/releases/download/v${RUNNER_VERSION}/act_runner-${RUNNER_VERSION}-linux-${arch_label}"
|
||||
chmod +x "$RUNNER_DIR/act_runner"
|
||||
chown "$SERVICE_USER:$SERVICE_USER" "$RUNNER_DIR/act_runner"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$RUNNER_DIR/.runner" ]]; then
|
||||
log "Registering runner with $GITEA_URL (labels: $RUNNER_LABELS)"
|
||||
sudo -u "$SERVICE_USER" -H bash -c "
|
||||
cd '$RUNNER_DIR' && \
|
||||
./act_runner register --no-interactive \
|
||||
--instance '$GITEA_URL' \
|
||||
--token '$RUNNER_TOKEN' \
|
||||
--name '$RUNNER_NAME' \
|
||||
--labels '$RUNNER_LABELS'
|
||||
"
|
||||
fi
|
||||
|
||||
# ---- 5. systemd unit (heavily sandboxed) ----
|
||||
#
|
||||
# Why these flags: the signing runner does almost nothing — pulls a PE file,
|
||||
# calls osslsigncode, uploads. So we can lock it down far more than the
|
||||
# rustdesk build runner.
|
||||
#
|
||||
# Notable omissions:
|
||||
# * NO MemoryDenyWriteExecute=yes — Node.js (V8 JIT) needs w+x mappings.
|
||||
# Action runners that invoke JS actions (download-artifact etc.) break
|
||||
# under MDWX. The other sandbox flags still cover the realistic
|
||||
# post-exploitation paths.
|
||||
# * PrivateDevices=yes is fine for software-key signing. If you migrate to
|
||||
# a USB hardware token (YubiKey via opensc-pkcs11), set PrivateDevices=no
|
||||
# and add a DeviceAllow= line for /dev/bus/usb/<bus>/<dev>.
|
||||
log "Installing systemd unit"
|
||||
cat > /etc/systemd/system/gitea-act-runner.service <<EOF
|
||||
[Unit]
|
||||
Description=Gitea Actions runner (hello-agent code signing)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${SERVICE_USER}
|
||||
WorkingDirectory=${RUNNER_DIR}
|
||||
ExecStart=${RUNNER_DIR}/act_runner daemon
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# --- sandbox ---
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectClock=yes
|
||||
ProtectHostname=yes
|
||||
RestrictNamespaces=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
SystemCallArchitectures=native
|
||||
SystemCallFilter=@system-service
|
||||
SystemCallFilter=~@privileged @resources @debug @mount @cpu-emulation @obsolete @raw-io @reboot @swap
|
||||
|
||||
# --- filesystem access ---
|
||||
ReadWritePaths=${RUNNER_DIR}
|
||||
ReadOnlyPaths=${PKI_DIR}
|
||||
|
||||
# --- network ---
|
||||
# Pull-mode runner: never binds. Disallow listening implicitly via
|
||||
# RestrictAddressFamilies (no AF_PACKET, etc.). The host firewall enforces
|
||||
# *which* outbound destinations are reachable.
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
|
||||
LimitNOFILE=65535
|
||||
TasksMax=512
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable gitea-act-runner.service
|
||||
systemctl restart gitea-act-runner.service
|
||||
|
||||
log "Done."
|
||||
cat <<EOF
|
||||
|
||||
----------------------------------------------------------------
|
||||
Next steps (manual, on this host):
|
||||
|
||||
1. Import the signing key + cert chain.
|
||||
|
||||
The provisioning intentionally does NOT pull these from anywhere —
|
||||
keys must move under operator control. Once you have them locally:
|
||||
|
||||
# Public chain (leaf || intermediate || root):
|
||||
install -m 0444 -o root -g root \\
|
||||
/path/to/chain.pem ${PKI_DIR}/chain.pem
|
||||
|
||||
# Private key:
|
||||
install -m 0400 -o root -g ${SERVICE_USER} \\
|
||||
/path/to/codesign.key ${PKI_DIR}/codesign.key
|
||||
|
||||
# Sanity-check the cert subject, EKU, and expiry:
|
||||
openssl x509 -in ${PKI_DIR}/chain.pem -noout \\
|
||||
-subject -enddate -ext extendedKeyUsage
|
||||
|
||||
Required: extendedKeyUsage MUST contain "Code Signing" and NOTHING ELSE.
|
||||
|
||||
2. Smoke-test signing as the runner user (uses an empty PE — fails fast
|
||||
but proves osslsigncode can read the key):
|
||||
|
||||
sudo -u ${SERVICE_USER} osslsigncode sign \\
|
||||
-certs ${PKI_DIR}/chain.pem \\
|
||||
-key ${PKI_DIR}/codesign.key \\
|
||||
-h sha256 \\
|
||||
-in /usr/bin/osslsigncode -out /tmp/signtest.exe \\
|
||||
&& echo "OK: signing key reachable" \\
|
||||
|| echo "FAIL: check perms and PEM format"
|
||||
|
||||
3. Confirm the runner came online:
|
||||
|
||||
systemctl status gitea-act-runner
|
||||
journalctl -u gitea-act-runner -n 50 --no-pager
|
||||
# Then check ${GITEA_URL} > Site Admin > Actions > Runners
|
||||
# for "${RUNNER_NAME}" with labels "${RUNNER_LABELS}"
|
||||
|
||||
4. Lock the LXC HOST firewall down. Outbound from the container should
|
||||
reach ONLY:
|
||||
- your Gitea instance (HTTPS, your Gitea host)
|
||||
- the RFC 3161 timestamp authority (HTTP, e.g. timestamp.digicert.com)
|
||||
- apt + node mirrors (HTTPS, only during provisioning;
|
||||
revoke after first successful run)
|
||||
Drop all inbound. Configure on the host (nftables / Proxmox firewall /
|
||||
Incus proxy device) — the container can't enforce this on itself.
|
||||
|
||||
----------------------------------------------------------------
|
||||
EOF
|
||||
Reference in New Issue
Block a user