11 Commits

Author SHA1 Message Date
mike 8de2ebea85 Implement performance monitor
build-windows / build-hello-agent-x64 (push) Successful in 5m0s
build-windows / sign-hello-agent-x64 (push) Successful in 5s
build-windows / validate-hello-agent-x64 (push) Successful in 7s
2026-05-22 21:52:13 +02:00
mike f868efa432 Implement user login logging
build-windows / build-hello-agent-x64 (push) Successful in 4m56s
build-windows / sign-hello-agent-x64 (push) Successful in 5s
build-windows / validate-hello-agent-x64 (push) Successful in 6s
2026-05-22 20:08:24 +02:00
mike 6bdf1058fa Implement remote execution
build-windows / build-hello-agent-x64 (push) Successful in 5m2s
build-windows / sign-hello-agent-x64 (push) Successful in 5s
build-windows / validate-hello-agent-x64 (push) Successful in 6s
2026-05-22 14:18:25 +02:00
mike 6807fe2bc0 Implement signed API communication to improve security
build-windows / build-hello-agent-x64 (push) Successful in 4m52s
build-windows / sign-hello-agent-x64 (push) Successful in 5s
build-windows / validate-hello-agent-x64 (push) Successful in 6s
2026-05-22 13:13:05 +02:00
mike fb00ac1101 Implement software inventory
build-windows / build-hello-agent-x64 (push) Successful in 5m20s
build-windows / sign-hello-agent-x64 (push) Successful in 5s
build-windows / validate-hello-agent-x64 (push) Successful in 6s
2026-05-21 23:55:20 +02:00
mike 8cff0c1863 Implement auto-update routine
build-windows / build-hello-agent-x64 (push) Successful in 5m7s
build-windows / sign-hello-agent-x64 (push) Successful in 5s
build-windows / validate-hello-agent-x64 (push) Successful in 6s
2026-05-21 23:25:53 +02:00
mike d10e547b70 Update README.md
build-windows / build-hello-agent-x64 (push) Successful in 6m5s
build-windows / sign-hello-agent-x64 (push) Successful in 5s
build-windows / validate-hello-agent-x64 (push) Successful in 7s
2026-05-09 14:25:27 +02:00
mike 8025f8558a Fix asset inventory update
build-windows / build-hello-agent-x64 (push) Successful in 6m6s
build-windows / sign-hello-agent-x64 (push) Successful in 6s
build-windows / validate-hello-agent-x64 (push) Successful in 7s
2026-05-09 11:32:12 +02:00
mike e815776329 Fix file-transfer
build-windows / build-hello-agent-x64 (push) Successful in 6m7s
build-windows / sign-hello-agent-x64 (push) Successful in 6s
build-windows / validate-hello-agent-x64 (push) Successful in 7s
2026-05-09 10:53:41 +02:00
mike b59be25a16 Implement asset inventory 2026-05-09 00:59:34 +02:00
mike a2c79e56d3 split builder and signer provision scripts for Gitea CI 2026-05-08 22:28:24 +02:00
29 changed files with 4374 additions and 418 deletions
+6 -3
View File
@@ -2,13 +2,13 @@ name: build-windows
on: on:
push: push:
branches: [main, master] branches: [pro-features]
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version_suffix: version_suffix:
description: "Version suffix (e.g. 'cst', 'beta1'). Empty = vanilla." description: "Version suffix (e.g. 'cst', 'beta1'). Empty = vanilla."
type: string type: string
default: "" default: "cst"
# Workflow-level env is visible to every job. Runner-specific paths # Workflow-level env is visible to every job. Runner-specific paths
# (VCPKG_ROOT, LLVM_HOME, …) live on the build-x64 job instead, since the # (VCPKG_ROOT, LLVM_HOME, …) live on the build-x64 job instead, since the
@@ -125,7 +125,10 @@ jobs:
id: version id: version
shell: pwsh shell: pwsh
env: env:
VERSION_SUFFIX: ${{ inputs.version_suffix }} # On push events `inputs.*` is empty — the workflow_dispatch default
# ("cst") doesn't apply. Fall back to "cst" in-script so push and
# dispatch produce the same default tag shape.
VERSION_SUFFIX: ${{ inputs.version_suffix || 'cst' }}
run: | run: |
$base = (Select-String -Path Cargo.toml -Pattern '^version = "([^"]+)"').Matches[0].Groups[1].Value $base = (Select-String -Path Cargo.toml -Pattern '^version = "([^"]+)"').Matches[0].Groups[1].Value
if (-not $base) { throw "could not parse version from Cargo.toml" } if (-not $base) { throw "could not parse version from Cargo.toml" }
Generated
+3 -1
View File
@@ -3197,13 +3197,15 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "hello-agent" name = "hello-agent"
version = "0.1.0" version = "0.1.7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"env_logger 0.10.2", "env_logger 0.10.2",
"hbb_common", "hbb_common",
"log", "log",
"rustdesk", "rustdesk",
"serde 1.0.228",
"serde_json 1.0.118",
"tokio", "tokio",
"winapi", "winapi",
"windows-service", "windows-service",
+15 -3
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "hello-agent" name = "hello-agent"
version = "0.1.0" version = "0.1.7"
edition = "2021" edition = "2021"
rust-version = "1.75" rust-version = "1.75"
description = "Headless RustDesk-protocol-compatible support agent for Windows" description = "Headless RustDesk-protocol-compatible support agent for Windows"
@@ -24,14 +24,26 @@ path = "src/main.rs"
librustdesk = { package = "rustdesk", path = "vendor/rustdesk", default-features = false, features = ["use_dasp", "hwcodec", "vram"] } librustdesk = { package = "rustdesk", path = "vendor/rustdesk", default-features = false, features = ["use_dasp", "hwcodec", "vram"] }
hbb_common = { path = "vendor/rustdesk/libs/hbb_common" } hbb_common = { path = "vendor/rustdesk/libs/hbb_common" }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "io-util"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "io-util", "process"] }
log = "0.4" log = "0.4"
env_logger = "0.10" env_logger = "0.10"
anyhow = "1" anyhow = "1"
# Used by `inventory.rs` to validate the PowerShell-produced JSON before
# we stamp it into the sysinfo upload. hbb_common already pulls serde_json
# transitively, so this is a free re-export — listed explicitly here so
# the inventory module's `use serde_json` doesn't depend on internal
# implementation details of hbb_common.
serde_json = "1"
# `perf_events.rs` derives Deserialize on the PowerShell row schema.
# hbb_common re-exports `serde_derive` and `serde_json` but NOT `serde`
# itself — and `#[derive(Deserialize)]` expands to a path that references
# the `serde` crate root, so we depend on it explicitly with the `derive`
# feature.
serde = { version = "1", features = ["derive"] }
[target.'cfg(target_os = "windows")'.dependencies] [target.'cfg(target_os = "windows")'.dependencies]
windows-service = "0.6" windows-service = "0.6"
winapi = { version = "0.3", features = ["winuser", "wtsapi32", "processthreadsapi", "synchapi", "handleapi", "winbase"] } winapi = { version = "0.3", features = ["winuser", "wtsapi32", "processthreadsapi", "synchapi", "handleapi", "winbase", "wlanapi", "wlantypes"] }
winreg = "0.11" winreg = "0.11"
# Embed the icon and EXE metadata via the Windows resource compiler. # Embed the icon and EXE metadata via the Windows resource compiler.
+178 -31
View File
@@ -42,9 +42,13 @@ empty, so `--config` always wins.
``` ```
hello-agent.exe --install hello-agent.exe --install
└──> creates Windows service "HelloAgent", binPath ends in --service └──> creates Windows service "hello-agent", binPath ends in --service
hello-agent.exe --service # Session 0, LocalSystem hello-agent.exe --service # Session 0, LocalSystem
├── unattended_password::rotate_and_report (background thread)
│ └─ POSTs per-boot password to <api-server>/api/unattended-password
│ with retry until ack — races rendezvous registration
└──> spawns into the active console session as SYSTEM token: └──> spawns into the active console session as SYSTEM token:
@@ -53,6 +57,24 @@ hello-agent.exe --server # user session, SYSTEM token
├── default ipc listener (rustdesk core) ├── default ipc listener (rustdesk core)
├── RendezvousMediator ──> rustdesk-server registration + NAT ├── RendezvousMediator ──> rustdesk-server registration + NAT
├── hbbs_http::sync ──> /api/heartbeat + /api/sysinfo ├── hbbs_http::sync ──> /api/heartbeat + /api/sysinfo
│ └─ stamps `agent_name` / `agent_version` / `inventory`
│ into each /api/sysinfo payload (re-uploads when the
│ inventory collector below transitions empty → ready)
│ └─ signs every request with the device's Ed25519 sk
│ (same key rendezvous registers via RegisterPk).
│ The server's first valid sig flips that peer to
│ `managed=1` and unsigned posts get 401 from then on.
│ Spec: rustdesk-server/docs/AGENT-API-AUTH.md
├── exec::run_loop (background thread)
│ └─ subscribes to sync.rs's EXEC_SENDER broadcast; for
│ each queued PowerShell command runs `powershell.exe
│ -NoProfile -NonInteractive -Command -`, captures
│ stdout+stderr with 1 MiB cap & 5-min timeout, POSTs
│ signed result to /api/agent/exec-result. Idle unless
│ an admin dispatches via the dashboard.
├── inventory::collect_inventory (background thread)
│ └─ PowerShell + WMI + wlanapi + ipify → `INVENTORY` global
│ consumed by hbbs_http::sync above; one-shot, no retry
│ at startup, --server proactively spawns (via WTSQueryUserToken │ at startup, --server proactively spawns (via WTSQueryUserToken
│ + CreateProcessAsUserW with lpDesktop = winsta0\default — │ + CreateProcessAsUserW with lpDesktop = winsta0\default —
@@ -78,35 +100,140 @@ Flutter Connection Manager.
``` ```
hello-agent/ hello-agent/
├── src/ hello-agent sources (~600 lines) ├── src/ hello-agent sources (~2400 lines)
├── vendor/rustdesk/ vendored RustDesk crate + workspace libs ├── vendor/rustdesk/ vendored RustDesk crate + workspace libs
│ ├── Cargo.toml rustdesk's own workspace + package manifest │ ├── Cargo.toml rustdesk's own workspace + package manifest
│ ├── src/ librustdesk source │ ├── src/ librustdesk source
│ └── libs/ hbb_common, scrap, enigo, clipboard, … │ └── libs/ hbb_common, scrap, enigo, clipboard, …
├── Cargo.toml hello-agent package manifest, path-deps on vendor ├── Cargo.toml hello-agent package manifest, path-deps on vendor
├── ci/ provision scripts for the Gitea Windows-build /
│ Linux-signing self-hosted runners
├── .gitea/workflows/ Gitea CI ├── .gitea/workflows/ Gitea CI
└── README.md └── README.md
``` ```
The vendored source has a few local divergences from upstream — all The vendored source has a handful of local divergences from upstream.
documented inline at the patch site so they're easy to spot when Most patch sites carry an inline `hello-agent` breadcrumb (search for
re-syncing: `grep -rn "hello-agent" vendor/rustdesk/` in a tree where the patches
have been applied) but a couple of one-token edits like
`pub mod custom_server` don't, so the list below is the authoritative
inventory — keep it in sync when adding new patches.
1. [`vendor/rustdesk/src/lib.rs`](vendor/rustdesk/src/lib.rs): 1. **Module visibility**
`mod custom_server``pub mod custom_server` so hello-agent can call [`vendor/rustdesk/src/lib.rs`](vendor/rustdesk/src/lib.rs):
the deploy-blob decoder. * `mod custom_server``pub mod custom_server` so hello-agent's
2. [`vendor/rustdesk/Cargo.toml`](vendor/rustdesk/Cargo.toml): `config_import` can call the deploy-blob decoder.
* `mod ui_cm_interface``pub mod ui_cm_interface` so the headless
`--cm` process can plug a `MessageBoxW`-based `InvokeUiCM` into
upstream's connection-manager IPC loop and inherit file-transfer,
chat, and clipboard handling rather than re-implementing them.
* `mod hbbs_http``pub mod hbbs_http` so hello-agent's
`unattended_password::try_report` and `exec::run_loop` can reach
`librustdesk::hbbs_http::sign::build_signed_headers` and
`librustdesk::hbbs_http::sync::exec_signal_receiver`. Without
this the in-crate code can't sign / can't subscribe to the
server's queued PowerShell commands, and the build fails with
`E0603: module 'hbbs_http' is private`. Tightly coupled to the
**Signed agent API** and **Remote PowerShell exec** divergences
below.
2. **Build shape** — [`vendor/rustdesk/Cargo.toml`](vendor/rustdesk/Cargo.toml):
`[lib] crate-type` reduced from `["cdylib", "staticlib", "rlib"]` to `[lib] crate-type` reduced from `["cdylib", "staticlib", "rlib"]` to
`["rlib"]`. We statically link the rlib into hello-agent.exe; the `["rlib"]`. We statically link the rlib into hello-agent.exe; the
cdylib link step (used by upstream for Flutter FFI) trips cdylib link step (used by upstream for Flutter FFI) trips
`LNK1169 multiply-defined symbols` from overlapping `LNK1169 multiply-defined symbols` from overlapping
windows-targets/windows_x86_64_msvc versions and we don't need it. windows-targets/windows_x86_64_msvc versions and we don't need it.
3. Heartbeat intervals lowered 15s → 1s so device-online status in the 3. **Heartbeat cadence** lowered 15s → 1s so device-online status in
admin UI reacts faster: the admin UI reacts faster:
[`vendor/rustdesk/libs/hbb_common/src/config.rs`](vendor/rustdesk/libs/hbb_common/src/config.rs) [`libs/hbb_common/src/config.rs`](vendor/rustdesk/libs/hbb_common/src/config.rs)
(`REG_INTERVAL`, UDP rendezvous re-register) and (`REG_INTERVAL`, UDP rendezvous re-register) and
[`vendor/rustdesk/src/hbbs_http/sync.rs`](vendor/rustdesk/src/hbbs_http/sync.rs) [`src/hbbs_http/sync.rs`](vendor/rustdesk/src/hbbs_http/sync.rs)
(`TIME_HEARTBEAT`, HTTP `/api/heartbeat`). (`TIME_HEARTBEAT`, HTTP `/api/heartbeat`).
4. **Sysinfo upload extensions**
[`libs/hbb_common/src/config.rs`](vendor/rustdesk/libs/hbb_common/src/config.rs)
adds three opt-in `RwLock<String>` globals — `AGENT_NAME`,
`AGENT_VERSION`, `INVENTORY` — that hello-agent populates at startup
(rebrand identity) and asynchronously (CMDB inventory).
[`src/hbbs_http/sync.rs`](vendor/rustdesk/src/hbbs_http/sync.rs)
reads them when each `/api/sysinfo` payload is constructed and
tracks `had_inventory` on the `InfoUploaded` state so the loop
re-uploads when `INVENTORY` transitions empty → populated (the
collector is async and routinely loses the race against the first
sysinfo tick).
5. **Documentation / branding URLs** retargeted from `rustdesk.com` to
`cstudio.ch/hello-agent/`:
[`libs/hbb_common/src/config.rs`](vendor/rustdesk/libs/hbb_common/src/config.rs)
(`LINK_DOCS_HOME`, `LINK_DOCS_X11_REQUIRED`),
[`src/client.rs`](vendor/rustdesk/src/client.rs) (`SCRAP_X11_REF_URL`,
login-screen help link). Plus author / copyright strings in
[`Cargo.toml`](vendor/rustdesk/Cargo.toml) (`LegalCopyright`) and
[`src/main.rs`](vendor/rustdesk/src/main.rs) (`.author(...)`).
Cosmetic, but they show through in the Windows EXE metadata and
in-app error dialogs.
6. **Signed agent API** — every `POST /api/heartbeat`,
`POST /api/sysinfo`, and `POST /api/unattended-password` carries
two extra headers (`X-RD-Device-Id`,
`X-RD-Signature: v1.<ts>.<base64-ed25519-sig>`) so the server can
bind the request to the device's existing rendezvous keypair
instead of trusting the `id` + `uuid` body fields. Without this
patch, anyone who knows a peer's id and uuid can inject inventory,
heartbeats, and unattended-access passwords for it. Three patch
sites in the vendor tree (plus one in the hello-agent crate):
* New file
[`src/hbbs_http/sign.rs`](vendor/rustdesk/src/hbbs_http/sign.rs) —
the signer (`build_signed_headers`, `path_from_url`). Reads
`Config::get_key_pair()` and `Config::get_id()`; uses the
re-exported `hbb_common::sodiumoxide`.
* [`src/hbbs_http.rs`](vendor/rustdesk/src/hbbs_http.rs) — adds
`pub mod sign;` next to the existing module declarations.
* [`src/common.rs`](vendor/rustdesk/src/common.rs) — the
`post_request_` and `parse_simple_header` header-string parsers
now accept a `\n`-separated list of `Name: Value` lines so we
can pass both signing headers in one call. Old single-pair
callers parse identically — there's no newline to split on.
* [`src/hbbs_http/sync.rs`](vendor/rustdesk/src/hbbs_http/sync.rs)
call sites (the sysinfo POST around the sysinfo-version
comparison block, and the heartbeat POST a few dozen lines
later) — both build a signed-headers string via
`crate::hbbs_http::sign::build_signed_headers("POST",
&path_from_url(&url), body.as_bytes()).unwrap_or_default()`
and pass it to `post_request` instead of `""`.
And in the hello-agent crate proper (not the vendor tree, no
re-sync concern):
* [`src/unattended_password.rs`](src/unattended_password.rs) —
`try_report` also signs its `POST /api/unattended-password`
via `librustdesk::hbbs_http::sign::build_signed_headers`.
Matching server side: see rustdesk-server's
[`docs/AGENT-API-AUTH.md`](https://github.com/cstudio-ch/rustdesk-server/blob/pro-features/docs/AGENT-API-AUTH.md)
for the wire format and verification flow.
7. **Remote PowerShell exec** — the dashboard can queue a PowerShell
script for a managed peer; the agent runs it as its service account
and POSTs the result back. Gated server-side on admin role +
`peer.managed=1` + strategy `enable-remote-exec=Y`. Vendor-tree
patches:
* [`src/hbbs_http/sync.rs`](vendor/rustdesk/src/hbbs_http/sync.rs) —
new `EXEC_SENDER` broadcast channel, new `ExecRequest` type, new
`pub fn exec_signal_receiver()` helper, and the heartbeat-reply
parser drains the `exec: [...]` field into the channel. Vanilla
rustdesk simply has no subscriber — the channel send errors out
with NoReceivers and the requests are dropped silently.
In the hello-agent crate:
* [`src/exec.rs`](src/exec.rs) — the PowerShell runner. Subscribes
to the broadcast channel above, spawns
`powershell.exe -NoProfile -NonInteractive -ExecutionPolicy
Bypass -Command -`, writes the script to stdin, captures
stdout+stderr with 1 MiB cap and a 5-minute wall-clock timeout,
signs and POSTs the result to `/api/agent/exec-result`. Started
from `run_server()` in [`src/main.rs`](src/main.rs) (must live in
the `--server` process to share the broadcast channel with
sync.rs).
* [`Cargo.toml`](Cargo.toml) — adds `process` to tokio's feature
list for `tokio::process::Command`.
Server-side spec: see [`docs/AGENT-API-AUTH.md`](https://github.com/cstudio-ch/rustdesk-server/blob/pro-features/docs/AGENT-API-AUTH.md)
§*Remote PowerShell exec*.
## Build ## Build
@@ -138,14 +265,19 @@ To pull updates from upstream RustDesk:
1. Sync the upstream rustdesk repo locally and `git submodule update --init` for `libs/hbb_common`. 1. Sync the upstream rustdesk repo locally and `git submodule update --init` for `libs/hbb_common`.
2. `rsync -a --delete --exclude=.git --exclude=target --exclude=flutter --exclude=appimage … upstream-rustdesk/ vendor/rustdesk/` 2. `rsync -a --delete --exclude=.git --exclude=target --exclude=flutter --exclude=appimage … upstream-rustdesk/ vendor/rustdesk/`
3. Re-apply the one-line `pub mod custom_server` patch in 3. Re-apply the local divergences enumerated in the
[`vendor/rustdesk/src/lib.rs`](vendor/rustdesk/src/lib.rs). [Repo layout](#repo-layout) section above (that list is the source
of truth — most patches carry a `hello-agent` breadcrumb in a
nearby comment, but a couple of one-token edits like
`pub mod custom_server` do not). The reliable recipe is to diff
against the upstream rev you rsync'd from before you do the rsync,
stash the patch hunks, then re-apply them on top of the new tree.
4. `cargo build --release --bin hello-agent` — fix any breakage from 4. `cargo build --release --bin hello-agent` — fix any breakage from
upstream API drift in our [src/](src/) modules. upstream API drift in our [src/](src/) modules.
## Stale keys / supporter "stuck on connecting" ## Stale keys / supporter "stuck on connecting"
The agent's identity (`id`) and `key_pair` live in `HelloAgent.toml`. The agent's identity (`id`) and `key_pair` live in `hello-agent.toml`.
They're generated once on first run, registered with the rendezvous They're generated once on first run, registered with the rendezvous
server, and re-used forever after. **If the rendezvous server's cached server, and re-used forever after. **If the rendezvous server's cached
entry and the agent's local keypair drift apart, the encrypted handshake entry and the agent's local keypair drift apart, the encrypted handshake
@@ -175,7 +307,7 @@ dir so the agent keypair survives an uninstall→reinstall cycle. To force
a fresh keypair, also run after `--uninstall`: a fresh keypair, also run after `--uninstall`:
``` ```
rmdir /s /q "%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\HelloAgent" rmdir /s /q "%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\hello-agent"
``` ```
…and then delete the device record from the admin UI as above. …and then delete the device record from the admin UI as above.
@@ -183,7 +315,7 @@ rmdir /s /q "%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\HelloAgen
## Verifying end-to-end ## Verifying end-to-end
1. Install: `hello-agent.exe --install --config <BLOB>` from elevated PowerShell. 1. Install: `hello-agent.exe --install --config <BLOB>` from elevated PowerShell.
2. Confirm: `sc query HelloAgent``RUNNING`. 2. Confirm: `sc query hello-agent``RUNNING`.
3. From another machine running stock `rustdesk.exe`, enter the agent's 3. From another machine running stock `rustdesk.exe`, enter the agent's
ID and click Connect. ID and click Connect.
4. The agent's logged-in user sees `HelloAgent — Allow remote support?`. 4. The agent's logged-in user sees `HelloAgent — Allow remote support?`.
@@ -195,7 +327,7 @@ rmdir /s /q "%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\HelloAgen
`hbb_common` ships a single global, `APP_NAME`, that drives the location `hbb_common` ships a single global, `APP_NAME`, that drives the location
of every piece of on-disk state (config dir, log dir) and the prefix of of every piece of on-disk state (config dir, log dir) and the prefix of
every named pipe. Upstream defaults it to `"RustDesk"`. Hello-agent every named pipe. Upstream defaults it to `"RustDesk"`. Hello-agent
rewrites it to `"HelloAgent"` as the very first line of `main()` rewrites it to `"hello-agent"` as the very first line of `main()`
identical to the write path the upstream Flutter build uses for OEM identical to the write path the upstream Flutter build uses for OEM
rebrands ([`read_custom_client`](vendor/rustdesk/src/common.rs)). Because rebrands ([`read_custom_client`](vendor/rustdesk/src/common.rs)). Because
`APP_NAME` is a `RwLock<String>` read lazily on first use, doing the `APP_NAME` is a `RwLock<String>` read lazily on first use, doing the
@@ -206,16 +338,16 @@ In practice that means:
| What | Stock rustdesk | hello-agent | | What | Stock rustdesk | hello-agent |
| --------------------------------- | ----------------------------------------- | ------------------------------------------------- | | --------------------------------- | ----------------------------------------- | ------------------------------------------------- |
| User-mode config / logs | `%APPDATA%\RustDesk\` | `%APPDATA%\HelloAgent\` | | User-mode config / logs | `%APPDATA%\RustDesk\` | `%APPDATA%\hello-agent\` |
| Service-mode config / logs | `…\LocalService\AppData\Roaming\RustDesk\`| `…\LocalService\AppData\Roaming\HelloAgent\` | | Service-mode config / logs | `…\LocalService\AppData\Roaming\RustDesk\`| `…\LocalService\AppData\Roaming\hello-agent\` |
| Identity file (id + keypair) | `RustDesk.toml` | `HelloAgent.toml` | | Identity file (id + keypair) | `RustDesk.toml` | `hello-agent.toml` |
| IPC pipe namespace | `\\.\pipe\RustDesk\query…` | `\\.\pipe\HelloAgent\query…` | | IPC pipe namespace | `\\.\pipe\RustDesk\query…` | `\\.\pipe\hello-agent\query…` |
| Windows service name | `RustDesk` | `HelloAgent` | | Windows service name | `RustDesk` | `hello-agent` |
| Install dir | `%ProgramFiles%\RustDesk\` | `%ProgramFiles%\hello-agent\` | | Install dir | `%ProgramFiles%\RustDesk\` | `%ProgramFiles%\hello-agent\` |
The two binaries can therefore coexist on the same machine without The two binaries can therefore coexist on the same machine without
clobbering each other's state. The override is set in clobbering each other's state. The override is set in
[`src/main.rs`](src/main.rs) (`pub const APP_NAME: &str = "HelloAgent"`) [`src/main.rs`](src/main.rs) (`pub const APP_NAME: &str = "hello-agent"`)
— change it there if you ever need to re-brand. — change it there if you ever need to re-brand.
## Where logs go ## Where logs go
@@ -224,10 +356,10 @@ clobbering each other's state. The override is set in
| Mode (CLI flag) | Effective user | Log dir | | Mode (CLI flag) | Effective user | Log dir |
| --------------------- | ------------------------------- | ---------------------------------------------------------------------------------------- | | --------------------- | ------------------------------- | ---------------------------------------------------------------------------------------- |
| `--install` / `--uninstall` | calling user (must be admin) | `%APPDATA%\HelloAgent\log\install\` (or `…\uninstall\`) | | `--install` / `--uninstall` | calling user (must be admin) | `%APPDATA%\hello-agent\log\install\` (or `…\uninstall\`) |
| `--service` | LocalSystem (mirrored) | `%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\HelloAgent\log\service\` | | `--service` | LocalSystem (mirrored) | `%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\hello-agent\log\service\` |
| `--server` (worker) | LocalSystem (mirrored) | `%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\HelloAgent\log\server\` | | `--server` (worker) | LocalSystem (mirrored) | `%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\hello-agent\log\server\` |
| no flags (dev mode) | calling user | `%APPDATA%\HelloAgent\log\hello-agent\` | | no flags (dev mode) | calling user | `%APPDATA%\hello-agent\log\hello-agent\` |
The `cm_popup` module also writes a parallel diagnostic trace at The `cm_popup` module also writes a parallel diagnostic trace at
`%TEMP%\hello-agent-cm.log` (kept around for debugging the IPC handshake; `%TEMP%\hello-agent-cm.log` (kept around for debugging the IPC handshake;
@@ -238,12 +370,27 @@ it duplicates info that's already in the main log).
- ✅ Windows x64 (physical console *and* RDP sessions — the agent picks - ✅ Windows x64 (physical console *and* RDP sessions — the agent picks
whichever session the user is actively using) whichever session the user is actively using)
- ✅ Coexists with stock RustDesk on the same box — config dir, log dir, - ✅ Coexists with stock RustDesk on the same box — config dir, log dir,
and named pipes are namespaced under `HelloAgent` rather than the and named pipes are namespaced under `hello-agent` rather than the
upstream default of `RustDesk` (see [Namespacing](#namespacing) below). upstream default of `RustDesk` (see [Namespacing](#namespacing) below).
The only residual contention is the optional direct-server port The only residual contention is the optional direct-server port
(TCP 21118) and LAN-discovery port (UDP 21119); both default to off, (TCP 21118) and LAN-discovery port (UDP 21119); both default to off,
so a vanilla install of each side can run simultaneously. so a vanilla install of each side can run simultaneously.
- ✅ CMDB asset inventory — BIOS serial, manufacturer, model, AD domain,
OS edition + release, CPU, RAM, disks, BitLocker recovery key,
network interfaces, public IP, current + nearby Wi-Fi networks.
Collected once at startup ([`src/inventory.rs`](src/inventory.rs),
[`src/wifi_native.rs`](src/wifi_native.rs)) and merged into the
`/api/sysinfo` payload under the `inventory` key, where the
rustdesk-server admin UI's per-device detail page reads it.
- ✅ Unattended-access password — rotated once per service start
([`src/unattended_password.rs`](src/unattended_password.rs)) and
reported to rustdesk-server's `/api/unattended-password` so the admin
UI can show the current per-boot password for headless /
no-user-logged-in support sessions.
- ✅ Authenticode code signing in CI — separate Linux signing job runs
`osslsigncode` against the cStudio CA, with pre-/post-sign SHA-256
audit hashes that catch a tampered transit between build and sign
runners ([`.gitea/workflows/build-windows.yml`](.gitea/workflows/build-windows.yml)).
- ⏳ Linux / macOS (out of scope for v0) - ⏳ Linux / macOS (out of scope for v0)
- ⏳ Code signing (CI warns, doesn't sign)
- ⏳ Multiple simultaneous interactive users (only one can receive the - ⏳ Multiple simultaneous interactive users (only one can receive the
approval popup at a time — the one in the `WTSActive` session) approval popup at a time — the one in the `WTSActive` session)
+10
View File
@@ -19,6 +19,16 @@
fn main() { fn main() {
println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=resources/icon.ico"); println!("cargo:rerun-if-changed=resources/icon.ico");
// winres derives FileVersion / ProductVersion from `CARGO_PKG_VERSION`,
// which is sourced from `Cargo.toml`. Without this directive, cargo
// happily skips the build script when only the version field changed
// (build.rs and the .ico are byte-identical), and the resulting EXE
// ships with stale "FileVersion" / "ProductVersion" properties — the
// binary itself is the new build, but Explorer's Properties dialog
// and any Authenticode tooling that reads the resource block see the
// previous version. Forcing a re-run on Cargo.toml changes is cheap
// (winres compile is sub-second) and bulletproof.
println!("cargo:rerun-if-changed=Cargo.toml");
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if target_os != "windows" { if target_os != "windows" {
+264
View File
@@ -0,0 +1,264 @@
#!/usr/bin/env bash
# Provisions an Ubuntu 22.04 LTS or Debian 13 (Trixie) host as a Gitea Actions
# runner for RustDesk desktop (.deb) builds. Idempotent: safe to re-run.
#
# Versions are pinned to .gitea/workflows/build-linux.yml. Bump them there and
# here together.
#
# Build host vs. user host: the resulting .deb links against the host's glibc.
# Build on the OLDEST distro your users have, otherwise the .deb won't install
# on older systems.
# - Ubuntu 22.04 build -> runs on Ubuntu 22.04+, Debian 12+, derivatives
# - Debian 13 build -> runs on Debian 13+, Ubuntu 24.04+ only
#
# Usage:
# sudo ./provision.sh \
# --gitea-url https://gitea.example.com \
# --runner-token <token>
#
# All toolchains land in /opt and are readable by the gitea-runner user.
# Service is installed as a systemd unit running as that user.
set -euo pipefail
# ---- pinned versions (mirror .gitea/workflows/build-linux.yml env block) ----
RUST_VERSION="1.75.0"
FLUTTER_VERSION="3.24.5" # used for `flutter build linux`
FLUTTER_BRIDGE_VERSION="3.22.3" # used for `flutter pub get` + flutter_rust_bridge_codegen
LLVM_VERSION="15.0.6"
VCPKG_COMMIT="120deac3062162151622ca4860575a33844ba10b"
RUNNER_VERSION="0.2.11"
# ---- defaults ----
RUNNER_NAME="$(hostname)-rustdesk"
RUNNER_LABELS="" # auto-derived from /etc/os-release if empty
SERVICE_USER="gitea-runner"
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,18p' "$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
ubuntu-22.04) DISTRO_LABEL="ubuntu-22.04" ;;
debian-13|debian-trixie) DISTRO_LABEL="debian-13" ;;
*)
echo "WARNING: tested only on Ubuntu 22.04 and Debian 13. You're on $PRETTY_NAME."
echo "Package names may differ; build outputs may not run on user systems."
DISTRO_LABEL="${ID}-${VERSION_ID:-unknown}"
sleep 3 ;;
esac
# If --runner-labels wasn't passed, derive a sensible default that includes the
# detected distro so workflows can target a specific build host when needed.
if [[ -z "$RUNNER_LABELS" ]]; then
RUNNER_LABELS="${DISTRO_LABEL},self-hosted,X64,Linux"
fi
log() { printf '\n==> %s\n' "$*"; }
# ---- 1. apt packages ----
log "Installing apt packages"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y --no-install-recommends \
build-essential clang gcc g++ cmake ninja-build pkg-config nasm yasm \
autoconf automake libtool libtool-bin \
libclang-dev llvm-dev \
libgtk-3-dev libayatana-appindicator3-dev \
libasound2-dev libpulse-dev libpam0g-dev libssl-dev \
libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \
libva-dev libxdo-dev libxfixes-dev \
libxcb-randr0-dev libxcb-shape0-dev libxcb-xfixes0-dev \
git curl wget zip unzip tar xz-utils ca-certificates \
python3 python3-pip \
rpm tree dpkg-dev sudo
# Node.js (act_runner spawns node for JS actions like actions/checkout)
if ! command -v node >/dev/null; then
log "Installing Node.js LTS"
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y --no-install-recommends nodejs
fi
# ---- 2. LLVM (binary tarball; libclang-15-dev was dropped from Debian 13) ----
LLVM_DIR="/opt/llvm-${LLVM_VERSION}"
if [[ ! -x "$LLVM_DIR/bin/clang" ]]; then
log "Installing LLVM $LLVM_VERSION (binary tarball)"
arch="$(uname -m)"
case "$arch" in
x86_64) llvm_arch="x86_64-linux-gnu-ubuntu-18.04" ;;
aarch64) llvm_arch="aarch64-linux-gnu" ;;
*) echo "Unsupported arch for LLVM tarball: $arch" >&2; exit 1 ;;
esac
tmp="$(mktemp -d)"
curl -fsSL -o "$tmp/llvm.tar.xz" \
"https://github.com/llvm/llvm-project/releases/download/llvmorg-${LLVM_VERSION}/clang+llvm-${LLVM_VERSION}-${llvm_arch}.tar.xz"
mkdir -p "$LLVM_DIR"
tar --strip-components=1 -xJf "$tmp/llvm.tar.xz" -C "$LLVM_DIR"
rm -rf "$tmp"
fi
# ---- 3. dedicated runner user ----
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
log "Creating user $SERVICE_USER"
useradd --system --create-home --shell /bin/bash "$SERVICE_USER"
fi
RUNNER_HOME="$(getent passwd "$SERVICE_USER" | cut -d: -f6)"
# ---- 4. Rust (machine-wide) ----
export RUSTUP_HOME=/opt/rustup
export CARGO_HOME=/opt/cargo
mkdir -p "$RUSTUP_HOME" "$CARGO_HOME"
if [[ ! -x "$CARGO_HOME/bin/rustup" ]]; then
log "Installing rustup at $RUSTUP_HOME / $CARGO_HOME"
curl -fsSL https://sh.rustup.rs | RUSTUP_HOME="$RUSTUP_HOME" CARGO_HOME="$CARGO_HOME" \
sh -s -- -y --default-toolchain none --profile minimal --no-modify-path
fi
"$CARGO_HOME/bin/rustup" toolchain install "$RUST_VERSION" --profile minimal --component rustfmt
"$CARGO_HOME/bin/rustup" target add --toolchain "$RUST_VERSION" x86_64-unknown-linux-gnu
"$CARGO_HOME/bin/rustup" default "$RUST_VERSION"
# ---- 5. Flutter (two SDKs: 3.24.5 for build, 3.22.3 for bridge gen) ----
# Why two: the bridge codegen (flutter_rust_bridge_codegen 1.80.1 + freezed)
# produces broken Dart output when run under newer Flutter SDKs on Linux.
# Upstream's bridge.yml uses 3.22.3 specifically; we mirror that. The .deb
# build itself uses 3.24.5.
install_flutter() {
local ver="$1" dir="$2"
if [[ ! -x "$dir/bin/flutter" ]]; then
log "Installing Flutter $ver -> $dir"
local tmp; tmp="$(mktemp -d)"
local parent; parent="$(dirname "$dir")"
curl -fsSL -o "$tmp/flutter.tar.xz" \
"https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${ver}-stable.tar.xz"
# Tarball extracts into a top-level `flutter/` dir; rename to target.
tar -xJf "$tmp/flutter.tar.xz" -C "$tmp"
mkdir -p "$parent"
mv "$tmp/flutter" "$dir"
rm -rf "$tmp"
fi
"$dir/bin/flutter" config --no-analytics >/dev/null
"$dir/bin/flutter" precache --linux >/dev/null
}
install_flutter "$FLUTTER_VERSION" /opt/flutter
install_flutter "$FLUTTER_BRIDGE_VERSION" /opt/flutter-bridge
FLUTTER_DIR=/opt/flutter
# ---- 6. vcpkg ----
VCPKG_DIR=/opt/vcpkg
if [[ ! -d "$VCPKG_DIR/.git" ]]; then
log "Cloning vcpkg"
git clone https://github.com/microsoft/vcpkg.git "$VCPKG_DIR"
fi
git -C "$VCPKG_DIR" fetch --tags origin
git -C "$VCPKG_DIR" -c advice.detachedHead=false checkout "$VCPKG_COMMIT"
[[ -x "$VCPKG_DIR/vcpkg" ]] || "$VCPKG_DIR/bootstrap-vcpkg.sh" -disableMetrics
# vcpkg binary cache (file-backed -- same scheme as build-windows.yml)
mkdir -p /var/cache/vcpkg
chown -R "$SERVICE_USER:$SERVICE_USER" /var/cache/vcpkg
# ---- 7. Permissions ----
log "Setting up permissions for $SERVICE_USER"
chown -R "$SERVICE_USER:$SERVICE_USER" "$CARGO_HOME"
# rustup state needs to be writable too -- toolchain installs touch it.
chown -R "$SERVICE_USER:$SERVICE_USER" "$RUSTUP_HOME"
# Flutter SDK: r/x is enough for builds, but `flutter pub get` writes to its
# own cache subdir so we make it writable as well.
chown -R "$SERVICE_USER:$SERVICE_USER" "$FLUTTER_DIR"
chown -R "$SERVICE_USER:$SERVICE_USER" /opt/flutter-bridge
# vcpkg: builds write under installed/, buildtrees/, etc.
chown -R "$SERVICE_USER:$SERVICE_USER" "$VCPKG_DIR"
# LLVM: read+execute is enough; we never write here at build time.
chown -R "$SERVICE_USER:$SERVICE_USER" "$LLVM_DIR"
# /opt/cargo-tools: workflow installs cargo-expand and flutter_rust_bridge_codegen
# here via `cargo install --root`. Pre-create with the right owner so the first
# job doesn't try to mkdir under root-owned /opt.
mkdir -p /opt/cargo-tools
chown -R "$SERVICE_USER:$SERVICE_USER" /opt/cargo-tools
# git "dubious ownership": same fix as Windows. Trust system-wide.
git config --system --add safe.directory '*' || true
# ---- 8. 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"
curl -fsSL -o "$RUNNER_DIR/act_runner" \
"https://gitea.com/gitea/act_runner/releases/download/v${RUNNER_VERSION}/act_runner-${RUNNER_VERSION}-linux-amd64"
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"
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
# ---- 9. systemd service ----
log "Installing systemd unit"
cat > /etc/systemd/system/gitea-act-runner.service <<EOF
[Unit]
Description=Gitea Actions runner (RustDesk)
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
# Toolchain locations -- needed because services don't inherit a login shell's PATH.
Environment=RUSTUP_HOME=${RUSTUP_HOME}
Environment=CARGO_HOME=${CARGO_HOME}
Environment=VCPKG_ROOT=${VCPKG_DIR}
Environment=LIBCLANG_PATH=${LLVM_DIR}/lib
Environment=PATH=${CARGO_HOME}/bin:${FLUTTER_DIR}/bin:${LLVM_DIR}/bin:${VCPKG_DIR}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# Resource limits for builds
LimitNOFILE=65535
TasksMax=infinity
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable gitea-act-runner.service
systemctl restart gitea-act-runner.service
log "Done."
echo " Verify with: systemctl status gitea-act-runner"
echo " Tail logs with: journalctl -u gitea-act-runner -f"
echo " Runner should appear (online) at $GITEA_URL > Site Admin > Actions > Runners"
+291
View File
@@ -0,0 +1,291 @@
#!/usr/bin/env bash
# Provisions a macOS host (Apple Silicon, macOS 14+) as a Gitea Actions runner
# for RustDesk desktop (.dmg) builds. Idempotent: safe to re-run.
#
# Versions are pinned to .gitea/workflows/build-macos.yml. Bump them there and
# here together.
#
# Usage:
# sudo ./provision.sh \
# --gitea-url https://gitea.example.com \
# --runner-token <token>
#
# Toolchains land in /opt/* (chowned to the runner user). Service is installed
# as a LaunchDaemon running as that user.
set -euo pipefail
# ---- pinned versions (mirror .gitea/workflows/build-macos.yml env block) ----
RUST_VERSION="1.81.0" # MAC_RUST_VERSION upstream (cidre crate needs >=1.81)
FLUTTER_VERSION="3.24.5" # used for `flutter build macos`
FLUTTER_BRIDGE_VERSION="3.22.3" # used for `flutter pub get` + flutter_rust_bridge_codegen
VCPKG_COMMIT="120deac3062162151622ca4860575a33844ba10b"
NASM_VERSION="2.16.03" # 3.x has incompatible CLI; aom/dav1d need 2.x
RUNNER_VERSION="0.2.11"
# ---- defaults ----
RUNNER_NAME="$(hostname -s)-rustdesk"
RUNNER_LABELS=""
SERVICE_USER="gitea-runner"
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,15p' "$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; }
# ---- arch + macOS version detection ----
ARCH="$(uname -m)"
case "$ARCH" in
arm64) HOMEBREW_PREFIX="/opt/homebrew"; ARCH_LABEL="ARM64" ;;
x86_64) HOMEBREW_PREFIX="/usr/local"; ARCH_LABEL="X64" ;;
*) echo "Unsupported arch: $ARCH" >&2; exit 1 ;;
esac
OS_MAJOR="$(sw_vers -productVersion | cut -d. -f1)"
[[ "$OS_MAJOR" -ge 14 ]] || {
echo "WARNING: tested only on macOS 14+. You're on $(sw_vers -productVersion)."
sleep 3
}
DISTRO_LABEL="macos-${OS_MAJOR}"
if [[ -z "$RUNNER_LABELS" ]]; then
RUNNER_LABELS="${DISTRO_LABEL},self-hosted,${ARCH_LABEL},macOS"
fi
log() { printf '\n==> %s\n' "$*"; }
# ---- 1. Xcode Command Line Tools ----
log "Verifying Xcode Command Line Tools"
if ! /usr/bin/xcode-select -p >/dev/null 2>&1; then
echo "Xcode Command Line Tools not installed. Run:" >&2
echo " xcode-select --install" >&2
echo "Then re-run this script." >&2
exit 1
fi
echo " $(xcode-select -p)"
# ---- 2. Homebrew (machine-wide) ----
# Homebrew refuses to install under root (its installer aborts with
# "Don't run this as root!"). It must be installed manually by a regular
# user before this script runs.
log "Verifying Homebrew"
if [[ ! -x "$HOMEBREW_PREFIX/bin/brew" ]]; then
echo "Homebrew not installed at $HOMEBREW_PREFIX." >&2
echo "Install it as your regular user (NOT root), then re-run this script:" >&2
echo " /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"" >&2
exit 1
fi
export PATH="$HOMEBREW_PREFIX/bin:$PATH"
echo " $(brew --version | head -1)"
# brew install must also run as a non-root user. Determine which user invoked
# sudo so we can drop privileges for brew commands below.
BREW_USER="${SUDO_USER:-}"
if [[ -z "$BREW_USER" || "$BREW_USER" == "root" ]]; then
echo "Could not determine the non-root user that ran sudo (SUDO_USER unset)." >&2
echo "Re-run with: sudo ./provision.sh ..." >&2
exit 1
fi
brew_as_user() { sudo -u "$BREW_USER" -H "$HOMEBREW_PREFIX/bin/brew" "$@"; }
# ---- 3. brew packages ----
log "Installing brew packages"
brew_pkgs=(node cocoapods llvm create-dmg pkg-config cmake ninja yasm autoconf automake libtool wget)
for p in "${brew_pkgs[@]}"; do
if brew_as_user list --versions "$p" >/dev/null 2>&1; then
echo " $p (already installed)"
else
brew_as_user install "$p"
fi
done
# ---- 4. NASM 2.16.x (NOT brew's nasm 3.x; aom/dav1d need 2.x) ----
if ! /usr/local/bin/nasm --version 2>/dev/null | grep -q "version $NASM_VERSION"; then
log "Installing NASM $NASM_VERSION"
tmp="$(mktemp -d)"
curl -fsSL -o "$tmp/nasm.zip" \
"https://www.nasm.us/pub/nasm/releasebuilds/${NASM_VERSION}/macosx/nasm-${NASM_VERSION}-macosx.zip"
unzip -q "$tmp/nasm.zip" -d "$tmp"
install -m 0755 "$tmp/nasm-${NASM_VERSION}/nasm" /usr/local/bin/nasm
rm -rf "$tmp"
fi
/usr/local/bin/nasm --version | head -1
# ---- 5. dedicated runner user ----
if ! /usr/bin/id -u "$SERVICE_USER" >/dev/null 2>&1; then
log "Creating user $SERVICE_USER"
# Find an unused UID >= 600
uid=600
while dscl . -list /Users UniqueID | awk '{print $2}' | grep -qx "$uid"; do
uid=$((uid + 1))
done
dscl . -create "/Users/$SERVICE_USER"
dscl . -create "/Users/$SERVICE_USER" UserShell /bin/bash
dscl . -create "/Users/$SERVICE_USER" RealName "Gitea Runner"
dscl . -create "/Users/$SERVICE_USER" UniqueID "$uid"
dscl . -create "/Users/$SERVICE_USER" PrimaryGroupID 20
dscl . -create "/Users/$SERVICE_USER" NFSHomeDirectory "/Users/$SERVICE_USER"
mkdir -p "/Users/$SERVICE_USER"
chown "$SERVICE_USER:staff" "/Users/$SERVICE_USER"
fi
RUNNER_HOME="/Users/$SERVICE_USER"
# ---- 6. Rust (machine-wide) ----
export RUSTUP_HOME=/opt/rustup
export CARGO_HOME=/opt/cargo
mkdir -p "$RUSTUP_HOME" "$CARGO_HOME"
if [[ ! -x "$CARGO_HOME/bin/rustup" ]]; then
log "Installing rustup at $RUSTUP_HOME / $CARGO_HOME"
curl -fsSL https://sh.rustup.rs | RUSTUP_HOME="$RUSTUP_HOME" CARGO_HOME="$CARGO_HOME" \
sh -s -- -y --default-toolchain none --profile minimal --no-modify-path
fi
"$CARGO_HOME/bin/rustup" toolchain install "$RUST_VERSION" --profile minimal --component rustfmt
"$CARGO_HOME/bin/rustup" target add --toolchain "$RUST_VERSION" aarch64-apple-darwin x86_64-apple-darwin
"$CARGO_HOME/bin/rustup" default "$RUST_VERSION"
# ---- 7. Flutter (two SDKs: 3.24.5 for build, 3.22.3 for bridge gen) ----
# Same rationale as Linux: bridge codegen 1.80.1 + freezed produces broken Dart
# under newer Flutter. Run codegen under 3.22.3, build under 3.24.5.
install_flutter() {
local ver="$1" dir="$2"
if [[ ! -x "$dir/bin/flutter" ]]; then
log "Installing Flutter $ver -> $dir"
local tmp; tmp="$(mktemp -d)"
local parent; parent="$(dirname "$dir")"
# Flutter URL pattern differs between archs: Apple Silicon has an
# `_arm64_` infix, Intel has no arch infix at all.
local flutter_url_base="https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos"
local flutter_url
case "$ARCH" in
arm64) flutter_url="${flutter_url_base}_arm64_${ver}-stable.zip" ;;
x86_64) flutter_url="${flutter_url_base}_${ver}-stable.zip" ;;
esac
curl -fsSL -o "$tmp/flutter.zip" "$flutter_url"
mkdir -p "$parent"
unzip -q "$tmp/flutter.zip" -d "$tmp"
mv "$tmp/flutter" "$dir"
rm -rf "$tmp"
fi
"$dir/bin/flutter" config --no-analytics >/dev/null
"$dir/bin/flutter" precache --macos >/dev/null
}
install_flutter "$FLUTTER_VERSION" /opt/flutter
install_flutter "$FLUTTER_BRIDGE_VERSION" /opt/flutter-bridge
# ---- 8. vcpkg ----
VCPKG_DIR=/opt/vcpkg
if [[ ! -d "$VCPKG_DIR/.git" ]]; then
log "Cloning vcpkg"
git clone https://github.com/microsoft/vcpkg.git "$VCPKG_DIR"
fi
git -C "$VCPKG_DIR" fetch --tags origin
git -C "$VCPKG_DIR" -c advice.detachedHead=false checkout "$VCPKG_COMMIT"
[[ -x "$VCPKG_DIR/vcpkg" ]] || "$VCPKG_DIR/bootstrap-vcpkg.sh" -disableMetrics
mkdir -p /var/cache/vcpkg
chown -R "$SERVICE_USER:staff" /var/cache/vcpkg
# ---- 9. Permissions ----
log "Setting up permissions for $SERVICE_USER"
chown -R "$SERVICE_USER:staff" "$CARGO_HOME" "$RUSTUP_HOME" \
/opt/flutter /opt/flutter-bridge "$VCPKG_DIR"
mkdir -p /opt/cargo-tools
chown -R "$SERVICE_USER:staff" /opt/cargo-tools
git config --system --add safe.directory '*' || true
# ---- 10. act_runner ----
RUNNER_DIR="/usr/local/var/gitea-runner"
mkdir -p "$RUNNER_DIR"
chown -R "$SERVICE_USER:staff" "$RUNNER_DIR"
if [[ ! -x "$RUNNER_DIR/act_runner" ]]; then
log "Downloading act_runner $RUNNER_VERSION"
case "$ARCH" in
arm64) rarch="arm64" ;;
x86_64) rarch="amd64" ;;
esac
curl -fsSL -o "$RUNNER_DIR/act_runner" \
"https://gitea.com/gitea/act_runner/releases/download/v${RUNNER_VERSION}/act_runner-${RUNNER_VERSION}-darwin-${rarch}"
chmod +x "$RUNNER_DIR/act_runner"
chown "$SERVICE_USER:staff" "$RUNNER_DIR/act_runner"
fi
if [[ ! -f "$RUNNER_DIR/.runner" ]]; then
log "Registering runner with $GITEA_URL"
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
# ---- 11. launchd service ----
log "Installing LaunchDaemon"
PLIST=/Library/LaunchDaemons/com.rustdesk.gitea-runner.plist
cat > "$PLIST" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.rustdesk.gitea-runner</string>
<key>UserName</key>
<string>${SERVICE_USER}</string>
<key>WorkingDirectory</key>
<string>${RUNNER_DIR}</string>
<key>ProgramArguments</key>
<array>
<string>${RUNNER_DIR}/act_runner</string>
<string>daemon</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>RUSTUP_HOME</key> <string>${RUSTUP_HOME}</string>
<key>CARGO_HOME</key> <string>${CARGO_HOME}</string>
<key>VCPKG_ROOT</key> <string>${VCPKG_DIR}</string>
<key>HOMEBREW_PREFIX</key> <string>${HOMEBREW_PREFIX}</string>
<key>PATH</key>
<string>${CARGO_HOME}/bin:/opt/flutter/bin:/opt/cargo-tools/bin:${HOMEBREW_PREFIX}/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
<key>RunAtLoad</key> <true/>
<key>KeepAlive</key> <true/>
<key>StandardOutPath</key><string>${RUNNER_DIR}/stdout.log</string>
<key>StandardErrorPath</key><string>${RUNNER_DIR}/stderr.log</string>
<key>SoftResourceLimits</key>
<dict>
<key>NumberOfFiles</key> <integer>65535</integer>
</dict>
</dict>
</plist>
EOF
chmod 0644 "$PLIST"
launchctl bootout system "$PLIST" 2>/dev/null || true
launchctl bootstrap system "$PLIST"
launchctl enable "system/com.rustdesk.gitea-runner"
log "Done."
echo " Verify with: sudo launchctl print system/com.rustdesk.gitea-runner | head"
echo " Tail logs with: tail -F $RUNNER_DIR/stderr.log"
echo " Runner should appear (online) at $GITEA_URL > Site Admin > Actions > Runners"
+324
View File
@@ -0,0 +1,324 @@
# Provisions a Windows host (Windows 10/11 or Server 2019+) as a Gitea Actions
# runner for RustDesk desktop builds. Idempotent: safe to re-run.
#
# Versions are pinned to .gitea/workflows/build-windows.yml. Bump them there and
# here together.
#
# Usage (Administrator PowerShell):
# Set-ExecutionPolicy -Scope Process Bypass -Force
# .\provision.ps1 -GiteaUrl https://gitea.example.com -RunnerToken <token>
#
# By default the runner service is created under a dedicated local user
# (`gitea-runner`) -- LocalSystem has been observed to break flutter pub get,
# symlink creation, and git's "dubious ownership" check on this codebase. To
# opt out, pass `-ServiceAccount LocalSystem` (not recommended).
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)] [string] $GiteaUrl,
[Parameter(Mandatory = $true)] [string] $RunnerToken,
[string] $RunnerName = "$env:COMPUTERNAME-rustdesk",
[string] $RunnerLabels = "windows-10,self-hosted,X64",
[string] $RunnerVersion = "0.2.11",
[string] $ServiceAccount = "gitea-runner",
[SecureString] $ServiceAccountPassword
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
# Must run elevated -- nearly every step writes Machine env, HKLM, or service config.
$me = [Security.Principal.WindowsIdentity]::GetCurrent()
if (-not (New-Object Security.Principal.WindowsPrincipal $me).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run this script in an elevated (Administrator) PowerShell session.'
}
# --- pinned versions (mirror .gitea/workflows/build-windows.yml env block) ---
$RUST_VERSION = '1.75.0'
$RUST_NIGHTLY = 'nightly-2023-10-13'
$LLVM_VERSION = '15.0.6'
$FLUTTER_VERSION = '3.24.5'
$VCPKG_COMMIT = '120deac3062162151622ca4860575a33844ba10b'
$ToolsRoot = 'C:\tools'
New-Item -ItemType Directory -Force -Path $ToolsRoot | Out-Null
# Exact-segment-match version of PATH augmentation. Substring matching would
# falsely find C:\bin when C:\binaries is on PATH.
function Add-MachinePath([string]$Dir) {
$cur = [Environment]::GetEnvironmentVariable('Path', 'Machine')
$segments = $cur -split ';' | Where-Object { $_ }
if ($segments -notcontains $Dir) {
[Environment]::SetEnvironmentVariable('Path', "$cur;$Dir", 'Machine')
}
if (($env:Path -split ';') -notcontains $Dir) { $env:Path = "$env:Path;$Dir" }
}
# --- 1. Chocolatey (used for git, python, nuget, 7zip, node, dotnet, ...) ---
if (-not (Get-Command choco -ErrorAction SilentlyContinue)) {
Write-Host '==> Installing Chocolatey'
Set-ExecutionPolicy Bypass -Scope Process -Force
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Invoke-Expression ((New-Object Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
}
Write-Host '==> Installing base packages'
# nodejs-lts: act_runner spawns Node to execute JavaScript actions.
# powershell-core: workflows use `shell: pwsh` (PS 7), not the OS's PS 5.1.
# dotnet-sdk: WiX 4 SDK-style projects (.wixproj) need it for the MSI build.
choco install -y --no-progress `
git python311 nuget.commandline 7zip cmake ninja `
nodejs-lts powershell-core dotnet-sdk
Add-MachinePath 'C:\Program Files\Git\cmd'
Add-MachinePath 'C:\Program Files\Git\bin' # bash.exe + posix tools (sed, find, ...)
Add-MachinePath 'C:\Python311'
Add-MachinePath 'C:\Python311\Scripts'
Add-MachinePath 'C:\Program Files\nodejs'
Add-MachinePath 'C:\Program Files\PowerShell\7'
Add-MachinePath 'C:\Program Files\dotnet'
# --- 2. Visual Studio 2022 Build Tools (MSVC v143 + Win10 SDK) ---
# Use [Environment]::GetEnvironmentVariable to avoid the PowerShell parser quirk
# that mis-tokenises `$env:ProgramFiles(x86)` as `$env:ProgramFiles` + `(x86)`.
$pfx86 = [Environment]::GetEnvironmentVariable('ProgramFiles(x86)')
$vsInstaller = Join-Path $pfx86 'Microsoft Visual Studio\Installer\vswhere.exe'
$vsPresent = (Test-Path $vsInstaller) -and `
((& $vsInstaller -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath) -ne $null)
if (-not $vsPresent) {
Write-Host '==> Installing VS 2022 Build Tools (this takes a while)'
$vsBootstrapper = "$env:TEMP\vs_buildtools.exe"
Invoke-WebRequest -Uri 'https://aka.ms/vs/17/release/vs_buildtools.exe' -OutFile $vsBootstrapper
$vsArgs = @(
'--quiet','--wait','--norestart','--nocache',
'--add','Microsoft.VisualStudio.Workload.VCTools',
'--add','Microsoft.VisualStudio.Component.VC.Tools.x86.x64',
'--add','Microsoft.VisualStudio.Component.VC.ATL',
'--add','Microsoft.VisualStudio.Component.Windows10SDK.20348',
'--add','Microsoft.VisualStudio.Component.VC.CMake.Project',
'--includeRecommended'
)
$p = Start-Process -FilePath $vsBootstrapper -ArgumentList $vsArgs -Wait -PassThru
if ($p.ExitCode -notin 0,3010) { throw "VS Build Tools installer exit $($p.ExitCode)" }
}
# --- 3. Rust (stable + nightly with i686 target) ---
# Install machine-wide so any user (including the dedicated runner account)
# shares one toolchain registry. Without this, rustup state lives in the
# installing user's profile and the service user has no default toolchain.
$rustupHome = 'C:\rustup'
$cargoHome = 'C:\cargo'
[Environment]::SetEnvironmentVariable('RUSTUP_HOME', $rustupHome, 'Machine')
[Environment]::SetEnvironmentVariable('CARGO_HOME', $cargoHome, 'Machine')
$env:RUSTUP_HOME = $rustupHome
$env:CARGO_HOME = $cargoHome
Add-MachinePath "$cargoHome\bin"
if (-not (Test-Path "$cargoHome\bin\rustup.exe")) {
Write-Host '==> Installing rustup (machine-wide at C:\rustup, C:\cargo)'
Invoke-WebRequest -Uri 'https://win.rustup.rs/x86_64' -OutFile "$env:TEMP\rustup-init.exe"
& "$env:TEMP\rustup-init.exe" -y --default-toolchain none --profile minimal
}
rustup toolchain install $RUST_VERSION --profile minimal --component rustfmt
rustup target add --toolchain $RUST_VERSION x86_64-pc-windows-msvc
rustup toolchain install $RUST_NIGHTLY --profile minimal --component rustfmt
rustup target add --toolchain $RUST_NIGHTLY i686-pc-windows-msvc
rustup default $RUST_VERSION
# --- 4. LLVM/Clang (matches KyleMayes/install-llvm-action layout) ---
$llvmDir = "$ToolsRoot\llvm-$LLVM_VERSION"
if (-not (Test-Path "$llvmDir\bin\clang.exe")) {
Write-Host "==> Installing LLVM $LLVM_VERSION"
$llvmExe = "$env:TEMP\LLVM-$LLVM_VERSION-win64.exe"
Invoke-WebRequest -Uri "https://github.com/llvm/llvm-project/releases/download/llvmorg-$LLVM_VERSION/LLVM-$LLVM_VERSION-win64.exe" -OutFile $llvmExe
& $llvmExe /S "/D=$llvmDir" | Out-Null
}
[Environment]::SetEnvironmentVariable('LIBCLANG_PATH', "$llvmDir\bin", 'Machine')
Add-MachinePath "$llvmDir\bin"
# --- 5. Flutter (stable channel, with windows precache) ---
$flutterDir = "$ToolsRoot\flutter"
if (-not (Test-Path "$flutterDir\bin\flutter.bat")) {
Write-Host "==> Installing Flutter $FLUTTER_VERSION"
$flutterZip = "$env:TEMP\flutter.zip"
Invoke-WebRequest -Uri "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_$FLUTTER_VERSION-stable.zip" -OutFile $flutterZip
Expand-Archive -Force -Path $flutterZip -DestinationPath $ToolsRoot
}
Add-MachinePath "$flutterDir\bin"
& "$flutterDir\bin\flutter.bat" config --no-analytics | Out-Null
& "$flutterDir\bin\flutter.bat" precache --windows | Out-Null
# --- 6. vcpkg pinned to commit ---
$vcpkgDir = 'C:\vcpkg'
if (-not (Test-Path "$vcpkgDir\.git")) {
Write-Host '==> Cloning vcpkg'
git clone https://github.com/microsoft/vcpkg.git $vcpkgDir
}
Push-Location $vcpkgDir
git fetch --tags origin
git -c advice.detachedHead=false checkout $VCPKG_COMMIT
if (-not (Test-Path "$vcpkgDir\vcpkg.exe")) { & "$vcpkgDir\bootstrap-vcpkg.bat" -disableMetrics }
Pop-Location
[Environment]::SetEnvironmentVariable('VCPKG_ROOT', $vcpkgDir, 'Machine')
Add-MachinePath $vcpkgDir
# --- 7. CI prerequisites that aren't tools, but environmental switches ---
# git's "dubious ownership" check (>= 2.35.2) refuses to operate on a repo whose
# .git directory is owned by a different user than the one running git. The
# Flutter SDK at C:\tools\flutter is provisioned by this script as Administrator
# but the runner service runs as a non-admin user. Trust everything system-wide.
git config --system --add safe.directory '*' 2>$null
# Flutter on Windows needs SeCreateSymbolicLinkPrivilege to build plugins.
# Enable Developer Mode (registry) AND grant the privilege via Local Security
# Policy to the built-in "Users" group (SID S-1-5-32-545). Either alone has been
# observed to not take effect until logon-token refresh; doing both is
# belt-and-suspenders. The privilege only reaches a long-running service after
# a reboot or a fresh service-token issuance.
$devKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock'
if (-not (Test-Path $devKey)) { New-Item -Path $devKey -Force | Out-Null }
New-ItemProperty -Path $devKey -Name 'AllowDevelopmentWithoutDevLicense' `
-PropertyType DWORD -Value 1 -Force | Out-Null
$secCfg = "$env:TEMP\sec-symlink.cfg"
secedit /export /cfg $secCfg | Out-Null
$secContent = Get-Content $secCfg -Raw
if ($secContent -match 'SeCreateSymbolicLinkPrivilege\s*=\s*([^\r\n]*)') {
$cur = $matches[1]
if ($cur -notmatch '\*S-1-5-32-545') {
$secContent = $secContent -replace `
'(SeCreateSymbolicLinkPrivilege\s*=\s*)([^\r\n]*)', `
'$1$2,*S-1-5-32-545'
}
} else {
$secContent = $secContent -replace `
'(\[Privilege Rights\][\r\n]+)', `
"`$1SeCreateSymbolicLinkPrivilege = *S-1-5-32-545`r`n"
}
$secContent | Set-Content $secCfg
secedit /configure /db "$env:TEMP\sec-symlink.sdb" /cfg $secCfg /areas USER_RIGHTS /quiet
Remove-Item $secCfg, "$env:TEMP\sec-symlink.sdb" -ErrorAction SilentlyContinue
# --- 8. Dedicated runner user ---
# Running as LocalSystem causes a cascade of issues:
# - $USERPROFILE = C:\Windows\System32\config\systemprofile, which Flutter,
# dart pub, and other POSIX-leaning tools mis-handle.
# - cargo install lands binaries in that systemprofile path -> not on PATH.
# - flutter/windows occasionally vanishes during long cargo builds.
# A normal local user fixes all of these.
if ($ServiceAccount -ne 'LocalSystem') {
if (-not (Get-LocalUser -Name $ServiceAccount -ErrorAction SilentlyContinue)) {
if (-not $ServiceAccountPassword) {
# Generate a 32-byte random password using the OS RNG. Encoded as
# base64 (alphanumeric + +/) and trimmed of padding -- meets local
# password complexity without needing System.Web (which is missing
# on Server Core).
$bytes = New-Object byte[] 24
[System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
$plain = ([Convert]::ToBase64String($bytes)).TrimEnd('=') + 'A1!'
$ServiceAccountPassword = ConvertTo-SecureString $plain -AsPlainText -Force
Remove-Variable plain, bytes
}
Write-Host "==> Creating local user '$ServiceAccount'"
New-LocalUser -Name $ServiceAccount -Password $ServiceAccountPassword `
-PasswordNeverExpires -AccountNeverExpires `
-Description 'Gitea Actions runner service account' | Out-Null
Add-LocalGroupMember -Group 'Users' -Member $ServiceAccount
}
# Grant "Log on as a service" via secedit (no PS native cmdlet for this).
$sid = (Get-LocalUser $ServiceAccount).SID.Value
$svcCfg = "$env:TEMP\sec-svc.cfg"
secedit /export /cfg $svcCfg | Out-Null
$svcContent = Get-Content $svcCfg -Raw
if ($svcContent -match "SeServiceLogonRight\s*=\s*([^\r\n]*)") {
if ($matches[1] -notmatch [regex]::Escape($sid)) {
$svcContent = $svcContent -replace `
'(SeServiceLogonRight\s*=\s*)([^\r\n]*)', `
"`$1`$2,*$sid"
}
} else {
$svcContent = $svcContent -replace `
'(\[Privilege Rights\][\r\n]+)', `
"`$1SeServiceLogonRight = *$sid`r`n"
}
$svcContent | Set-Content $svcCfg
secedit /configure /db "$env:TEMP\sec-svc.sdb" /cfg $svcCfg /areas USER_RIGHTS /quiet
Remove-Item $svcCfg, "$env:TEMP\sec-svc.sdb" -ErrorAction SilentlyContinue
# Ensure the user can read/write everything it needs for builds.
foreach ($p in @('C:\actions-runner','C:\cargo','C:\cargo-tools','C:\vcpkg','C:\vcpkg-cache')) {
New-Item -ItemType Directory -Force -Path $p | Out-Null
icacls $p /grant "${ServiceAccount}:(OI)(CI)F" /T 2>$null | Out-Null
}
foreach ($p in @('C:\rustup','C:\tools')) {
if (Test-Path $p) { icacls $p /grant "${ServiceAccount}:(OI)(CI)RX" /T 2>$null | Out-Null }
}
}
# --- 9. Gitea act_runner ---
$runnerDir = 'C:\actions-runner'
New-Item -ItemType Directory -Force -Path $runnerDir | Out-Null
$runnerExe = "$runnerDir\act_runner.exe"
if (-not (Test-Path $runnerExe)) {
Write-Host "==> Downloading act_runner $RunnerVersion"
Invoke-WebRequest -Uri "https://gitea.com/gitea/act_runner/releases/download/v$RunnerVersion/act_runner-$RunnerVersion-windows-amd64.exe" -OutFile $runnerExe
}
Push-Location $runnerDir
if (-not (Test-Path "$runnerDir\.runner")) {
Write-Host '==> Registering runner'
& $runnerExe register --no-interactive `
--instance $GiteaUrl `
--token $RunnerToken `
--name $RunnerName `
--labels $RunnerLabels
}
# Reconfigure the service every run so re-running with a different
# -ServiceAccount actually takes effect.
$svc = Get-Service -Name 'gitea-act-runner' -ErrorAction SilentlyContinue
if ($svc) {
if ($svc.Status -eq 'Running') { Stop-Service gitea-act-runner }
} else {
Write-Host '==> Installing runner as Windows service'
choco install -y --no-progress nssm
nssm install gitea-act-runner $runnerExe daemon | Out-Null
}
nssm set gitea-act-runner AppDirectory $runnerDir | Out-Null
nssm set gitea-act-runner Start SERVICE_AUTO_START | Out-Null
nssm set gitea-act-runner AppStdout "$runnerDir\runner.log" | Out-Null
nssm set gitea-act-runner AppStderr "$runnerDir\runner.log" | Out-Null
if ($ServiceAccount -eq 'LocalSystem') {
nssm set gitea-act-runner ObjectName 'LocalSystem' | Out-Null
} else {
$bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($ServiceAccountPassword)
try {
$plain = [System.Runtime.InteropServices.Marshal]::PtrToStringUni($bstr)
nssm set gitea-act-runner ObjectName ".\$ServiceAccount" $plain | Out-Null
} finally {
[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
Remove-Variable plain -ErrorAction SilentlyContinue
}
}
# Start may fail before reboot if the new SeServiceLogonRight hasn't reached
# SCM yet -- that's expected; the service will start cleanly after reboot.
try {
Start-Service gitea-act-runner
} catch {
Write-Warning "Could not start gitea-act-runner now ($($_.Exception.Message)). It will start on reboot."
}
Pop-Location
Write-Host ''
Write-Host '==> Done.'
Write-Host ' A reboot is REQUIRED before the first build run, so:'
Write-Host ' - the runner service inherits the new SeCreateSymbolicLinkPrivilege token'
Write-Host ' - all PATH/env changes propagate to the SCM-launched service'
Write-Host ' After reboot, verify the runner shows up in Gitea > Site Admin > Actions > Runners.'
if ($ServiceAccount -eq 'LocalSystem') {
Write-Warning 'Service is running as LocalSystem. RustDesk builds have been observed to fail in this configuration (Flutter pub get, symlinks, dubious ownership). Re-run with -ServiceAccount gitea-runner to switch.'
}
+53 -7
View File
@@ -22,11 +22,22 @@ pub enum Action {
ConfigOnly, ConfigOnly,
/// No flags. Foreground dev mode. /// No flags. Foreground dev mode.
None, None,
/// `--cm`. Connection-manager popup mode. Spawned as a USER-token child /// `--cm`. Connection-manager process. Spawned as a USER-token child of
/// by the SYSTEM-token `--server` worker (via librustdesk's /// the SYSTEM-token `--server` worker (either pre-emptively by hello-agent's
/// `run_as_user`) when a peer needs interactive approval. Binds the /// own `spawn_cm_into_user_desktop`, or as a fallback by librustdesk's
/// `_cm` IPC pipe, shows MessageBoxW, replies, exits. /// `run_as_user` when its first `ipc::connect("_cm")` fails). Binds the
/// `_cm` named pipe, runs upstream's `IpcTaskRunner` for each incoming
/// `--server` connection, and lives for as long as the user session does
/// — every `Data::FS(...)` frame the server sends is executed here, in
/// the user's security context.
Cm, Cm,
/// `--update`. Self-replacement entry point launched as an elevated child
/// by the running service's updater (see `librustdesk::updater`) after it
/// has downloaded and SHA256-verified a new hello-agent.exe from the
/// Gitea releases page. `current_exe()` here points at the staged new
/// binary in `%TEMP%`; it copies itself over the installed location and
/// restarts the service via `librustdesk::platform::update_me`.
Update,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -43,6 +54,7 @@ impl ParsedArgs {
let mut service = false; let mut service = false;
let mut server = false; let mut server = false;
let mut cm = false; let mut cm = false;
let mut update = false;
let mut config_blob: Option<String> = None; let mut config_blob: Option<String> = None;
let mut i = 0; let mut i = 0;
@@ -52,6 +64,7 @@ impl ParsedArgs {
"--uninstall" => uninstall = true, "--uninstall" => uninstall = true,
"--service" => service = true, "--service" => service = true,
"--server" => server = true, "--server" => server = true,
"--update" => update = true,
// Connection-manager popup mode. Treat `--cm-no-ui` (the // Connection-manager popup mode. Treat `--cm-no-ui` (the
// Linux-headless variant librustdesk also tries) as a // Linux-headless variant librustdesk also tries) as a
// synonym; either way we run cm_popup. // synonym; either way we run cm_popup.
@@ -77,14 +90,21 @@ impl ParsedArgs {
} }
// Mutual-exclusion rules. --install + --config is the MDM one-liner; // Mutual-exclusion rules. --install + --config is the MDM one-liner;
// everything else is one-action-at-a-time. // everything else is one-action-at-a-time. --update is launched by
let exclusive = [uninstall, service, server, cm].iter().filter(|x| **x).count(); // the updater as a standalone elevated child, never combined.
let exclusive = [uninstall, service, server, cm, update]
.iter()
.filter(|x| **x)
.count();
if exclusive > 1 { if exclusive > 1 {
bail!("--uninstall, --service, --server, --cm are mutually exclusive"); bail!("--uninstall, --service, --server, --cm, --update are mutually exclusive");
} }
if uninstall && (install || config_blob.is_some()) { if uninstall && (install || config_blob.is_some()) {
bail!("--uninstall cannot be combined with other flags"); bail!("--uninstall cannot be combined with other flags");
} }
if update && (install || config_blob.is_some()) {
bail!("--update cannot be combined with other flags");
}
let action = if uninstall { let action = if uninstall {
Action::Uninstall Action::Uninstall
@@ -96,6 +116,8 @@ impl ParsedArgs {
Action::Server Action::Server
} else if cm { } else if cm {
Action::Cm Action::Cm
} else if update {
Action::Update
} else if config_blob.is_some() { } else if config_blob.is_some() {
Action::ConfigOnly Action::ConfigOnly
} else { } else {
@@ -127,6 +149,10 @@ OPTIONS:
--service SCM entry point. Do not invoke manually. --service SCM entry point. Do not invoke manually.
--server Worker mode (launched by the service shell into --server Worker mode (launched by the service shell into
the active console session). the active console session).
--update Self-replacement entry point. Launched by the
running service's updater after downloading and
SHA256-verifying a new release from Gitea. Do
not invoke manually.
-h, --help Show this help. -h, --help Show this help.
-V, --version Show version. -V, --version Show version.
@@ -187,4 +213,24 @@ mod tests {
fn unknown_arg() { fn unknown_arg() {
assert!(parse(&["--no-such-flag"]).is_err()); assert!(parse(&["--no-such-flag"]).is_err());
} }
#[test]
fn update_alone() {
assert_eq!(parse(&["--update"]).unwrap().action, Action::Update);
}
#[test]
fn update_install_conflict() {
assert!(parse(&["--update", "--install"]).is_err());
}
#[test]
fn update_service_conflict() {
assert!(parse(&["--update", "--service"]).is_err());
}
#[test]
fn update_config_conflict() {
assert!(parse(&["--update", "--config", "BLOB"]).is_err());
}
} }
+107 -170
View File
@@ -1,4 +1,5 @@
// Approval popup, run in a dedicated `--cm` child process. // Approval popup + connection-manager process body, run in a dedicated
// `--cm` child process.
// //
// Architecture (matches stock rustdesk): // Architecture (matches stock rustdesk):
// //
@@ -8,29 +9,34 @@
// --server (user session, SYSTEM token) --- screen capture, rendezvous, … // --server (user session, SYSTEM token) --- screen capture, rendezvous, …
// │ on incoming peer requiring approval, librustdesk's start_ipc // │ on incoming peer requiring approval, librustdesk's start_ipc
// │ tries `ipc::connect("_cm")`, fails (no listener), then falls // │ tries `ipc::connect("_cm")`, fails (no listener), then falls
// │ back to `run_as_user(["--cm"])`: // │ back to `run_as_user(["--cm"])`. Either way, a `--cm` process
// │ must be holding the `_cm` named pipe in the user's session:
// ▼ // ▼
// --cm (user session, USER token) --- this module // --cm (user session, USER token) --- this module
// │ binds `_cm`, accepts one connection from the parent's start_ipc, // │ binds `_cm`, accepts connections from `--server`, hands each one
// │ reads frames until it sees Data::Login{authorized:false, …}, // │ to upstream's `IpcTaskRunner` (via `start_ipc`). Our only role
// │ shows MessageBoxW (works cleanly because USER token + interactive // │ on top of that is to plug in an `InvokeUiCM` impl that renders
// │ desktop), replies Data::Authorize / Data::Close, drains the // │ the approval popup with `MessageBoxW` and forwards the user's
// │ stream until the server closes it, exits. // │ decision back via `authorize(id)` / `close(id)`.
// //
// The previous design (run cm_popup as a thread inside the SYSTEM-token // Why this delegates to upstream's `start_ipc` instead of running its own
// --server worker) hit Windows' UI-isolation rules — `MessageBoxW` from a // frame loop: on Windows the `--server` process forwards *every* filesystem
// SYSTEM-token process technically returns successfully but draws on a // operation (ReadDir, ReadFile, WriteBlock, …) over the `_cm` pipe and
// desktop the logged-in user can't see, so the popup was invisible. // expects the CM to execute them in the user's security context. Reading
// Spawning as a USER child sidesteps the whole class of issues. // only the Login frame and discarding the rest — what an earlier version of
// this module did — meant the supporter could open a file-transfer session
// and get the request approved, but the directory listing never arrived
// because the `Data::FS(ReadDir)` frame was being silently dropped. The
// upstream `IpcTaskRunner` implements all of that machinery (handle_fs +
// the file_timer for streaming read jobs); we just provide the popup.
use anyhow::Result; use librustdesk::ui_cm_interface::{self, Client, ConnectionManager, InvokeUiCM};
use librustdesk::ipc; use std::collections::HashMap;
use std::sync::{Arc, Mutex};
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
use std::os::windows::ffi::OsStrExt; use std::os::windows::ffi::OsStrExt;
const POSTFIX: &str = "_cm";
/// Diagnostic trace: writes to stderr AND a debug log file. /// Diagnostic trace: writes to stderr AND a debug log file.
/// Bypasses `log` so we still see output even when env_logger / flexi_logger /// Bypasses `log` so we still see output even when env_logger / flexi_logger
/// init went wrong. Drop these calls once the popup mechanism is stable. /// init went wrong. Drop these calls once the popup mechanism is stable.
@@ -56,174 +62,105 @@ fn trace(msg: &str) {
} }
} }
/// Run the popup loop forever on a freshly-created Tokio runtime. /// Connection-manager process entry point: bind `_cm`, accept connections
/// Safe to call from a `std::thread::spawn` body. /// from the `--server` worker forever, run upstream's IpcTaskRunner on each.
///
/// `start_ipc` is `#[tokio::main(flavor = "current_thread")]` — it builds
/// its own runtime internally — so this is callable from sync context.
pub fn run_blocking() { pub fn run_blocking() {
trace("run_blocking entered"); trace("run_blocking entered");
let cm = ConnectionManager {
let rt = match tokio::runtime::Builder::new_current_thread() ui_handler: HeadlessCm::default(),
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
trace(&format!("build runtime: {e}"));
return;
}
}; };
trace("runtime built; entering serve()"); // Returns only on listener error (e.g. another --cm already holds the
// pipe) or process shutdown. Either way there's nothing to do after.
if let Err(e) = rt.block_on(serve()) { ui_cm_interface::start_ipc(cm);
trace(&format!("serve exited: {e:#}")); trace("start_ipc returned");
} else {
trace("serve returned cleanly");
}
} }
/// Bind `_cm`, accept connections from `--server`'s `start_ipc` for as /// `InvokeUiCM` adapter for hello-agent. Stateless except for a small map
/// long as the user session lasts. Each connection corresponds to one /// of `(connection id) -> (peer_id, name)` we keep so we can render a
/// peer requesting approval; we handle them concurrently. /// "session ended" notification at remove time (the `Client` is dropped
async fn serve() -> Result<()> { /// from upstream's `CLIENTS` registry before our `remove_connection` hook
trace(&format!("calling new_listener({POSTFIX})")); /// is called, so we can't fish the peer info out of there).
let mut incoming = match ipc::new_listener(POSTFIX).await { #[derive(Clone, Default)]
Ok(i) => { struct HeadlessCm {
trace("new_listener succeeded"); /// Tracks peers we approved. Connections that the user denied are not
i /// inserted here, so they don't trigger a "session ended" banner the
} /// user has no context for.
Err(e) => { approved: Arc<Mutex<HashMap<i32, (String, String)>>>,
trace(&format!("new_listener failed: {e}")); }
return Err(anyhow::anyhow!("new_listener({POSTFIX}): {e}"));
}
};
trace("entering accept loop"); impl InvokeUiCM for HeadlessCm {
while let Some(result) = incoming.next().await { /// Called by upstream's IPC loop the moment a peer's Login frame is
match result { /// received and the client has been registered in the global CLIENTS
Ok(stream) => { /// map. We must NOT block here — the same task that called us is
trace("accepted incoming connection"); /// also the one that pumps the `_cm` pipe, so blocking on a user
let conn = ipc::Connection::new(stream); /// click would prevent the IPC loop from ever delivering the
tokio::spawn(async move { /// `Data::Authorize` we send back.
if let Err(e) = handle_one(conn).await { fn add_connection(&self, client: &Client) {
trace(&format!("handle_one error: {e:#}")); trace(&format!(
"add_connection: id={} peer_id={} name={} authorized={}",
client.id, client.peer_id, client.name, client.authorized
));
if client.authorized {
// Already authorized (e.g. password-based auth). No popup,
// but track so remove_connection can show "session ended".
self.approved
.lock()
.unwrap()
.insert(client.id, (client.peer_id.clone(), client.name.clone()));
return;
}
// Render the approval MessageBox on a fresh OS thread so the IPC
// task that called us stays responsive. On Yes we register the
// peer in `approved` and call `authorize(id)` which sends
// `Data::Authorize` back to `--server`; on No we call `close(id)`
// which sends `Data::Close` and the server tears the session down.
let id = client.id;
let peer_id = client.peer_id.clone();
let name = client.name.clone();
let approved_map = self.approved.clone();
std::thread::spawn(move || {
let approved = show_messagebox(&peer_id, &name);
trace(&format!("add_connection: MessageBox approved={approved}"));
if approved {
approved_map
.lock()
.unwrap()
.insert(id, (peer_id, name));
ui_cm_interface::authorize(id);
} else {
ui_cm_interface::close(id);
} }
}); });
} }
Err(e) => {
trace(&format!("accept error: {e}"));
}
}
}
trace("accept loop exited");
Ok(())
}
async fn handle_one(mut conn: ipc::Connection) -> Result<()> { fn remove_connection(&self, id: i32, _close: bool) {
// Frame ordering on the `_cm` pipe is NOT "Login first, then chatter". trace(&format!("remove_connection: id={id}"));
// For an installed/portable controlled side, the server first emits let entry = self.approved.lock().unwrap().remove(&id);
// `Data::DataPortableService(CmShowElevation(...))` so the Flutter CM if let Some((peer_id, name)) = entry {
// can render its elevation banner. The `Data::Login` we care about std::thread::spawn(move || show_session_ended(&peer_id, &name));
// arrives a moment later. We loop through frames, ignore everything
// until we see Login{authorized:false}, decide once, and from then on
// just drain the stream so the server's `tx_to_cm.send()` calls don't
// back up.
//
// We use `conn.next()` (no timeout). A long active session can sit
// quiet for tens of minutes — `tx_to_cm` only fires on Login, FS
// transfers, and connection-close — so a short read timeout would
// false-positive into "session ended" UX during normal use.
trace("handle_one: entering frame loop");
let mut decided = false;
// Set when the user clicks Yes on the approval popup. Carries the
// peer's id / name for the matching "session ended" notification we
// fire after the server tears the connection down.
let mut approved_peer: Option<(String, String)> = None;
loop {
match conn.next().await {
Ok(Some(ipc::Data::Login {
peer_id,
name,
authorized: false,
..
})) if !decided => {
trace(&format!(
"handle_one: Login peer_id={peer_id} name={name} authorized=false"
));
decided = true;
let approved = ask_user_blocking(&peer_id, &name).await;
trace(&format!(
"handle_one: MessageBox returned approved={approved}"
));
if approved {
let _ = conn.send(&ipc::Data::Authorize).await;
trace("handle_one: sent Authorize");
approved_peer = Some((peer_id, name));
} else {
let _ = conn.send(&ipc::Data::Close).await;
trace("handle_one: sent Close — exiting handler");
return Ok(());
}
}
Ok(Some(ipc::Data::Close)) | Ok(Some(ipc::Data::Disconnected)) => {
// Server signals the supporter has left (or the
// connection failed). Fall through to the post-loop
// notification path.
trace("handle_one: server sent Close/Disconnected");
break;
}
Ok(Some(other)) => {
// Pre-login chatter (CmShowElevation), or post-Authorize
// chatter (chat, file transfer events, voice call). We
// don't act on any of it — the Flutter CM would, we just
// need to consume frames so the server's send buffer
// drains.
trace(&format!("handle_one: ignoring frame: {other:?}"));
continue;
}
Ok(None) => {
trace("handle_one: stream closed by peer");
break;
}
Err(e) => {
trace(&format!("handle_one: stream error: {e}"));
break;
}
} }
} }
// Tell the user the supporter is gone. Only fires when we approved // The remaining InvokeUiCM hooks fire for chat / theme / voice-call /
// the connection — denied/cancelled connections already returned // file-transfer-log / privacy-mode-elevation events. Hello-agent
// above, and pre-approval Close from the server (e.g., auth failure // doesn't surface any of them — file transfers complete silently in
// before the popup even fired) shouldn't show a "session ended" // the background (the supporter's UI shows progress on their end),
// banner the user has no context for. // chat is unsupported, voice call is unsupported. Stubs only.
if let Some((peer_id, name)) = approved_peer { fn new_message(&self, _id: i32, _text: String) {}
notify_session_ended(&peer_id, &name).await; fn change_theme(&self, _dark: String) {}
fn change_language(&self) {}
fn show_elevation(&self, _show: bool) {}
fn update_voice_call_state(&self, _client: &Client) {}
fn file_transfer_log(&self, action: &str, log: &str) {
// Useful breadcrumb for debugging file-transfer failures, gated
// behind trace() to keep production stderr quiet.
trace(&format!("file_transfer_log: action={action} log={log}"));
} }
trace("handle_one: returning");
Ok(())
}
/// Show a native MessageBox in the calling (user) session. Runs the dialog
/// on tokio's blocking thread pool so we don't park the reactor while it
/// waits for the user to click.
async fn ask_user_blocking(peer_id: &str, name: &str) -> bool {
let peer_id = peer_id.to_string();
let name = name.to_string();
tokio::task::spawn_blocking(move || show_messagebox(&peer_id, &name))
.await
.unwrap_or(false)
}
/// Inform the user that the remote support session has ended. Best-effort:
/// errors out of the OS dialog APIs are logged (via `trace`) and otherwise
/// ignored — failing to show the post-session banner shouldn't block the
/// handler from cleaning up.
async fn notify_session_ended(peer_id: &str, name: &str) {
let peer_id = peer_id.to_string();
let name = name.to_string();
let _ = tokio::task::spawn_blocking(move || show_session_ended(&peer_id, &name)).await;
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
+259
View File
@@ -0,0 +1,259 @@
//! PowerShell remote-exec worker.
//!
//! Subscribes to `librustdesk::hbbs_http::sync::exec_signal_receiver()` — a
//! broadcast channel that the vendored sync loop populates whenever the
//! server returns an `exec` field in a heartbeat reply (see
//! rustdesk-server/docs/AGENT-API-AUTH.md). For each `ExecRequest` we:
//!
//! 1. Spawn `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy
//! Bypass -Command -` and write the script to stdin.
//! 2. Concurrently drain stdout and stderr into 1 MiB-capped buffers.
//! 3. Apply a wall-clock timeout (default 5 min); kill on expiry.
//! 4. POST the result to `/api/agent/exec-result` with the same Ed25519
//! signature the heartbeat / sysinfo posts use.
//!
//! The whole thing only makes sense on Windows (the agent's target OS),
//! so the module body is `#[cfg(windows)]` and other platforms get a
//! no-op `start()` to keep the call site in `service.rs` portable.
#[cfg(windows)]
mod windows_impl {
use anyhow::{anyhow, Result};
use hbb_common::config::Config;
use librustdesk::hbbs_http::sync::ExecRequest;
use std::process::Stdio;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
pub fn start() {
std::thread::spawn(|| {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
log::warn!("exec worker: build runtime: {e}");
return;
}
};
rt.block_on(run_loop());
});
}
async fn run_loop() {
// The vendored sync layer creates the broadcast channel lazily on
// first `subscribe()`. Calling here also primes it for the parser.
let mut rx = librustdesk::hbbs_http::sync::exec_signal_receiver();
log::info!("exec worker: subscribed to heartbeat exec channel");
loop {
match rx.recv().await {
Ok(req) => {
log::info!(
"exec worker: received cmd_id={} script_len={} max_secs={} max_bytes={}",
req.cmd_id,
req.script.len(),
req.max_secs,
req.max_bytes
);
let outcome = run_one(&req).await;
if let Err(e) = report(&req, &outcome).await {
log::warn!(
"exec worker: report failed for cmd_id={}: {e:#}",
req.cmd_id
);
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
log::warn!("exec worker: lagged, dropped {n} exec requests");
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
log::warn!("exec worker: channel closed, exiting");
return;
}
}
}
}
struct Outcome {
exit_code: i64,
stdout: String,
stderr: String,
timed_out: bool,
truncated: bool,
}
async fn run_one(req: &ExecRequest) -> Outcome {
// Defensive lower bound — a misconfigured server shouldn't be able to
// send max_secs=0 and have us skip the wait.
let timeout = Duration::from_secs(req.max_secs.max(1));
let max_bytes = req.max_bytes.max(1024) as usize;
// `-Command -` makes PowerShell read the script body from stdin,
// which avoids quoting / length issues that plague `-Command "…"`
// for multi-line scripts. `-NoProfile` skips both the
// machine-wide and user-wide profile loads — those would change
// behaviour depending on which AD-managed PowerShell profile the
// service account inherited. `-NonInteractive` makes prompts fail
// instead of hanging the run.
let mut child = match tokio::process::Command::new("powershell.exe")
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
"-",
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
return Outcome {
exit_code: -1,
stdout: String::new(),
stderr: format!("spawn failed: {e}"),
timed_out: false,
truncated: false,
};
}
};
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(req.script.as_bytes()).await;
let _ = stdin.shutdown().await;
}
let stdout = child.stdout.take().expect("piped stdout was requested");
let stderr = child.stderr.take().expect("piped stderr was requested");
// Concurrent capped readers. Each task accumulates up to
// `max_bytes` bytes, then drains and discards the rest so the
// pipe doesn't block the child writer.
let stdout_buf: Arc<Mutex<(Vec<u8>, bool)>> = Arc::new(Mutex::new((Vec::new(), false)));
let stderr_buf: Arc<Mutex<(Vec<u8>, bool)>> = Arc::new(Mutex::new((Vec::new(), false)));
let so = tokio::spawn(read_capped(stdout, stdout_buf.clone(), max_bytes));
let se = tokio::spawn(read_capped(stderr, stderr_buf.clone(), max_bytes));
let wait_result = tokio::time::timeout(timeout, child.wait()).await;
let (exit_code, timed_out) = match wait_result {
Ok(Ok(s)) => (s.code().unwrap_or(-1) as i64, false),
Ok(Err(_)) => (-1, false),
Err(_) => {
// Timed out: kill, then wait the killed child so it
// reaps cleanly (and so the read tasks finish via EOF).
let _ = child.kill().await;
let _ = child.wait().await;
(-1, true)
}
};
let _ = so.await;
let _ = se.await;
let (out_bytes, out_trunc) = {
let g = stdout_buf.lock().unwrap();
(g.0.clone(), g.1)
};
let (err_bytes, err_trunc) = {
let g = stderr_buf.lock().unwrap();
(g.0.clone(), g.1)
};
Outcome {
exit_code,
// PowerShell on a current Windows defaults to UTF-8 when
// OutputEncoding is set, but the agent service inherits the
// legacy code page on older boxes. `from_utf8_lossy`
// guarantees we always have a UTF-8 string to ship; the
// operator sees a U+FFFD when raw bytes weren't UTF-8.
stdout: String::from_utf8_lossy(&out_bytes).into_owned(),
stderr: String::from_utf8_lossy(&err_bytes).into_owned(),
timed_out,
truncated: out_trunc || err_trunc,
}
}
async fn read_capped<R: AsyncReadExt + Unpin>(
mut reader: R,
buf: Arc<Mutex<(Vec<u8>, bool)>>,
cap: usize,
) {
let mut chunk = [0u8; 8192];
loop {
match reader.read(&mut chunk).await {
Ok(0) => return,
Ok(n) => {
let mut g = buf.lock().unwrap();
if g.0.len() < cap {
let room = cap - g.0.len();
if n <= room {
g.0.extend_from_slice(&chunk[..n]);
} else {
g.0.extend_from_slice(&chunk[..room]);
g.1 = true; // truncated; keep draining
}
}
// else: already truncated, drop this chunk on the floor.
}
Err(_) => return,
}
}
}
async fn report(req: &ExecRequest, out: &Outcome) -> Result<()> {
let api = librustdesk::common::get_api_server(
Config::get_option("api-server"),
Config::get_option("custom-rendezvous-server"),
);
if api.is_empty() {
return Err(anyhow!("no api-server configured"));
}
let url = format!("{api}/api/agent/exec-result");
let id = Config::get_id();
let uuid = librustdesk::common::encode64(hbb_common::get_uuid());
let body = hbb_common::serde_json::json!({
"id": id,
"uuid": uuid,
"cmd_id": req.cmd_id,
"exit_code": out.exit_code,
"stdout": out.stdout,
"stderr": out.stderr,
"timed_out": out.timed_out,
"truncated": out.truncated,
})
.to_string();
let headers = librustdesk::hbbs_http::sign::build_signed_headers(
"POST",
"/api/agent/exec-result",
body.as_bytes(),
)
.unwrap_or_default();
if headers.is_empty() {
// Server rejects unsigned exec-result posts unconditionally
// (see api/agent_exec.rs); bail loudly so the operator can
// see the agent isn't ready to sign yet.
return Err(anyhow!("no signing keypair available"));
}
let resp = librustdesk::common::post_request(url, body, &headers)
.await
.map_err(|e| anyhow!("post: {e}"))?;
if resp.trim() == "OK" {
Ok(())
} else {
Err(anyhow!("unexpected response: {}", resp.trim()))
}
}
}
#[cfg(windows)]
pub use windows_impl::start;
#[cfg(not(windows))]
pub fn start() {
log::info!("exec worker: skipped (non-Windows build)");
}
+260
View File
@@ -0,0 +1,260 @@
//! System inventory collection for hello-agent (CMDB).
//!
//! Collects hardware and OS metadata at startup — BIOS serial number,
//! manufacturer / model, AD domain, OS edition + release, CPU details,
//! RAM, disks, and the BitLocker recovery key for the system drive — and
//! returns it as a compact JSON object. The caller stamps the result into
//! `hbb_common::config::INVENTORY` so the next /api/sysinfo upload carries
//! it under the `inventory` key. The rustdesk-server admin UI's per-device
//! detail page reads it back from `device_sysinfo.payload`.
//!
//! Implementation: a single PowerShell child gathers everything via
//! Get-CimInstance and emits compact JSON. One subprocess for the whole
//! inventory is cheaper than per-field WMI queries and avoids pulling a
//! `wmi`/COM crate into the dep tree. Inventory is collected once at
//! startup. Collection routinely outruns the first sysinfo tick (TIME_CONN
//! = 3 s) — `Invoke-RestMethod 'api.ipify.org' -TimeoutSec 5` alone can
//! burn that budget on hosts with blocked egress — so the sysinfo loop in
//! `hbbs_http::sync` watches for INVENTORY transitioning empty → populated
//! and forces a re-upload at that point. Subsequent ticks are suppressed
//! by the loop's `had_inventory` / `uploaded` bookkeeping.
//!
//! Non-Windows builds return an empty JSON object — hello-agent v0 only
//! ships on Windows, but keeping the cross-platform surface compiling
//! makes future Linux work cheap.
#[cfg(target_os = "windows")]
const PS_SCRIPT: &str = r#"
$ErrorActionPreference = 'SilentlyContinue'
$bios = Get-CimInstance -ClassName Win32_BIOS
$cs = Get-CimInstance -ClassName Win32_ComputerSystem
$os = Get-CimInstance -ClassName Win32_OperatingSystem
$cpus = @(Get-CimInstance -ClassName Win32_Processor)
$first_cpu = $cpus | Select-Object -First 1
$total_phys_cores = ($cpus | Measure-Object -Property NumberOfCores -Sum).Sum
$total_log_cores = ($cpus | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum
$disks = @(Get-CimInstance -ClassName Win32_DiskDrive | ForEach-Object {
[pscustomobject]@{
name = $_.DeviceID
model = $_.Model
size_gb = if ($_.Size) { [math]::Round([double]$_.Size / 1GB, 1) } else { 0 }
media = $_.MediaType
}
})
# DisplayVersion is the marketing release ID (e.g. "23H2") — not surfaced
# by Win32_OperatingSystem.Version, which only carries the build number.
$displayVersion = ''
try {
$displayVersion = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name DisplayVersion -ErrorAction Stop).DisplayVersion
} catch {}
# BitLocker recovery key for the system drive. Get-BitLockerVolume needs
# the BitLocker PowerShell module (present on Pro/Enterprise SKUs) and
# admin rights; the agent runs as LocalSystem in production. On Home SKUs
# or Linux this just stays empty.
$bl_key = ''
try {
$sysDrive = $env:SystemDrive
$bv = Get-BitLockerVolume -MountPoint $sysDrive -ErrorAction Stop
$kp = $bv.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'RecoveryPassword' } | Select-Object -First 1
if ($kp -and $kp.RecoveryPassword) { $bl_key = $kp.RecoveryPassword }
} catch {}
# Network interfaces: every adapter (Up + Disabled + Disconnected) with
# its MAC and bound IPv4/IPv6 addresses. Filtering happens server-side
# so the operator can still see "this NIC is disabled" rather than
# silently dropping it. LinkSpeed comes back as a string like "1 Gbps"
# or "100 Mbps" — normalize to integer Mbps, 0 when unknown / down.
$nics = @(Get-NetAdapter | ForEach-Object {
$nic = $_
$ipv4 = @(Get-NetIPAddress -InterfaceIndex $nic.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue | ForEach-Object { $_.IPAddress })
$ipv6 = @(Get-NetIPAddress -InterfaceIndex $nic.ifIndex -AddressFamily IPv6 -ErrorAction SilentlyContinue | ForEach-Object { $_.IPAddress })
$speed_mbps = 0
if ($nic.LinkSpeed) {
if ($nic.LinkSpeed -match '^([\d.]+)\s*Gbps') { $speed_mbps = [int]([double]$Matches[1] * 1000) }
elseif ($nic.LinkSpeed -match '^([\d.]+)\s*Mbps') { $speed_mbps = [int]([double]$Matches[1]) }
elseif ($nic.LinkSpeed -match '^([\d.]+)\s*Kbps') { $speed_mbps = 0 }
}
[pscustomobject]@{
name = $nic.Name
description = $nic.InterfaceDescription
mac = $nic.MacAddress
status = "$($nic.Status)"
ipv4 = $ipv4
ipv6 = $ipv6
speed_mbps = $speed_mbps
is_wifi = ($nic.PhysicalMediaType -eq 'Native 802.11')
}
})
# Wi-Fi inventory is collected separately, in Rust, against the Win32
# Native Wi-Fi API (`wlanapi.dll`). netsh's text output is partially
# localized — `Authentication` becomes `Authentifizierung` /
# `Autenticación` / `Autentificare` on de/es/ro Windows — and our
# regexes silently dropped fields on non-English hosts. The native API
# returns SSIDs as bytes and auth/cipher as numeric enums, so the
# resulting data is locale-stable. See `src/wifi_native.rs`. The fields
# `wifi_current` / `wifi_nearby` are merged into this object after
# PowerShell exits.
# Public egress IP: best-effort lookup against an external echo service.
# Used when the operator wants to correlate the device with a NAT'd
# location. 5 s timeout so a blocked corporate firewall doesn't stall
# inventory collection. ipify is HTTPS, IPv4-only by default, no auth.
$public_ip = ''
try {
$public_ip = (Invoke-RestMethod -Uri 'https://api.ipify.org' -TimeoutSec 5 -ErrorAction Stop).ToString().Trim()
} catch {}
# Installed software (the "Add/Remove Programs" / "Apps & features" list).
# We enumerate the Uninstall registry keys directly — the same source
# Settings reads — rather than `Get-CimInstance Win32_Product`, which is
# notoriously slow (triggers MSI self-repair on every entry) and only
# covers MSI-installed software, missing everything from per-user
# installers, Chocolatey/Scoop/Inno-Setup, etc.
#
# We read both HKLM hives (64-bit + WOW6432Node 32-bit) so apps installed
# under either bitness show up. HKCU is skipped on purpose: the agent
# runs as LocalSystem (or LocalService), whose HKCU hive has nothing the
# logged-in user installed under their own profile — that data would
# require running per-user, which is out of scope for v1.
#
# Filter rules:
# * `DisplayName` must be set — empty-DisplayName entries are uninstall
# stubs for individual update KBs and not user-facing apps.
# * Skip `SystemComponent = 1` — internal Windows components hidden
# from Settings (DirectX shims, VC++ private redists, …).
# * Skip entries with a `ParentKeyName` — those are subcomponents of a
# parent application (e.g. Office's per-language packs); the parent
# row already covers the user-facing app.
#
# Bitness-tagged so the admin UI can distinguish a 64-bit vs 32-bit
# install of the same product (common for runtimes like VC++).
$installed_software = @()
foreach ($scope in @(
@{ path = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'; bitness = '64' },
@{ path = 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'; bitness = '32' }
)) {
try {
$entries = Get-ItemProperty -Path $scope.path -ErrorAction SilentlyContinue
} catch { continue }
foreach ($e in $entries) {
if (-not $e.DisplayName) { continue }
if ($e.SystemComponent -eq 1) { continue }
if ($e.ParentKeyName) { continue }
$installed_software += [pscustomobject]@{
name = "$($e.DisplayName)"
version = if ($e.DisplayVersion) { "$($e.DisplayVersion)" } else { '' }
publisher = if ($e.Publisher) { "$($e.Publisher)" } else { '' }
install_date = if ($e.InstallDate) { "$($e.InstallDate)" } else { '' }
bitness = $scope.bitness
}
}
}
$installed_software = @($installed_software | Sort-Object name, version)
$os_release = "$($os.Version)"
if ($displayVersion) { $os_release = "$($os.Version) $displayVersion" }
$result = [pscustomobject]@{
serial_number = $bios.SerialNumber
manufacturer = $cs.Manufacturer
model = $cs.Model
domain = $cs.Domain
os_distro = $os.Caption
os_release = $os_release
cpu_model = $first_cpu.Name
cpu_speed_ghz = if ($first_cpu.MaxClockSpeed) { [math]::Round([double]$first_cpu.MaxClockSpeed / 1000, 2) } else { 0 }
cpu_cores_physical = $total_phys_cores
cpu_cores_logical = $total_log_cores
ram_gb = if ($cs.TotalPhysicalMemory) { [math]::Round([double]$cs.TotalPhysicalMemory / 1GB, 1) } else { 0 }
disks = $disks
bitlocker_recovery_key = $bl_key
network_interfaces = $nics
public_ip = $public_ip
installed_software = $installed_software
}
$result | ConvertTo-Json -Compress -Depth 6
"#;
/// Collect the inventory and return it as a JSON string. Empty string on
/// any failure — the caller treats that as "skip this upload's
/// `inventory` field" rather than uploading garbage.
#[cfg(target_os = "windows")]
pub fn collect_inventory() -> String {
use std::os::windows::process::CommandExt;
use std::process::Command;
// CREATE_NO_WINDOW prevents a brief PowerShell console flash if the
// agent is ever run interactively (dev mode). In service mode there's
// no console anyway, but the flag is harmless.
const CREATE_NO_WINDOW: u32 = 0x08000000;
let output = match Command::new("powershell.exe")
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
PS_SCRIPT,
])
.creation_flags(CREATE_NO_WINDOW)
.output()
{
Ok(o) => o,
Err(e) => {
log::warn!("inventory: powershell failed to spawn: {e}");
return String::new();
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
log::warn!(
"inventory: powershell exited non-zero ({:?}): {}",
output.status.code(),
stderr.trim()
);
return String::new();
}
let stdout = String::from_utf8_lossy(&output.stdout);
let trimmed = stdout.trim();
if trimmed.is_empty() {
log::warn!("inventory: powershell produced empty stdout");
return String::new();
}
// Parse the PowerShell-produced object so we can merge in the
// native Wi-Fi data below. Any parse failure aborts the whole
// collection — sync.rs would otherwise have to reject malformed
// payloads mid-upload and we'd carry the bad data until the next
// collect.
let mut value: serde_json::Value = match serde_json::from_str(trimmed) {
Ok(v) => v,
Err(e) => {
log::warn!(
"inventory: powershell output is not valid JSON: {e}; first 200 chars: {:.200}",
trimmed
);
return String::new();
}
};
// Native Wi-Fi via wlanapi.dll. Emits SSIDs as bytes and auth/cipher
// as numeric enums, so the result is locale-stable across the
// en/de/es/ro Windows builds in our fleet — replaces the
// previously-localized netsh parser.
let (wifi_current, wifi_nearby) = crate::wifi_native::collect();
if let Some(c) = wifi_current {
value["wifi_current"] = c;
}
value["wifi_nearby"] = serde_json::Value::Array(wifi_nearby);
let serialized = value.to_string();
log::info!("inventory: collected ({} bytes)", serialized.len());
serialized
}
#[cfg(not(target_os = "windows"))]
pub fn collect_inventory() -> String {
String::new()
}
+617
View File
@@ -0,0 +1,617 @@
// User-login tracking.
//
// Polls the Windows Terminal Services session table at a low cadence and
// reports logon / logoff events to the rustdesk-server admin API. Each
// event carries an explicit unix timestamp — for logons that's the OS's
// session `ConnectTime` (so the recorded time is when the user actually
// signed in, not when the agent first noticed them); for logoffs it's
// the agent's wall clock at the moment the session disappeared.
//
// Architecture mirrors `unattended_password`:
// * One background thread, its own current-thread Tokio runtime, no
// entanglement with the SCM supervisor's poll loop.
// * In-memory queue with retry-with-backoff on transport / ID_NOT_FOUND
// errors (the agent's first POST routinely races rendezvous
// registration). Events that never land are eventually dropped to
// bound memory — see DROP_AFTER.
// * Server-side dedup via UNIQUE INDEX
// (peer_id, kind, session_id, at, username) lets the agent re-emit
// the same `logon@ConnectTime` event on every service restart without
// piling up duplicate rows; agent-side state stays in memory only.
//
// Known limits (v1):
// * Lock / unlock are not reported — they don't change the
// WTSEnumerateSessions output we diff on.
// * Logoffs that happen while the service is down are not detected.
// The next service start sees "no session" and has nothing to diff
// against; this leaves a logon without a paired logoff in the UI.
// Tradeoff vs. persisting a snapshot to disk; revisit if operators
// ask for it.
use anyhow::{anyhow, Result};
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Duration;
/// How often to poll the WTS session table. A user can't meaningfully
/// log in / out faster than this, and the OS keeps the table cheap to
/// enumerate (it's a kernel-side struct, not a registry scan).
const POLL_INTERVAL: Duration = Duration::from_secs(5);
/// How often to attempt a flush of the pending queue. Decoupled from the
/// poll interval so a transient server outage doesn't slow down session
/// observation; we keep observing locally and just back off the network
/// retry.
const FLUSH_INTERVAL_BASE: Duration = Duration::from_secs(5);
const FLUSH_INTERVAL_MAX: Duration = Duration::from_secs(60);
/// Drop events from the queue after this many failed delivery attempts.
/// At backoff cap = 60s, this is ~6 hours of trying — enough to ride out
/// a long server-side outage, short enough that a permanently-misconfigured
/// agent doesn't blow up memory.
const DROP_AFTER: u32 = 360;
/// Cap per request — must match the server's MAX_EVENTS_PER_POST. Server
/// rejects anything larger with a 400 so we'd retry forever if we exceeded
/// it.
const MAX_EVENTS_PER_POST: usize = 256;
/// One observed session snapshot. Equality by `(session_id, connect_time)`
/// — Windows can recycle session IDs across logins, so we use the OS-
/// reported `ConnectTime` as the disambiguator. Two snapshots compare
/// equal iff they describe the *same* logical login.
#[derive(Clone, Debug)]
struct Session {
session_id: u32,
/// FILETIME → unix epoch seconds. 0 if the OS returned an unparseable
/// value (very old Windows builds); we still report `logon` but the
/// server-side dedup degrades to "first observation wins".
connect_unix: i64,
username: String,
domain: String,
/// "console" | "rdp" | "" (unknown).
session_kind: String,
}
#[derive(Clone, Debug)]
struct PendingEvent {
at: i64,
kind: &'static str,
username: String,
domain: String,
session_id: u32,
session_kind: String,
/// Number of times we've tried to flush this event. Used to drop
/// rows that have been retrying since forever.
attempts: u32,
}
#[cfg(target_os = "windows")]
static QUEUE: Mutex<Vec<PendingEvent>> = Mutex::new(Vec::new());
/// Kick off the background tracker. Returns immediately. Safe to call
/// multiple times (subsequent calls are no-ops) — gated by an
/// `AtomicBool`.
pub fn start() {
#[cfg(not(target_os = "windows"))]
{
// Login tracking is Windows-only; on other platforms the call is
// a no-op so cross-platform builds (CI lint runs on Linux) link.
}
#[cfg(target_os = "windows")]
{
use std::sync::atomic::{AtomicBool, Ordering};
static STARTED: AtomicBool = AtomicBool::new(false);
if STARTED.swap(true, Ordering::SeqCst) {
return;
}
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
log::warn!("login-events: build runtime: {e}");
return;
}
};
rt.block_on(run_loop());
});
}
}
#[cfg(target_os = "windows")]
async fn run_loop() {
// Diff snapshot. Keyed by session_id; value carries connect_unix so we
// can detect "session id reused for a new login" as well as "session
// disappeared".
let mut prev: HashMap<u32, Session> = HashMap::new();
let mut first_poll = true;
let mut flush_backoff = FLUSH_INTERVAL_BASE;
loop {
match enumerate_active_user_sessions() {
Ok(snapshot) => {
let now_unix = hbb_common::chrono::Utc::now().timestamp();
let curr: HashMap<u32, Session> =
snapshot.into_iter().map(|s| (s.session_id, s)).collect();
let events =
diff_into_events(&prev, &curr, first_poll, now_unix);
if !events.is_empty() {
let mut q = QUEUE.lock().unwrap();
q.extend(events);
}
prev = curr;
first_poll = false;
}
Err(e) => {
log::warn!("login-events: enumerate failed: {e:#}");
}
}
// Try to flush. If the queue is empty this is cheap (no network).
// The flush also handles its own retry / drop semantics — we just
// adjust our local cadence based on whether it succeeded.
match flush_once().await {
FlushOutcome::Idle | FlushOutcome::AllSent => {
flush_backoff = FLUSH_INTERVAL_BASE;
}
FlushOutcome::Failed => {
flush_backoff = (flush_backoff * 2).min(FLUSH_INTERVAL_MAX);
}
}
// Poll cadence is constant; the network backoff doesn't slow down
// observation — that would risk missing logoffs while the server
// is down. We just delay the retry of pending events.
tokio::time::sleep(POLL_INTERVAL.min(flush_backoff)).await;
}
}
#[cfg(target_os = "windows")]
fn diff_into_events(
prev: &HashMap<u32, Session>,
curr: &HashMap<u32, Session>,
first_poll: bool,
now_unix: i64,
) -> Vec<PendingEvent> {
let mut out = Vec::new();
// New / changed sessions → logon.
for (sid, sess) in curr {
let changed = match prev.get(sid) {
None => true,
Some(old) => {
// Same session id but a different connect_time → the
// OS recycled the slot for a fresh login. Emit a
// logoff for the old occupant and a logon for the new.
if old.connect_unix != sess.connect_unix
|| old.username != sess.username
{
out.push(PendingEvent {
at: now_unix,
kind: "logoff",
username: old.username.clone(),
domain: old.domain.clone(),
session_id: *sid,
session_kind: old.session_kind.clone(),
attempts: 0,
});
true
} else {
false
}
}
};
if !changed {
continue;
}
// ConnectTime can be 0 on the login-screen session even when a
// user is shown as logged in (rare edge); fall back to `now` so
// the row still lands somewhere sensible.
let at = if sess.connect_unix > 0 {
sess.connect_unix
} else {
now_unix
};
out.push(PendingEvent {
at,
kind: "logon",
username: sess.username.clone(),
domain: sess.domain.clone(),
session_id: *sid,
session_kind: sess.session_kind.clone(),
attempts: 0,
});
}
// Disappeared sessions → logoff. Skipped on the very first poll
// because we have no baseline to diff against — see the module-level
// "first poll: emit logons-only" note.
if !first_poll {
for (sid, old) in prev {
if !curr.contains_key(sid) {
out.push(PendingEvent {
at: now_unix,
kind: "logoff",
username: old.username.clone(),
domain: old.domain.clone(),
session_id: *sid,
session_kind: old.session_kind.clone(),
attempts: 0,
});
}
}
}
out
}
#[cfg(target_os = "windows")]
enum FlushOutcome {
Idle,
AllSent,
Failed,
}
#[cfg(target_os = "windows")]
async fn flush_once() -> FlushOutcome {
// Take a snapshot under the lock so we don't hold it across the
// (potentially slow) network call. If the POST succeeds we drop the
// matching prefix from the queue; if it fails we put back the
// unsent tail with bumped attempt counters.
let batch: Vec<PendingEvent> = {
let mut q = QUEUE.lock().unwrap();
if q.is_empty() {
return FlushOutcome::Idle;
}
let take = q.len().min(MAX_EVENTS_PER_POST);
q.drain(..take).collect()
};
let posted = post_batch(&batch).await;
match posted {
Ok(()) => {
// Server accepted (or returned ID_NOT_FOUND, which we treat
// as "drop and continue" because no amount of retrying will
// make the peer materialize without the rendezvous loop in
// --server, which the unattended_password reporter already
// hammers on; once that succeeds the next batch lands).
FlushOutcome::AllSent
}
Err(e) => {
log::warn!(
"login-events: flush of {} event(s) failed: {e:#}",
batch.len(),
);
// Put the batch back with bumped attempt counters, modulo
// events that have hit the drop threshold. Prepend so that
// any events pushed by the poll loop between the drain and
// here stay after the (older) failed batch.
let mut requeued: Vec<PendingEvent> = batch
.into_iter()
.filter_map(|mut ev| {
ev.attempts = ev.attempts.saturating_add(1);
if ev.attempts >= DROP_AFTER {
log::warn!(
"login-events: dropping event after {} attempts: \
kind={} session={} user={}",
ev.attempts, ev.kind, ev.session_id, ev.username,
);
None
} else {
Some(ev)
}
})
.collect();
let mut q = QUEUE.lock().unwrap();
let tail: Vec<PendingEvent> = q.drain(..).collect();
requeued.extend(tail);
*q = requeued;
FlushOutcome::Failed
}
}
}
#[cfg(target_os = "windows")]
async fn post_batch(batch: &[PendingEvent]) -> Result<()> {
let api = librustdesk::common::get_api_server(
hbb_common::config::Config::get_option("api-server"),
hbb_common::config::Config::get_option("custom-rendezvous-server"),
);
if api.is_empty() {
return Err(anyhow!("no api-server configured yet"));
}
let url = format!("{api}/api/agent/login-event");
let id = hbb_common::config::Config::get_id();
let uuid = librustdesk::common::encode64(hbb_common::get_uuid());
let events: Vec<hbb_common::serde_json::Value> = batch
.iter()
.map(|ev| {
hbb_common::serde_json::json!({
"at": ev.at,
"kind": ev.kind,
"username": ev.username,
"domain": ev.domain,
"session_id": ev.session_id,
"session_kind": ev.session_kind,
})
})
.collect();
let body = hbb_common::serde_json::json!({
"id": id,
"uuid": uuid,
"events": events,
})
.to_string();
let headers = librustdesk::hbbs_http::sign::build_signed_headers(
"POST",
"/api/agent/login-event",
body.as_bytes(),
)
.unwrap_or_default();
let resp = librustdesk::common::post_request(url, body, &headers)
.await
.map_err(|e| anyhow!("post: {e}"))?;
let trimmed = resp.trim();
if trimmed == "OK" || trimmed == "ID_NOT_FOUND" {
// ID_NOT_FOUND is "peer not registered yet" — happens on the
// first few flushes after a fresh install, before the
// rendezvous loop in --server has created the peer row. The
// unattended_password reporter races this same window; once
// either of them succeeds the peer row exists and subsequent
// posts land. We treat it as success here so the agent doesn't
// pile up unbounded retries — if rendezvous never registers,
// the heartbeat path is also broken and the operator has
// bigger problems than missing login events.
Ok(())
} else {
Err(anyhow!("unexpected response: {trimmed}"))
}
}
// ─────────────────────────── Win32 session enumeration ────────────────────
//
// Same shape as service.rs's find_active_user_session — we declare just
// the WTS functions we touch rather than pull in another bindgen. The
// types are tiny and ABI-stable.
#[cfg(target_os = "windows")]
#[repr(C)]
struct WtsSessionInfoW {
session_id: u32,
win_station_name: *mut u16,
state: i32,
}
#[cfg(target_os = "windows")]
#[repr(C)]
struct WtsInfoW {
state: i32,
session_id: u32,
incoming_bytes: u32,
outgoing_bytes: u32,
incoming_frames: u32,
outgoing_frames: u32,
incoming_compressed_bytes: u32,
outgoing_compressed_bytes: u32,
win_station_name: [u16; 32],
domain: [u16; 17],
user_name: [u16; 21],
connect_time: i64,
disconnect_time: i64,
last_input_time: i64,
logon_time: i64,
current_time: i64,
}
#[cfg(target_os = "windows")]
extern "system" {
fn WTSEnumerateSessionsW(
h_server: winapi::shared::ntdef::HANDLE,
reserved: u32,
version: u32,
pp_session_info: *mut *mut WtsSessionInfoW,
p_count: *mut u32,
) -> i32;
fn WTSFreeMemory(p_memory: *mut std::ffi::c_void);
fn WTSQuerySessionInformationW(
h_server: winapi::shared::ntdef::HANDLE,
session_id: u32,
info_class: i32,
pp_buffer: *mut *mut u16,
p_bytes_returned: *mut u32,
) -> i32;
}
#[cfg(target_os = "windows")]
const WTS_ACTIVE: i32 = 0;
#[cfg(target_os = "windows")]
const WTS_USER_NAME: i32 = 5;
#[cfg(target_os = "windows")]
const WTS_DOMAIN_NAME: i32 = 7;
#[cfg(target_os = "windows")]
const WTS_CLIENT_PROTOCOL_TYPE: i32 = 16;
#[cfg(target_os = "windows")]
const WTS_SESSION_INFO: i32 = 24;
#[cfg(target_os = "windows")]
fn enumerate_active_user_sessions() -> Result<Vec<Session>> {
let mut sessions: *mut WtsSessionInfoW = std::ptr::null_mut();
let mut count: u32 = 0;
let ok = unsafe {
WTSEnumerateSessionsW(
std::ptr::null_mut(),
0,
1,
&mut sessions,
&mut count,
)
};
if ok == 0 || sessions.is_null() {
return Err(anyhow!(
"WTSEnumerateSessionsW failed: {}",
std::io::Error::last_os_error()
));
}
let mut out = Vec::new();
for i in 0..count {
let info = unsafe { &*sessions.add(i as usize) };
if info.state != WTS_ACTIVE {
continue;
}
let sid = info.session_id;
// Skip sessions without a logged-in user (login screen, Session 0).
let username = match query_wide(sid, WTS_USER_NAME) {
Some(s) if !s.is_empty() => s,
_ => continue,
};
let domain = query_wide(sid, WTS_DOMAIN_NAME).unwrap_or_default();
let session_kind = match query_protocol_type(sid) {
Some(0) => "console".to_string(),
Some(2) => "rdp".to_string(),
Some(_) | None => String::new(),
};
let connect_unix = query_connect_time(sid).unwrap_or(0);
out.push(Session {
session_id: sid,
connect_unix,
username,
domain,
session_kind,
});
}
unsafe { WTSFreeMemory(sessions as *mut std::ffi::c_void) };
Ok(out)
}
/// Pull a WCHAR-string-shaped value (WTSUserName / WTSDomainName / …)
/// and convert to UTF-8. Returns None on failure or empty result.
#[cfg(target_os = "windows")]
fn query_wide(session_id: u32, info_class: i32) -> Option<String> {
let mut buf: *mut u16 = std::ptr::null_mut();
let mut bytes: u32 = 0;
let ok = unsafe {
WTSQuerySessionInformationW(
std::ptr::null_mut(),
session_id,
info_class,
&mut buf,
&mut bytes,
)
};
if ok == 0 || buf.is_null() {
return None;
}
let s = unsafe { wide_to_string(buf, bytes) };
unsafe { WTSFreeMemory(buf as *mut std::ffi::c_void) };
if s.is_empty() {
None
} else {
Some(s)
}
}
/// WTSClientProtocolType returns a single USHORT (2 bytes). Buffer is
/// allocated by Windows; we read the 16-bit value and free.
#[cfg(target_os = "windows")]
fn query_protocol_type(session_id: u32) -> Option<u16> {
let mut buf: *mut u16 = std::ptr::null_mut();
let mut bytes: u32 = 0;
let ok = unsafe {
WTSQuerySessionInformationW(
std::ptr::null_mut(),
session_id,
WTS_CLIENT_PROTOCOL_TYPE,
&mut buf,
&mut bytes,
)
};
if ok == 0 || buf.is_null() || bytes < 2 {
if !buf.is_null() {
unsafe { WTSFreeMemory(buf as *mut std::ffi::c_void) };
}
return None;
}
let val = unsafe { *buf };
unsafe { WTSFreeMemory(buf as *mut std::ffi::c_void) };
Some(val)
}
/// Pull ConnectTime from WTSINFOW (the per-session struct returned by
/// WTSQuerySessionInformation with class WTSSessionInfo). FILETIME 0
/// means "OS doesn't know" (rare — happens on the login-screen session
/// before a user signs in); we surface that as None and the caller
/// falls back to `now()`.
#[cfg(target_os = "windows")]
fn query_connect_time(session_id: u32) -> Option<i64> {
let mut buf: *mut u16 = std::ptr::null_mut();
let mut bytes: u32 = 0;
let ok = unsafe {
WTSQuerySessionInformationW(
std::ptr::null_mut(),
session_id,
WTS_SESSION_INFO,
&mut buf,
&mut bytes,
)
};
if ok == 0 || buf.is_null() || (bytes as usize) < std::mem::size_of::<WtsInfoW>() {
if !buf.is_null() {
unsafe { WTSFreeMemory(buf as *mut std::ffi::c_void) };
}
return None;
}
let info: &WtsInfoW = unsafe { &*(buf as *const WtsInfoW) };
let ft = info.connect_time;
unsafe { WTSFreeMemory(buf as *mut std::ffi::c_void) };
let unix = filetime_to_unix(ft);
if unix > 0 {
Some(unix)
} else {
None
}
}
/// FILETIME is 100-nanosecond ticks since 1601-01-01 UTC. Convert to
/// unix seconds; clamp to >=0 since the table column is signed but a
/// pre-epoch ConnectTime is nonsense for our purposes.
#[cfg(target_os = "windows")]
fn filetime_to_unix(ft: i64) -> i64 {
// 11644473600 = seconds between 1601-01-01 and 1970-01-01.
const TICKS_PER_SEC: i64 = 10_000_000;
const EPOCH_DIFF_SECS: i64 = 11_644_473_600;
if ft <= 0 {
return 0;
}
let secs_since_1601 = ft / TICKS_PER_SEC;
let unix = secs_since_1601 - EPOCH_DIFF_SECS;
unix.max(0)
}
/// Convert a NUL-terminated UTF-16 buffer to a Rust String. `bytes` is
/// the byte count Windows returned (NOT the char count); we trust the
/// trailing NUL but cap on `bytes / 2` so a malformed buffer can't run
/// us into unallocated memory.
#[cfg(target_os = "windows")]
unsafe fn wide_to_string(buf: *const u16, bytes: u32) -> String {
if buf.is_null() || bytes == 0 {
return String::new();
}
let max_chars = (bytes as usize) / 2;
let mut len = 0usize;
while len < max_chars && *buf.add(len) != 0 {
len += 1;
}
let slice = std::slice::from_raw_parts(buf, len);
String::from_utf16_lossy(slice)
}
+92 -6
View File
@@ -15,7 +15,7 @@
// lazily from a `RwLock<String>` whenever any path is computed (config dir, // lazily from a `RwLock<String>` whenever any path is computed (config dir,
// log dir, named-pipe namespace, …), so setting it before any of those // log dir, named-pipe namespace, …), so setting it before any of those
// initializers fire is enough to redirect all hbb_common state under // initializers fire is enough to redirect all hbb_common state under
// `%APPDATA%\HelloAgent\` and the matching LocalService path. Identical // `%APPDATA%\hello-agent\` and the matching LocalService path. Identical
// to the `read_custom_client` write path the upstream Flutter build uses // to the `read_custom_client` write path the upstream Flutter build uses
// for OEM rebrands. // for OEM rebrands.
@@ -23,30 +23,49 @@
mod cli; mod cli;
mod config_import; mod config_import;
mod inventory;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
mod cm_popup; mod cm_popup;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
mod exec;
mod login_events;
mod perf;
mod perf_events;
#[cfg(target_os = "windows")]
mod service; mod service;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
mod unattended_password; mod unattended_password;
#[cfg(target_os = "windows")]
mod wifi_native;
use cli::{Action, ParsedArgs}; use cli::{Action, ParsedArgs};
/// Product name used to namespace all on-disk state and the IPC pipe path. /// Product name used to namespace all on-disk state and the IPC pipe path.
/// Written into `hbb_common::config::APP_NAME` at the top of `main` so /// Written into `hbb_common::config::APP_NAME` at the top of `main` so
/// every subsequent path computation (config dir, log dir, named pipe) /// every subsequent path computation (config dir, log dir, named pipe)
/// targets `%APPDATA%\HelloAgent\` rather than the upstream default of /// targets `%APPDATA%\hello-agent\` rather than the upstream default of
/// `%APPDATA%\RustDesk\`. Must be set before any code touches a path — /// `%APPDATA%\RustDesk\`. Must be set before any code touches a path —
/// `hbb_common` initializes path globals lazily on first read. /// `hbb_common` initializes path globals lazily on first read.
pub const APP_NAME: &str = "HelloAgent"; ///
/// Important: this value also drives upstream's installer lookup paths.
/// `librustdesk::platform::get_install_info` computes the expected install
/// dir as `%ProgramFiles%\<APP_NAME>` and the expected exe filename as
/// `<APP_NAME>.exe`. Keeping `APP_NAME` aligned with the lowercase-hyphenated
/// install path (`%ProgramFiles%\hello-agent\hello-agent.exe`) is what
/// makes `--update` (which delegates to `librustdesk::platform::update_me`)
/// find the binary it needs to replace, kill the right process by image
/// name, and rename the staged exe to `hello-agent.exe` after the copy.
/// Renaming this constant without renaming the install dir / exe will
/// silently break self-update.
pub const APP_NAME: &str = "hello-agent";
/// Set up logging. We delegate to `hbb_common::init_log`, which: /// Set up logging. We delegate to `hbb_common::init_log`, which:
/// * In **debug** builds: installs `env_logger` writing to stderr. /// * In **debug** builds: installs `env_logger` writing to stderr.
/// * In **release** builds: installs `flexi_logger` writing to a rolling /// * In **release** builds: installs `flexi_logger` writing to a rolling
/// file under `<config_dir>/log/<mode>/` — the SYSTEM service log ends /// file under `<config_dir>/log/<mode>/` — the SYSTEM service log ends
/// up at `%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\HelloAgent\log\<mode>\` /// up at `%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\hello-agent\log\<mode>\`
/// and the user-mode log at `%APPDATA%\HelloAgent\log\<mode>\`. /// and the user-mode log at `%APPDATA%\hello-agent\log\<mode>\`.
/// ///
/// The `mode` label segregates per-run-mode log files so service worker /// The `mode` label segregates per-run-mode log files so service worker
/// chatter doesn't tangle with --install diagnostics. `init_log` is /// chatter doesn't tangle with --install diagnostics. `init_log` is
@@ -62,7 +81,7 @@ fn main() {
// we'd never recover. // we'd never recover.
*hbb_common::config::APP_NAME.write().unwrap() = APP_NAME.to_owned(); *hbb_common::config::APP_NAME.write().unwrap() = APP_NAME.to_owned();
// Identify ourselves to the rustdesk-server's /api/sysinfo endpoint // Identify ourselves to the rustdesk-server's /api/sysinfo endpoint
// so the admin Devices page can show "HelloAgent 0.1.0" instead of // so the admin Devices page can show "hello-agent 0.1.0" instead of
// the embedded rustdesk core version. These RwLocks are read once // the embedded rustdesk core version. These RwLocks are read once
// per sysinfo upload by hbbs_http::sync; setting them here (before // per sysinfo upload by hbbs_http::sync; setting them here (before
// start_server) ensures the very first upload carries the identity. // start_server) ensures the very first upload carries the identity.
@@ -87,10 +106,40 @@ fn main() {
Action::Service => "service", Action::Service => "service",
Action::Server => "server", Action::Server => "server",
Action::Cm => "cm", Action::Cm => "cm",
Action::Update => "update",
Action::ConfigOnly | Action::None => "hello-agent", Action::ConfigOnly | Action::None => "hello-agent",
}; };
init_logging(mode); init_logging(mode);
// --update is the self-replacement re-entry: the running service's
// updater downloads a new hello-agent.exe to %TEMP%, verifies its
// SHA256, then launches `<temp>\hello-agent.exe --update` as an
// elevated child. We are that child — `current_exe()` is the staged
// new binary, and our only job is to copy ourselves over the
// installed location and restart the service. Do it before the
// config-import dance below so a corrupt-on-disk config can't block
// an update from going through.
if parsed.action == Action::Update {
#[cfg(target_os = "windows")]
{
match librustdesk::platform::update_me(false) {
Ok(()) => {
log::info!("hello-agent: --update completed");
}
Err(e) => {
log::error!("hello-agent: --update failed: {e:#}");
std::process::exit(1);
}
}
}
#[cfg(not(target_os = "windows"))]
{
eprintln!("hello-agent: --update is Windows-only.");
std::process::exit(1);
}
return;
}
// --config is allowed to combine with --install (one-line MDM deploy) // --config is allowed to combine with --install (one-line MDM deploy)
// but on its own is a separate operation. Apply it first so --install // but on its own is a separate operation. Apply it first so --install
// sees the populated config. // sees the populated config.
@@ -105,7 +154,14 @@ fn main() {
// (or a prior install) already set custom-rendezvous-server, this is a // (or a prior install) already set custom-rendezvous-server, this is a
// no-op. Without this, a bare `hello-agent.exe --install` would land // no-op. Without this, a bare `hello-agent.exe --install` would land
// at an unconfigured agent that can't reach any server. // at an unconfigured agent that can't reach any server.
//
// Skipped for `--uninstall`: an uninstall flow has no business mutating
// the calling user's config, and otherwise we'd write defaults into
// %APPDATA% right before tearing the agent down. (`--update` is
// dispatched in the early-return block above and never reaches here.)
if parsed.action != Action::Uninstall {
config_import::apply_defaults_if_empty(); config_import::apply_defaults_if_empty();
}
match parsed.action { match parsed.action {
Action::Install => { Action::Install => {
@@ -169,6 +225,11 @@ fn main() {
// can watch logs. Production deployments use --install + --service. // can watch logs. Production deployments use --install + --service.
run_server(); run_server();
} }
Action::Update => {
// Handled in the early-return block above (before config-import).
// The match has to cover this variant for exhaustiveness.
unreachable!("Action::Update is dispatched before this match");
}
} }
} }
@@ -206,6 +267,31 @@ fn run_server() {
), ),
} }
// Kick off CMDB inventory collection on a background thread before
// start_server boots. PowerShell's first-run cost (a few hundred ms
// to a few seconds) shouldn't delay the rendezvous heartbeat — the
// sysinfo upload loop only fires every TIME_CONN seconds, so the
// inventory will be ready in time for the very first /api/sysinfo
// POST whether collection finishes in 50ms or 5s. We deliberately
// don't retry on failure: a transient PowerShell hiccup leaves the
// INVENTORY global empty, and sync.rs simply omits the `inventory`
// key from the upload. Next agent restart re-tries.
std::thread::spawn(|| {
let inv = inventory::collect_inventory();
if !inv.is_empty() {
*hbb_common::config::INVENTORY.write().unwrap() = inv;
}
});
// Start the PowerShell remote-exec worker. Subscribes to the
// broadcast channel in the vendored sync layer; the channel is
// shared in-process so the worker MUST run in this --server process
// (where sync.rs lives), not the --service supervisor. The worker
// is idle until an admin dispatches an exec from the dashboard.
// Gated server-side on peer.managed=1 + strategy.enable-remote-exec.
#[cfg(target_os = "windows")]
exec::start();
// `start_server` is `#[tokio::main]` and runs forever. (is_server=true, // `start_server` is `#[tokio::main]` and runs forever. (is_server=true,
// no_server=false). It boots the default IPC server, input service, // no_server=false). It boots the default IPC server, input service,
// rendezvous mediator, and heartbeat sync. // rendezvous mediator, and heartbeat sync.
+311
View File
@@ -0,0 +1,311 @@
// Continuous performance sampling.
//
// One sample per minute: overall CPU%, memory used / total, top
// process by CPU, top process by memory, uptime, process count.
// Posted in batches to /api/agent/metrics; surfaced on the admin
// device detail page as a 24 h sparkline plus a "live" snapshot card.
//
// Architecture mirrors `login_events`:
// * One background thread, its own current-thread Tokio runtime.
// * Sysinfo crate (vendored under hbb_common) does the cross-cutting
// work — we keep one `System` instance alive across iterations so
// its per-process CPU% accounting (which is differential against
// the previous refresh) stays accurate.
// * In-memory queue with retry-with-backoff and a hard drop cap so
// a permanently-misconfigured agent can't balloon memory.
// * Server-side `INSERT OR IGNORE` keyed on (peer_id, at) dedups any
// retries that get there twice.
//
// Process CPU% on Windows is reported by sysinfo as "% of one core",
// so a busy multi-threaded process can read >100%. We normalise by
// `cpus().len()` so the snapshot card's "top CPU: chrome.exe 18%"
// is comparable to the overall CPU%. Memory is in bytes from sysinfo;
// we convert to MB on the wire.
use anyhow::{anyhow, Result};
use std::sync::Mutex;
use std::time::Duration;
/// Sampling cadence. 60 s strikes a balance between resolution (enough
/// granularity to spot a 2-minute CPU spike) and storage (~1440 rows /
/// device / day on the server side).
const SAMPLE_INTERVAL: Duration = Duration::from_secs(60);
/// Flush cadence on the happy path. The reporter tries to flush after
/// every sample anyway; this is the floor used by the network-error
/// backoff before it doubles.
const FLUSH_INTERVAL_BASE: Duration = Duration::from_secs(60);
const FLUSH_INTERVAL_MAX: Duration = Duration::from_secs(15 * 60);
/// At backoff cap = 15 min, this is ~3 days of trying — enough to ride
/// out a long server-side outage. Beyond that we drop, on the same
/// reasoning as `login_events::DROP_AFTER`.
const DROP_AFTER: u32 = 300;
/// Must match the server's MAX_SAMPLES_PER_POST.
const MAX_SAMPLES_PER_POST: usize = 512;
#[derive(Clone, Debug, Default)]
struct PendingSample {
at: i64,
cpu_pct: f64,
mem_used_mb: i64,
mem_total_mb: i64,
proc_count: i64,
uptime_secs: i64,
top_cpu_name: String,
top_cpu_pct: f64,
top_mem_name: String,
top_mem_mb: i64,
attempts: u32,
}
#[cfg(target_os = "windows")]
static QUEUE: Mutex<Vec<PendingSample>> = Mutex::new(Vec::new());
/// Kick off the metrics sampler. Safe to call multiple times — guarded
/// by an `AtomicBool` so a stray second call is a no-op.
pub fn start() {
#[cfg(not(target_os = "windows"))]
{
// Cross-platform stub; the implementation only runs on Windows
// because that's the only OS hello-agent ships on. Sysinfo is
// cross-platform, so a future Linux build can drop the cfg-gate
// and reuse this module unchanged.
}
#[cfg(target_os = "windows")]
{
use std::sync::atomic::{AtomicBool, Ordering};
static STARTED: AtomicBool = AtomicBool::new(false);
if STARTED.swap(true, Ordering::SeqCst) {
return;
}
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
log::warn!("perf: build runtime: {e}");
return;
}
};
rt.block_on(run_loop());
});
}
}
#[cfg(target_os = "windows")]
async fn run_loop() {
use hbb_common::sysinfo::System;
// One System instance, refreshed across iterations. Sysinfo's
// per-process CPU% is computed against the previous refresh, so a
// throwaway `System::new()` each tick would always read 0%.
let mut sys = System::new();
// Prime CPU + processes once so the first real sample isn't NaN.
// The MINIMUM_CPU_UPDATE_INTERVAL sleep is what sysinfo's docs
// recommend between back-to-back CPU refreshes; here we have the
// full SAMPLE_INTERVAL coming up so we just refresh-and-wait.
sys.refresh_cpu();
sys.refresh_processes();
tokio::time::sleep(SAMPLE_INTERVAL).await;
let mut flush_backoff = FLUSH_INTERVAL_BASE;
loop {
if let Some(sample) = collect_sample(&mut sys) {
QUEUE.lock().unwrap().push(sample);
}
match flush_once().await {
FlushOutcome::Idle | FlushOutcome::AllSent => {
flush_backoff = FLUSH_INTERVAL_BASE;
}
FlushOutcome::Failed => {
flush_backoff = (flush_backoff * 2).min(FLUSH_INTERVAL_MAX);
}
}
// We always sleep SAMPLE_INTERVAL — the network backoff slows
// the *retry* of pending events, not observation. Sampling
// continues at full cadence so a transient outage doesn't punch
// a hole in the chart on either side of the dropped window.
tokio::time::sleep(SAMPLE_INTERVAL.min(flush_backoff)).await;
}
}
#[cfg(target_os = "windows")]
fn collect_sample(sys: &mut hbb_common::sysinfo::System) -> Option<PendingSample> {
sys.refresh_cpu();
sys.refresh_memory();
sys.refresh_processes();
let at = hbb_common::chrono::Utc::now().timestamp();
let cpu_pct = sys.global_cpu_info().cpu_usage() as f64;
// Sysinfo returns bytes on every platform; the server schema stores
// MB to keep row sizes small at scale. Divide-then-cast bounds
// arithmetic to u64 territory.
let mem_total_mb = (sys.total_memory() / 1024 / 1024) as i64;
let mem_used_mb = (sys.used_memory() / 1024 / 1024) as i64;
let uptime_secs = sys.uptime() as i64;
// Per-process CPU% from sysinfo is normalised per core: a 4-thread
// process pinning 4 cores on a 4-core machine reads 400%. Divide
// by core count so the snapshot card's number matches the overall
// CPU% reading (both 0-100 of the whole machine).
let cpu_count = sys.cpus().len().max(1) as f64;
let mut top_cpu: Option<(&str, f32)> = None;
let mut top_mem: Option<(&str, u64)> = None;
let mut proc_count = 0i64;
for proc in sys.processes().values() {
proc_count += 1;
let name = proc.name();
// Some kernel-side rows show up with empty names on Windows;
// skip them so we don't ever render a top-CPU row with no
// label.
if name.is_empty() {
continue;
}
let cu = proc.cpu_usage();
if cu.is_finite() && cu > top_cpu.map(|(_, v)| v).unwrap_or(0.0) {
top_cpu = Some((name, cu));
}
let mu = proc.memory();
if mu > top_mem.map(|(_, v)| v).unwrap_or(0) {
top_mem = Some((name, mu));
}
}
let (top_cpu_name, top_cpu_pct) = top_cpu
.map(|(n, v)| (n.to_string(), (v as f64 / cpu_count).min(100.0)))
.unwrap_or_default();
let (top_mem_name, top_mem_mb) = top_mem
.map(|(n, v)| (n.to_string(), (v / 1024 / 1024) as i64))
.unwrap_or_default();
Some(PendingSample {
at,
cpu_pct,
mem_used_mb,
mem_total_mb,
proc_count,
uptime_secs,
top_cpu_name,
top_cpu_pct,
top_mem_name,
top_mem_mb,
attempts: 0,
})
}
#[cfg(target_os = "windows")]
enum FlushOutcome {
Idle,
AllSent,
Failed,
}
#[cfg(target_os = "windows")]
async fn flush_once() -> FlushOutcome {
let batch: Vec<PendingSample> = {
let mut q = QUEUE.lock().unwrap();
if q.is_empty() {
return FlushOutcome::Idle;
}
let take = q.len().min(MAX_SAMPLES_PER_POST);
q.drain(..take).collect()
};
match post_batch(&batch).await {
Ok(()) => FlushOutcome::AllSent,
Err(e) => {
log::warn!(
"perf: flush of {} sample(s) failed: {e:#}",
batch.len(),
);
let mut requeued: Vec<PendingSample> = batch
.into_iter()
.filter_map(|mut s| {
s.attempts = s.attempts.saturating_add(1);
if s.attempts >= DROP_AFTER {
log::warn!(
"perf: dropping sample after {} attempts: at={}",
s.attempts, s.at,
);
None
} else {
Some(s)
}
})
.collect();
let mut q = QUEUE.lock().unwrap();
let tail: Vec<PendingSample> = q.drain(..).collect();
requeued.extend(tail);
*q = requeued;
FlushOutcome::Failed
}
}
}
#[cfg(target_os = "windows")]
async fn post_batch(batch: &[PendingSample]) -> Result<()> {
let api = librustdesk::common::get_api_server(
hbb_common::config::Config::get_option("api-server"),
hbb_common::config::Config::get_option("custom-rendezvous-server"),
);
if api.is_empty() {
return Err(anyhow!("no api-server configured yet"));
}
let url = format!("{api}/api/agent/metrics");
let id = hbb_common::config::Config::get_id();
let uuid = librustdesk::common::encode64(hbb_common::get_uuid());
let samples: Vec<hbb_common::serde_json::Value> = batch
.iter()
.map(|s| {
hbb_common::serde_json::json!({
"at": s.at,
"cpu_pct": s.cpu_pct,
"mem_used_mb": s.mem_used_mb,
"mem_total_mb": s.mem_total_mb,
"proc_count": s.proc_count,
"uptime_secs": s.uptime_secs,
"top_cpu_name": s.top_cpu_name,
"top_cpu_pct": s.top_cpu_pct,
"top_mem_name": s.top_mem_name,
"top_mem_mb": s.top_mem_mb,
})
})
.collect();
let body = hbb_common::serde_json::json!({
"id": id,
"uuid": uuid,
"samples": samples,
})
.to_string();
let headers = librustdesk::hbbs_http::sign::build_signed_headers(
"POST",
"/api/agent/metrics",
body.as_bytes(),
)
.unwrap_or_default();
let resp = librustdesk::common::post_request(url, body, &headers)
.await
.map_err(|e| anyhow!("post: {e}"))?;
let trimmed = resp.trim();
if trimmed == "OK" || trimmed == "ID_NOT_FOUND" {
// ID_NOT_FOUND mirrors the unattended_password / login_events
// contract: server doesn't know the peer yet (rendezvous race
// on first boot). We drop the batch rather than retry forever;
// the next sample lands once /api/heartbeat has created the
// peer row.
Ok(())
} else {
Err(anyhow!("unexpected response: {trimmed}"))
}
}
+507
View File
@@ -0,0 +1,507 @@
// Windows event-log scraper for performance-related events.
//
// Pulls fresh entries from three channels Microsoft itself uses to
// flag "the OS noticed this machine was slow":
//
// * `Microsoft-Windows-Diagnostics-Performance/Operational` — boot,
// shutdown, standby, resume degradation (Event IDs 100/200/300/400
// family), each carrying total time + the component that caused it.
// * `Microsoft-Windows-Resource-Exhaustion-Detector/Operational` —
// Event ID 2004 fires when virtual memory hits the wall, with the
// top processes by working set.
// * `System` — IDs 41 (Kernel-Power unexpected reboot), 6008 (dirty
// shutdown), 1001 (BugCheck / BSOD).
//
// Cadence is intentionally low (5 min): these events are sparse — a
// healthy machine may produce zero per day, a sick one a handful.
//
// State machine per channel: a numeric RecordId cursor persisted in
// the agent config (`Config::set_option`). First run with no cursor
// pulls the trailing 7 days bounded by `MAX_FIRST_RUN_EVENTS`; every
// subsequent run pulls everything with `EventRecordID > cursor`. The
// cursor advances on observation, not on successful POST — see the
// `flush_once` comment for the tradeoff.
#![cfg_attr(not(target_os = "windows"), allow(dead_code))]
use anyhow::{anyhow, Result};
use serde::Deserialize;
use std::sync::Mutex;
use std::time::Duration;
/// How often to scrape. 5 min keeps the UI freshness reasonable
/// (operators looking at a complaint won't usually see a stale view)
/// without burning host CPU on PowerShell startup every minute.
const SCRAPE_INTERVAL: Duration = Duration::from_secs(5 * 60);
const FLUSH_INTERVAL_BASE: Duration = Duration::from_secs(60);
const FLUSH_INTERVAL_MAX: Duration = Duration::from_secs(15 * 60);
/// First-run lookback. A box that's been alive for months would
/// otherwise dump thousands of `Diagnostics-Performance` events on a
/// fresh install — useful in theory, but the UI shows the most recent
/// 20 and the older entries are mostly noise.
const MAX_FIRST_RUN_EVENTS: u32 = 100;
/// Per-scrape cap so a misbehaving event log can't blow up an
/// individual run. Anything beyond this on a single pass is dropped on
/// the floor and picked up on the next scrape (cursor advances to the
/// last seen, so we don't oscillate).
const MAX_PER_SCRAPE: u32 = 200;
/// Must match the server's MAX_EVENTS_PER_POST.
const MAX_EVENTS_PER_POST: usize = 128;
/// Drop pending events after this many retries — at 15 min cap this
/// is ~5 days, plenty for any realistic outage window.
const DROP_AFTER: u32 = 480;
/// One channel config: the WEL log name, the short provider tag stored
/// server-side (matches the `devices.perf_src_*` i18n keys), and an
/// optional ID allow-list. `None` means "everything in this channel";
/// `Some(&[…])` restricts to those event IDs (used for `System` to
/// avoid pulling boot / service-start chatter).
struct ChannelCfg {
provider: &'static str,
log_name: &'static str,
event_ids: Option<&'static [u32]>,
}
const CHANNELS: &[ChannelCfg] = &[
ChannelCfg {
provider: "diag-perf",
log_name: "Microsoft-Windows-Diagnostics-Performance/Operational",
event_ids: None,
},
ChannelCfg {
provider: "res-exh",
log_name: "Microsoft-Windows-Resource-Exhaustion-Detector/Operational",
event_ids: None,
},
ChannelCfg {
// Subset of `System` that's actually performance-relevant —
// 41 = Kernel-Power unexpected reboot, 6008 = dirty shutdown,
// 1001 = BugCheck (BSOD report). Adding more IDs is just a
// matter of extending this slice.
provider: "system",
log_name: "System",
event_ids: Some(&[41, 6008, 1001]),
},
];
/// JSON shape we ask PowerShell to emit. Mirrors `PerfEventIn` on the
/// server.
#[derive(Debug, Clone, Deserialize)]
struct RawEvent {
at: i64,
event_id: i64,
level: i64,
record_id: i64,
#[serde(default)]
summary: String,
#[serde(default)]
detail: String,
}
#[derive(Clone, Debug)]
struct PendingPerfEvent {
provider: &'static str,
at: i64,
event_id: i64,
level: i64,
record_id: i64,
summary: String,
detail: String,
attempts: u32,
}
#[cfg(target_os = "windows")]
static QUEUE: Mutex<Vec<PendingPerfEvent>> = Mutex::new(Vec::new());
pub fn start() {
#[cfg(not(target_os = "windows"))]
{}
#[cfg(target_os = "windows")]
{
use std::sync::atomic::{AtomicBool, Ordering};
static STARTED: AtomicBool = AtomicBool::new(false);
if STARTED.swap(true, Ordering::SeqCst) {
return;
}
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
log::warn!("perf-events: build runtime: {e}");
return;
}
};
rt.block_on(run_loop());
});
}
}
#[cfg(target_os = "windows")]
async fn run_loop() {
// Stagger the first scrape so we don't pile on the
// unattended_password POST + login_events first poll + sysinfo
// upload all at the same boot moment. PowerShell first invocation
// is ~1 s of CPU; doing it 30 s in instead of immediately is
// friendlier on cold-boot CPU load.
tokio::time::sleep(Duration::from_secs(30)).await;
let mut flush_backoff = FLUSH_INTERVAL_BASE;
loop {
for ch in CHANNELS {
match scrape_channel(ch) {
Ok(events) if !events.is_empty() => {
log::info!(
"perf-events: {} new from {}",
events.len(),
ch.provider,
);
QUEUE.lock().unwrap().extend(events);
}
Ok(_) => {}
Err(e) => {
log::warn!(
"perf-events: scrape {} failed: {e:#}",
ch.provider,
);
}
}
}
match flush_once().await {
FlushOutcome::Idle | FlushOutcome::AllSent => {
flush_backoff = FLUSH_INTERVAL_BASE;
}
FlushOutcome::Failed => {
flush_backoff = (flush_backoff * 2).min(FLUSH_INTERVAL_MAX);
}
}
tokio::time::sleep(SCRAPE_INTERVAL.min(flush_backoff)).await;
}
}
// ─────────────────────────── per-channel scrape ───────────────────────────
#[cfg(target_os = "windows")]
fn scrape_channel(ch: &ChannelCfg) -> Result<Vec<PendingPerfEvent>> {
let cursor = read_cursor(ch.provider);
let script = build_script(ch, cursor);
let stdout = run_powershell(&script)?;
if stdout.is_empty() {
return Ok(Vec::new());
}
// ConvertTo-Json with a single-element array still emits a JSON
// object (PowerShell's "unrolling" quirk); coerce both shapes.
let trimmed = stdout.trim();
if trimmed == "[]" || trimmed.is_empty() {
return Ok(Vec::new());
}
let raw: Vec<RawEvent> = match hbb_common::serde_json::from_str(trimmed) {
Ok(v) => v,
Err(_) => {
// Single-object case
match hbb_common::serde_json::from_str::<RawEvent>(trimmed) {
Ok(single) => vec![single],
Err(e) => {
return Err(anyhow!(
"PowerShell output is not valid JSON: {e}; first 200 chars: {:.200}",
trimmed
));
}
}
}
};
let mut max_record_id = cursor;
let mut out = Vec::with_capacity(raw.len());
for ev in raw {
if (ev.record_id as u64) > max_record_id {
max_record_id = ev.record_id as u64;
}
out.push(PendingPerfEvent {
provider: ch.provider,
at: ev.at,
event_id: ev.event_id,
level: ev.level,
record_id: ev.record_id,
summary: ev.summary,
detail: ev.detail,
attempts: 0,
});
}
// Advance the cursor on observation, not on successful POST. The
// tradeoff: an agent that crashes between observing and POSTing
// loses those rows from the UI. Windows still has them in the
// event log, so the operator can fall back to Event Viewer if
// they really need them; we prefer that to repeatedly re-observing
// a backlog the server has already taken (unique-index dedup
// would absorb it, but the agent's queue would grow unbounded
// every scrape).
if max_record_id > cursor {
write_cursor(ch.provider, max_record_id);
}
Ok(out)
}
/// Build the PowerShell script for one channel. Two shapes:
///
/// * `cursor == 0` (first run): `FilterHashtable` with a 7-day
/// `StartTime` and the optional ID allow-list. The hashtable form
/// is the only one that accepts both a time bound and an `Id`
/// array in one call.
/// * `cursor > 0`: `FilterXPath` with `EventRecordID > $cursor` and
/// the optional ID-list expanded to `(EventID=A or EventID=B …)`.
#[cfg(target_os = "windows")]
fn build_script(ch: &ChannelCfg, cursor: u64) -> String {
// PowerShell-quote the log name (single-quote escape = doubled
// single quote).
let q = |s: &str| s.replace('\'', "''");
let log_name_quoted = q(ch.log_name);
let filter_clause = if cursor == 0 {
// FilterHashtable + StartTime + optional Id array.
match ch.event_ids {
Some(ids) => {
let id_list = ids
.iter()
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join(",");
format!(
"-FilterHashtable @{{LogName='{ln}'; StartTime=(Get-Date).AddDays(-7); Id=@({ids})}} -MaxEvents {max}",
ln = log_name_quoted,
ids = id_list,
max = MAX_FIRST_RUN_EVENTS,
)
}
None => format!(
"-FilterHashtable @{{LogName='{ln}'; StartTime=(Get-Date).AddDays(-7)}} -MaxEvents {max}",
ln = log_name_quoted,
max = MAX_FIRST_RUN_EVENTS,
),
}
} else {
// FilterXPath. Note PowerShell expands `$cursor` from the
// outer script, so we splat the literal value into the XPath.
let id_clause = match ch.event_ids {
Some(ids) => {
let or = ids
.iter()
.map(|i| format!("System/EventID={i}"))
.collect::<Vec<_>>()
.join(" or ");
format!(" and ({or})")
}
None => String::new(),
};
// Double-curly to escape format!'s own `{}` interpolation.
format!(
"-LogName '{ln}' -FilterXPath \"*[System/EventRecordID>{cur}{id}]\" -MaxEvents {max}",
ln = log_name_quoted,
cur = cursor,
id = id_clause,
max = MAX_PER_SCRAPE,
)
};
// Single-quoted PowerShell here-doc would escape too aggressively;
// we stick to plain string concatenation. The `try / catch` block
// returns '[]' on any failure so the Rust side gets a parseable
// empty array rather than a stderr blob.
format!(
r#"$ErrorActionPreference = 'SilentlyContinue'
try {{
$events = Get-WinEvent {filter} 2>$null
if ($null -eq $events) {{ '[]' ; exit 0 }}
$arr = @($events | Sort-Object RecordId | ForEach-Object {{
$msg = $_.Message
if ($null -eq $msg) {{ $msg = '' }}
$oneline = ($msg -replace "(`r?`n)+", " ").Trim()
if ($oneline.Length -gt 300) {{ $oneline = $oneline.Substring(0,300) }}
$detail = $msg
if ($detail.Length -gt 4000) {{ $detail = $detail.Substring(0,4000) }}
[PSCustomObject]@{{
at = [int64](([System.DateTimeOffset]$_.TimeCreated.ToUniversalTime()).ToUnixTimeSeconds())
event_id = $_.Id
level = $_.Level
record_id = $_.RecordId
summary = $oneline
detail = $detail
}}
}})
$arr | ConvertTo-Json -Depth 3 -Compress
}} catch {{
'[]'
}}
"#,
filter = filter_clause,
)
}
#[cfg(target_os = "windows")]
fn run_powershell(script: &str) -> Result<String> {
use std::os::windows::process::CommandExt;
use std::process::Command;
// Matches inventory.rs — hides the brief console flash when the
// agent runs interactively (dev mode); no effect in service mode.
const CREATE_NO_WINDOW: u32 = 0x08000000;
let output = Command::new("powershell.exe")
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
script,
])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|e| anyhow!("spawn powershell: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!(
"powershell exited {:?}: {}",
output.status.code(),
stderr.trim()
));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
// ─────────────────────────────── cursor I/O ───────────────────────────────
#[cfg(target_os = "windows")]
fn cursor_key(provider: &str) -> String {
format!("perf-event-cursor-{provider}")
}
#[cfg(target_os = "windows")]
fn read_cursor(provider: &str) -> u64 {
let raw = hbb_common::config::Config::get_option(&cursor_key(provider));
raw.parse::<u64>().unwrap_or(0)
}
#[cfg(target_os = "windows")]
fn write_cursor(provider: &str, value: u64) {
hbb_common::config::Config::set_option(
cursor_key(provider),
value.to_string(),
);
}
// ───────────────────────────────── flush ──────────────────────────────────
#[cfg(target_os = "windows")]
enum FlushOutcome {
Idle,
AllSent,
Failed,
}
#[cfg(target_os = "windows")]
async fn flush_once() -> FlushOutcome {
let batch: Vec<PendingPerfEvent> = {
let mut q = QUEUE.lock().unwrap();
if q.is_empty() {
return FlushOutcome::Idle;
}
let take = q.len().min(MAX_EVENTS_PER_POST);
q.drain(..take).collect()
};
match post_batch(&batch).await {
Ok(()) => FlushOutcome::AllSent,
Err(e) => {
log::warn!(
"perf-events: flush of {} event(s) failed: {e:#}",
batch.len(),
);
let mut requeued: Vec<PendingPerfEvent> = batch
.into_iter()
.filter_map(|mut ev| {
ev.attempts = ev.attempts.saturating_add(1);
if ev.attempts >= DROP_AFTER {
log::warn!(
"perf-events: dropping event after {} attempts: \
provider={} record_id={}",
ev.attempts, ev.provider, ev.record_id,
);
None
} else {
Some(ev)
}
})
.collect();
let mut q = QUEUE.lock().unwrap();
let tail: Vec<PendingPerfEvent> = q.drain(..).collect();
requeued.extend(tail);
*q = requeued;
FlushOutcome::Failed
}
}
}
#[cfg(target_os = "windows")]
async fn post_batch(batch: &[PendingPerfEvent]) -> Result<()> {
let api = librustdesk::common::get_api_server(
hbb_common::config::Config::get_option("api-server"),
hbb_common::config::Config::get_option("custom-rendezvous-server"),
);
if api.is_empty() {
return Err(anyhow!("no api-server configured yet"));
}
let url = format!("{api}/api/agent/perf-events");
let id = hbb_common::config::Config::get_id();
let uuid = librustdesk::common::encode64(hbb_common::get_uuid());
let events: Vec<hbb_common::serde_json::Value> = batch
.iter()
.map(|ev| {
hbb_common::serde_json::json!({
"at": ev.at,
"provider": ev.provider,
"event_id": ev.event_id,
"level": ev.level,
"record_id": ev.record_id,
"summary": ev.summary,
"detail_json": ev.detail,
})
})
.collect();
let body = hbb_common::serde_json::json!({
"id": id,
"uuid": uuid,
"events": events,
})
.to_string();
let headers = librustdesk::hbbs_http::sign::build_signed_headers(
"POST",
"/api/agent/perf-events",
body.as_bytes(),
)
.unwrap_or_default();
let resp = librustdesk::common::post_request(url, body, &headers)
.await
.map_err(|e| anyhow!("post: {e}"))?;
let trimmed = resp.trim();
if trimmed == "OK" || trimmed == "ID_NOT_FOUND" {
Ok(())
} else {
Err(anyhow!("unexpected response: {trimmed}"))
}
}
+287 -67
View File
@@ -3,7 +3,7 @@
// Three responsibilities: // Three responsibilities:
// //
// 1. `install()` — copy the binary to %ProgramFiles%\hello-agent, mirror the // 1. `install()` — copy the binary to %ProgramFiles%\hello-agent, mirror the
// calling user's `HelloAgent.toml` into the LocalService-effective // calling user's `hello-agent.toml` into the LocalService-effective
// config dir so the SYSTEM service inherits the --config blob, register // config dir so the SYSTEM service inherits the --config blob, register
// the service with the SCM pointing at the installed exe, and start it. // the service with the SCM pointing at the installed exe, and start it.
// Idempotent. // Idempotent.
@@ -29,14 +29,21 @@ use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use windows_service::service::{ use windows_service::service::{
ServiceAccess, ServiceControl, ServiceControlAccept, ServiceErrorControl, ServiceExitCode, ServiceAccess, ServiceAction, ServiceActionType, ServiceControl, ServiceControlAccept,
ServiceErrorControl, ServiceExitCode, ServiceFailureActions, ServiceFailureResetPeriod,
ServiceInfo, ServiceStartType, ServiceState, ServiceStatus, ServiceType, ServiceInfo, ServiceStartType, ServiceState, ServiceStatus, ServiceType,
}; };
use windows_service::service_control_handler::{self, ServiceControlHandlerResult}; use windows_service::service_control_handler::{self, ServiceControlHandlerResult};
use windows_service::service_dispatcher; use windows_service::service_dispatcher;
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess}; use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
const SERVICE_NAME: &str = "HelloAgent"; /// Internal service name registered with the SCM. Must equal `crate::APP_NAME`
/// because upstream `librustdesk::platform::is_self_service_running` queries
/// `is_service_running(&crate::get_app_name())` — i.e. it looks up the
/// service whose name *is* the app name. If these diverge, the `--update`
/// path's `sc stop` / `sc start` use the wrong name and the service is
/// left in a Stopped state after a self-update.
const SERVICE_NAME: &str = crate::APP_NAME;
const DISPLAY_NAME: &str = "HelloAgent Remote Support"; const DISPLAY_NAME: &str = "HelloAgent Remote Support";
const SERVICE_DESCRIPTION: &str = const SERVICE_DESCRIPTION: &str =
"HelloAgent — headless remote-support agent (RustDesk-protocol-compatible). \ "HelloAgent — headless remote-support agent (RustDesk-protocol-compatible). \
@@ -47,6 +54,11 @@ const SERVICE_TYPE: ServiceType = ServiceType::OWN_PROCESS;
const INSTALL_SUBDIR: &str = "hello-agent"; const INSTALL_SUBDIR: &str = "hello-agent";
const INSTALLED_EXE_NAME: &str = "hello-agent.exe"; const INSTALLED_EXE_NAME: &str = "hello-agent.exe";
/// Display name used for the Windows Firewall rule. Stable across versions
/// so `--uninstall` (or a re-install that clears it before re-adding) can
/// find and delete the prior entry by name.
const FIREWALL_RULE_NAME: &str = "HelloAgent";
// ----------------------------- paths --------------------------------------- // ----------------------------- paths ---------------------------------------
/// `%ProgramFiles%\hello-agent`. Falls back to `C:\Program Files\hello-agent` /// `%ProgramFiles%\hello-agent`. Falls back to `C:\Program Files\hello-agent`
@@ -68,9 +80,9 @@ fn install_dir() -> PathBuf {
/// Note the trailing `config` segment: `directories_next::ProjectDirs`, /// Note the trailing `config` segment: `directories_next::ProjectDirs`,
/// which hbb_common uses on Windows, appends a literal `\config` to the /// which hbb_common uses on Windows, appends a literal `\config` to the
/// app's roaming dir (so the user-side path is /// app's roaming dir (so the user-side path is
/// `%APPDATA%\HelloAgent\config\HelloAgent.toml`, not /// `%APPDATA%\hello-agent\config\hello-agent.toml`, not
/// `…\HelloAgent\…`). The SYSTEM-side path follows the same convention. /// `…\hello-agent\…`). The SYSTEM-side path follows the same convention.
/// The `HelloAgent` segment is sourced from `crate::APP_NAME` so it stays /// The `hello-agent` segment is sourced from `crate::APP_NAME` so it stays
/// in lockstep with the `APP_NAME` we install into hbb_common at startup. /// in lockstep with the `APP_NAME` we install into hbb_common at startup.
fn service_config_dir() -> PathBuf { fn service_config_dir() -> PathBuf {
let system_root = std::env::var_os("SystemRoot") let system_root = std::env::var_os("SystemRoot")
@@ -88,11 +100,15 @@ fn service_config_dir() -> PathBuf {
// ----------------------------- install -------------------------------------- // ----------------------------- install --------------------------------------
pub fn install() -> Result<()> { pub fn install() -> Result<()> {
// Probe-open the SCM with CREATE_SERVICE rights up front; if the caller
// isn't elevated this fails with ERROR_ACCESS_DENIED (raw_os_error == 5)
// and we surface a single human-readable message instead of bubbling
// up a Win32 errno string. Anything else propagates as-is.
let scm = ServiceManager::local_computer( let scm = ServiceManager::local_computer(
None::<&str>, None::<&str>,
ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE, ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
) )
.context("open SCM")?; .map_err(map_scm_open_error)?;
// 1. If a previous install left a running service, stop it before we // 1. If a previous install left a running service, stop it before we
// overwrite its binary. Otherwise the file copy in step 2 fails // overwrite its binary. Otherwise the file copy in step 2 fails
@@ -106,8 +122,8 @@ pub fn install() -> Result<()> {
// idempotent / usable as an in-place update — without it, the // idempotent / usable as an in-place update — without it, the
// `stage_binary` file copy below fails with "access denied" // `stage_binary` file copy below fails with "access denied"
// whenever a `--cm` child is still holding the old exe open. // whenever a `--cm` child is still holding the old exe open.
// `kill_orphan_processes` uses taskkill with `/FI "PID ne <ours>"` // `kill_orphan_processes` walks the process table via sysinfo and
// so it never kills the running installer. // filters out our own pid so the installer doesn't suicide.
kill_orphan_processes(); kill_orphan_processes();
// 2. Pin the binary to %ProgramFiles%\hello-agent. The user might be // 2. Pin the binary to %ProgramFiles%\hello-agent. The user might be
@@ -120,17 +136,17 @@ pub fn install() -> Result<()> {
// first, fall back to popup). Older hello-agent installs wrote // first, fall back to popup). Older hello-agent installs wrote
// "click" here, which disabled the password path; clearing it // "click" here, which disabled the password path; clearing it
// every install makes upgrades idempotent. These write into the // every install makes upgrades idempotent. These write into the
// *calling user's* %APPDATA%\HelloAgent\ — we mirror the result // *calling user's* %APPDATA%\hello-agent\ — we mirror the result
// into the service's effective dir in step 4. // into the service's effective dir in step 4.
hbb_common::config::Config::set_option("stop-service".into(), "".into()); hbb_common::config::Config::set_option("stop-service".into(), "".into());
hbb_common::config::Config::set_option("approve-mode".into(), "".into()); hbb_common::config::Config::set_option("approve-mode".into(), "".into());
// 4. Mirror the calling user's `HelloAgent.toml` / `HelloAgent2.toml` // 4. Mirror the calling user's `hello-agent.toml` / `hello-agent2.toml`
// into the LocalService-effective config root that the SYSTEM // into the LocalService-effective config root that the SYSTEM
// service will actually read. Without this, --config writes to e.g. // service will actually read. Without this, --config writes to e.g.
// C:\Users\Admin\AppData\Roaming\HelloAgent\, but the service runs // C:\Users\Admin\AppData\Roaming\hello-agent\, but the service runs
// as LocalSystem and (via hbb_common's `patch()`) reads from // as LocalSystem and (via hbb_common's `patch()`) reads from
// C:\Windows\ServiceProfiles\LocalService\AppData\Roaming\HelloAgent\. // C:\Windows\ServiceProfiles\LocalService\AppData\Roaming\hello-agent\.
if let Err(e) = mirror_config_to_service_dir() { if let Err(e) = mirror_config_to_service_dir() {
log::warn!( log::warn!(
"could not mirror config to service dir ({e:#}); the service may not see --config until first heartbeat" "could not mirror config to service dir ({e:#}); the service may not see --config until first heartbeat"
@@ -183,6 +199,60 @@ pub fn install() -> Result<()> {
let _ = svc.set_description(SERVICE_DESCRIPTION); let _ = svc.set_description(SERVICE_DESCRIPTION);
// 5b. Configure SCM auto-restart on unexpected exit. Without this,
// a panic in the `--service` supervisor leaves the agent permanently
// Stopped until the host reboots. The schedule restarts after
// 5s, 30s, 60s and gives up after that; the failure-count reset
// window is one day, so transient hiccups don't accumulate and
// stable hosts converge back to "running" within a minute.
//
// `set_failure_actions_on_non_crash_failures(true)` is what makes
// these actions fire when the service exits cleanly with a non-zero
// code (panic via abort, for instance), not just on outright
// crashes detected by the SCM. Both are best-effort; the SCM
// accepts the call but doesn't error if the underlying ChangeServiceConfig2
// fails for some reason — we log and continue.
let failure_actions = ServiceFailureActions {
reset_period: ServiceFailureResetPeriod::After(Duration::from_secs(60 * 60 * 24)),
reboot_msg: None,
command: None,
actions: Some(vec![
ServiceAction {
action_type: ServiceActionType::Restart,
delay: Duration::from_secs(5),
},
ServiceAction {
action_type: ServiceActionType::Restart,
delay: Duration::from_secs(30),
},
ServiceAction {
action_type: ServiceActionType::Restart,
delay: Duration::from_secs(60),
},
]),
};
if let Err(e) = svc.update_failure_actions(failure_actions) {
log::warn!("could not set SCM failure actions ({e}); auto-restart-on-crash disabled");
}
if let Err(e) = svc.set_failure_actions_on_non_crash_failures(true) {
log::warn!(
"could not enable failure actions for clean-exit-with-error ({e}); only hard crashes will trigger restart"
);
}
// 5c. Allow inbound TCP/UDP to hello-agent.exe at the Windows Firewall.
// A vanilla deploy doesn't actually need it (the rendezvous/relay
// connections are outbound), but operators who enable `direct-server`
// (TCP 21118) or `enable-lan-discovery` (UDP 21119) via the --config
// blob need this rule or those features silently fail. Cheaper to
// add it always than to discover at support-call time that the
// deploy never matched a firewall rule. Best-effort: if netsh
// isn't present (extremely stripped-down server SKUs) we log and
// continue.
if let Err(e) = install_firewall_rule(&target_exe) {
log::warn!("could not install firewall rule ({e:#}); inbound connections may be blocked");
}
// 6. Start the service. (Step 1 already stopped any prior instance.) // 6. Start the service. (Step 1 already stopped any prior instance.)
svc.start::<&str>(&[]).context("start service")?; svc.start::<&str>(&[]).context("start service")?;
log::info!( log::info!(
@@ -250,7 +320,7 @@ fn stage_binary() -> Result<PathBuf> {
Ok(dest) Ok(dest)
} }
/// Copy the calling user's `HelloAgent.toml` + `HelloAgent2.toml` into /// Copy the calling user's `hello-agent.toml` + `hello-agent2.toml` into
/// the LocalService-effective config dir so the SYSTEM service sees them. /// the LocalService-effective config dir so the SYSTEM service sees them.
fn mirror_config_to_service_dir() -> Result<()> { fn mirror_config_to_service_dir() -> Result<()> {
let dest_dir = service_config_dir(); let dest_dir = service_config_dir();
@@ -272,7 +342,7 @@ fn mirror_config_to_service_dir() -> Result<()> {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => { Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
// Calling user never had this file (e.g. --install without // Calling user never had this file (e.g. --install without
// --config, or first ever run on this machine, or the user // --config, or first ever run on this machine, or the user
// wiped %APPDATA%\HelloAgent\ between tests). Logged at // wiped %APPDATA%\hello-agent\ between tests). Logged at
// info so the post-install log shows clearly which toml // info so the post-install log shows clearly which toml
// files were available and which weren't. // files were available and which weren't.
log::info!( log::info!(
@@ -298,6 +368,16 @@ fn mirror_config_to_service_dir() -> Result<()> {
// ----------------------------- uninstall ------------------------------------ // ----------------------------- uninstall ------------------------------------
pub fn uninstall() -> Result<()> { pub fn uninstall() -> Result<()> {
// Probe-open the SCM with the rights we'll need (CONNECT for the SCM
// handle itself, and DELETE on the per-service open below). The same
// elevation-error mapping as install() — surface a single clear message
// when the operator forgot the elevated prompt.
let scm = ServiceManager::local_computer(
None::<&str>,
ServiceManagerAccess::CONNECT,
)
.map_err(map_scm_open_error)?;
// Kill every hello-agent.exe process except ourselves *first*. We can't // Kill every hello-agent.exe process except ourselves *first*. We can't
// rely on the SCM Stop control alone because the `--cm` child spawned // rely on the SCM Stop control alone because the `--cm` child spawned
// via `run_as_user` runs under the logged-in user's token, not SYSTEM, // via `run_as_user` runs under the logged-in user's token, not SYSTEM,
@@ -305,15 +385,9 @@ pub fn uninstall() -> Result<()> {
// Doing this up front means the SCM stop below is usually a no-op // Doing this up front means the SCM stop below is usually a no-op
// (service process already gone) and the rmdir at the end no longer // (service process already gone) and the rmdir at the end no longer
// races a lingering child holding hello-agent.exe open. Our own PID // races a lingering child holding hello-agent.exe open. Our own PID
// is excluded via taskkill's `/FI` so the uninstaller doesn't suicide. // is excluded via the sysinfo filter so the uninstaller doesn't suicide.
kill_orphan_processes(); kill_orphan_processes();
let scm = ServiceManager::local_computer(
None::<&str>,
ServiceManagerAccess::CONNECT,
)
.context("open SCM")?;
match scm.open_service( match scm.open_service(
SERVICE_NAME, SERVICE_NAME,
ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE, ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
@@ -343,9 +417,17 @@ pub fn uninstall() -> Result<()> {
Err(e) => return Err(anyhow!("open_service: {e}")), Err(e) => return Err(anyhow!("open_service: {e}")),
} }
// Remove the firewall rule we installed (best-effort). netsh delete is
// idempotent — if the rule was never there (or someone manually removed
// it) netsh returns 1 with "No rules match the specified criteria",
// which we treat as success.
if let Err(e) = delete_firewall_rule() {
log::warn!("could not delete firewall rule ({e:#}); remove it manually if needed");
}
cleanup_install_dir(); cleanup_install_dir();
// We deliberately do NOT delete the LocalService config dir here. // We deliberately do NOT delete the LocalService config dir here.
// `HelloAgent.toml` in that directory holds the agent's id + keypair, // `hello-agent.toml` in that directory holds the agent's id + keypair,
// which the rustdesk-server / rendezvous server has registered against // which the rustdesk-server / rendezvous server has registered against
// the agent's id. Wiping it forces the next --install to generate // the agent's id. Wiping it forces the next --install to generate
// fresh keys, which the rendezvous server's cached entry (and any // fresh keys, which the rendezvous server's cached entry (and any
@@ -354,7 +436,7 @@ pub fn uninstall() -> Result<()> {
// the connection sits idle until the peer times out. // the connection sits idle until the peer times out.
// //
// Operators who want a true hard wipe can run: // Operators who want a true hard wipe can run:
// rmdir /s /q "%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\HelloAgent" // rmdir /s /q "%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\hello-agent"
// and then delete the device record from the rustdesk-server admin UI. // and then delete the device record from the rustdesk-server admin UI.
log::info!("preserved LocalService config dir to keep agent keys/id stable across reinstalls"); log::info!("preserved LocalService config dir to keep agent keys/id stable across reinstalls");
Ok(()) Ok(())
@@ -365,58 +447,175 @@ pub fn uninstall() -> Result<()> {
/// old `--cm` child holding the exe open) and `--uninstall` (so the /// old `--cm` child holding the exe open) and `--uninstall` (so the
/// rmdir at the end isn't racing a lingering child). /// rmdir at the end isn't racing a lingering child).
/// ///
/// Shells out to the built-in `taskkill` rather than re-implementing the /// Walks the process table via `hbb_common::sysinfo` (the same enumerator
/// Toolhelp32 enumeration in winapi: taskkill ships in every Windows /// the vendored rustdesk uses internally) and calls `Process::kill` —
/// install since XP, runs in milliseconds, and the `/FI "PID ne <ours>"` /// equivalent to `TerminateProcess` under the hood. After issuing the
/// filter handles the "don't suicide ourselves" requirement declaratively. /// kills we poll the process table for actual exit rather than guessing
/// /// at a 500 ms sleep: `TerminateProcess` marks the process as exited but
/// Exit code 128 from taskkill means "no matching processes" — common /// the kernel takes a variable amount of time to release the image-file
/// case when there's no orphan to clean up — and we treat it the same /// handle, and we only want to return once those handles are gone (so
/// as success. Anything else gets logged but does not fail the caller. /// the install-time file copy and uninstall-time rmdir don't race a
/// half-finalized victim).
fn kill_orphan_processes() { fn kill_orphan_processes() {
// hbb_common pulls the rustdesk-org sysinfo 0.29 fork, which exposes
// System/Process/Pid with inherent methods (no SystemExt/ProcessExt
// trait imports needed — that style was removed when this fork
// diverged from upstream 0.30).
use hbb_common::sysinfo::{Pid, System};
let our_pid = std::process::id(); let our_pid = std::process::id();
let pid_filter = format!("PID ne {our_pid}"); let target = INSTALLED_EXE_NAME;
let output = std::process::Command::new("taskkill")
.args([ let mut system = System::new();
"/F", system.refresh_processes();
"/IM", let victims: Vec<Pid> = system
INSTALLED_EXE_NAME, .processes()
"/FI", .iter()
&pid_filter, .filter(|(pid, p)| {
]) pid.as_u32() != our_pid && p.name().eq_ignore_ascii_case(target)
.output(); })
match output { .map(|(pid, _)| *pid)
Ok(out) => { .collect();
let code = out.status.code();
let stdout = String::from_utf8_lossy(&out.stdout); if victims.is_empty() {
let stderr = String::from_utf8_lossy(&out.stderr); log::info!("no orphan {target} processes to kill");
if out.status.success() { return;
log::info!( }
"taskkill killed orphan {INSTALLED_EXE_NAME} processes (excluding pid {our_pid}): {}",
stdout.trim() let killed: Vec<u32> = victims
); .iter()
// TerminateProcess is synchronous w.r.t. the kernel marking .filter_map(|pid| {
// the process as exited, but kernel-mode finalization let process = system.process(*pid)?;
// (releasing file handles, paging out the image section) if process.kill() {
// can lag by up to a few hundred ms. The rmdir that follows Some(pid.as_u32())
// races against this: without the pause, an immediate
// remove_dir_all can still see "file in use" on the just-
// killed process's exe.
std::thread::sleep(Duration::from_millis(500));
} else if code == Some(128) {
log::info!("no orphan {INSTALLED_EXE_NAME} processes to kill");
} else { } else {
log::warn!("Process::kill failed for pid {}", pid.as_u32());
None
}
})
.collect();
log::info!("issued kill on {} {target} process(es): {killed:?}", killed.len());
// Poll for actual exit. 5 s ceiling is generous (TerminateProcess
// usually finalizes within tens of ms) but cheap — we only burn it
// when the kernel really is dragging its feet, which is the exact
// case the old `sleep(500ms)` heuristic couldn't handle.
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
system.refresh_processes();
let still_alive = victims.iter().any(|pid| system.process(*pid).is_some());
if !still_alive {
return;
}
std::thread::sleep(Duration::from_millis(50));
}
log::warn!( log::warn!(
"taskkill returned {code:?}: stdout={} stderr={}", "some {target} processes were still alive after 5 s; subsequent file ops may fail with sharing violation"
stdout.trim(), );
stderr.trim(), }
/// Translate a `windows_service::Error` from `ServiceManager::local_computer`
/// into a friendlier user-facing message. ERROR_ACCESS_DENIED (Win32 err 5)
/// is the overwhelmingly common case — operator forgot to elevate — and
/// deserves a single clear line rather than the raw Win32 errno string.
fn map_scm_open_error(e: windows_service::Error) -> anyhow::Error {
if let windows_service::Error::Winapi(ref ioe) = e {
if ioe.raw_os_error() == Some(5) {
return anyhow!(
"requires an elevated (Administrator) prompt — re-run from \"Run as administrator\""
); );
} }
} }
Err(e) => { anyhow!("open SCM: {e}")
log::warn!("could not invoke taskkill: {e}"); }
/// Add a Windows Firewall rule allowing inbound TCP/UDP to the installed
/// hello-agent.exe. Idempotent: we delete any prior rule by the same name
/// first, so re-running --install (or upgrading in place) doesn't pile up
/// duplicate entries in the firewall's per-name list.
///
/// We use the program-scoped form (`program=<path>`) rather than port-scoped
/// rules because hello-agent's optional listeners (direct-server TCP 21118,
/// LAN-discovery UDP 21119) are gated on operator-controlled config flags;
/// rule-by-program covers whatever ports the agent actually decides to bind.
fn install_firewall_rule(exe_path: &PathBuf) -> Result<()> {
// Drop any pre-existing rule first; netsh quietly succeeds-with-exit-1
// when nothing matches, so we ignore the result.
let _ = run_netsh(&[
"advfirewall",
"firewall",
"delete",
"rule",
&format!("name={FIREWALL_RULE_NAME}"),
]);
let program_arg = format!(
"program={}",
exe_path.to_str().ok_or_else(|| anyhow!(
"non-UTF-8 install path can't be passed to netsh: {}",
exe_path.display()
))?
);
let status = run_netsh(&[
"advfirewall",
"firewall",
"add",
"rule",
&format!("name={FIREWALL_RULE_NAME}"),
"dir=in",
"action=allow",
"enable=yes",
"profile=any",
&program_arg,
])?;
if !status {
return Err(anyhow!("netsh add rule failed"));
} }
log::info!(
"added firewall rule '{FIREWALL_RULE_NAME}' for {}",
exe_path.display()
);
Ok(())
}
/// Remove the hello-agent firewall rule by name. netsh exits non-zero when
/// no rule matches; we translate that into success since the post-condition
/// (no rule by that name) is what we want anyway.
fn delete_firewall_rule() -> Result<()> {
let status = run_netsh(&[
"advfirewall",
"firewall",
"delete",
"rule",
&format!("name={FIREWALL_RULE_NAME}"),
]);
match status {
Ok(_) => {
log::info!("removed firewall rule '{FIREWALL_RULE_NAME}' (or none was present)");
Ok(())
} }
Err(e) => Err(e),
}
}
/// Shell out to netsh.exe with the given args. Returns Ok(true) on
/// exit-0, Ok(false) on a non-zero exit that *netsh itself* produced
/// (e.g. "rule already exists" or "no rules match"), and Err only when
/// the binary couldn't be invoked at all (PATH stripped, etc.).
fn run_netsh(args: &[&str]) -> Result<bool> {
let out = std::process::Command::new("netsh")
.args(args)
.output()
.context("invoke netsh")?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
log::debug!(
"netsh {args:?} exited {:?}: {}",
out.status.code(),
stderr.trim()
);
}
Ok(out.status.success())
} }
/// Remove %ProgramFiles%\hello-agent. Best-effort: if the user ran /// Remove %ProgramFiles%\hello-agent. Best-effort: if the user ran
@@ -505,6 +704,27 @@ fn service_main_inner() -> Result<()> {
// can race the rendezvous registration done by `--server`). // can race the rendezvous registration done by `--server`).
crate::unattended_password::rotate_and_report(); crate::unattended_password::rotate_and_report();
// Start the user-login tracker. Polls the WTS session table every
// few seconds, diffs against its previous snapshot, and POSTs
// logon/logoff events to the admin API. Independent background
// thread + Tokio runtime; lives for the service lifetime, no
// shutdown hook (the SCM termination is enough).
crate::login_events::start();
// Start the continuous performance sampler: one CPU / memory /
// top-process sample per minute, posted in batches to
// /api/agent/metrics. Powers the device-detail Performance card
// and 24 h sparkline.
crate::perf::start();
// Start the Windows-event-log perf-event scraper: pulls boot /
// shutdown / sleep degradation, memory exhaustion, BSOD and
// unexpected-reboot events from the OS-managed channels and
// POSTs them to /api/agent/perf-events. Persists a per-channel
// RecordId cursor in the agent config so a restart doesn't
// re-emit the whole history.
crate::perf_events::start();
// Worker process handle. Killed on Stop, replaced on session change. // Worker process handle. Killed on Stop, replaced on session change.
// `last_state` carries (session_id, had_user). The `had_user` bit is // `last_state` carries (session_id, had_user). The `had_user` bit is
// what forces a respawn when a user logs in to a session we're // what forces a respawn when a user logs in to a session we're
+12 -1
View File
@@ -111,7 +111,18 @@ async fn try_report(password: &str) -> Result<()> {
}) })
.to_string(); .to_string();
let resp = librustdesk::common::post_request(url, body, "") // Same per-peer signature gate as heartbeat / sysinfo. Once this peer's
// `managed` flag has flipped to 1 server-side, unsigned posts here
// would be rejected — and we want unattended-password to keep landing
// through the same TOFU lifecycle as the other endpoints.
let headers = librustdesk::hbbs_http::sign::build_signed_headers(
"POST",
"/api/unattended-password",
body.as_bytes(),
)
.unwrap_or_default();
let resp = librustdesk::common::post_request(url, body, &headers)
.await .await
.map_err(|e| anyhow!("post: {e}"))?; .map_err(|e| anyhow!("post: {e}"))?;
let trimmed = resp.trim(); let trimmed = resp.trim();
+278
View File
@@ -0,0 +1,278 @@
//! Locale-independent Wi-Fi inventory via the Win32 Native Wi-Fi API
//! (`wlanapi.dll`).
//!
//! Replaces the previous `netsh wlan show …` text-parsing approach.
//! `netsh` partially localizes its output — labels like
//! `Authentication` / `Authentifizierung` / `Autenticación` / `Autentificare`
//! shift with the user's display language, and our regexes silently
//! dropped fields on non-English Windows. The fleet runs four
//! languages (en, de, es, ro), so we move to the structured API:
//!
//! * SSIDs come back as raw `UCHAR[32]` bytes — no localization
//! possible.
//! * Authentication comes back as a `DOT11_AUTH_ALGORITHM` enum
//! (a small integer); we map to our own English labels.
//! * Cipher / signal quality are likewise plain enums / integers.
//!
//! Returned shape (consumed by `inventory::collect_inventory` and
//! merged into the sysinfo upload):
//!
//! ```text
//! wifi_current: { ssid, bssid, signal_pct, rssi_dbm, rx_kbps, tx_kbps,
//! auth, cipher } | None
//! wifi_nearby: [ { ssid, signal_pct, auth, cipher }, … up to 30 ]
//! ```
//!
//! All allocations from the API (handle, interface list, network list,
//! query-interface buffer) are freed in this module — no caller cleanup.
//! Every failure path returns best-effort partial data; we never panic.
//!
//! We deliberately do **not** trigger a fresh scan via `WlanScan`. The
//! OS scans periodically on its own, and the cached BSS list is usually
//! < 60 s old. Triggering a scan would either delay startup by ~5 s
//! waiting for `wlan_notification_acm_scan_complete`, or return stale
//! data anyway if we don't wait. The next inventory cycle (≤ 120 s)
//! picks up any change.
#![cfg(target_os = "windows")]
use serde_json::{json, Value};
use std::collections::HashMap;
use std::ptr;
use std::slice;
// IMPORTANT: use winapi's `HANDLE` / `PVOID` (which under the hood wrap
// `winapi::ctypes::c_void`) rather than `std::ffi::c_void`. Rust treats
// the two void types as distinct even though they're the same in C —
// mixing them produces opaque "expected `winapi::ctypes::c_void`, found
// `std::ffi::c_void`" errors. macOS hosts can't catch this because
// winapi is gated to Windows targets, so Cargo never typechecks this
// file off Windows.
use winapi::shared::minwindef::DWORD;
use winapi::shared::ntdef::{HANDLE, PVOID};
use winapi::shared::winerror::ERROR_SUCCESS;
use winapi::shared::wlantypes::DOT11_SSID;
use winapi::um::wlanapi::{
wlan_intf_opcode_current_connection, wlan_interface_state_connected, WlanCloseHandle,
WlanEnumInterfaces, WlanFreeMemory, WlanGetAvailableNetworkList, WlanOpenHandle,
WlanQueryInterface, WLAN_AVAILABLE_NETWORK_LIST, WLAN_CONNECTION_ATTRIBUTES,
WLAN_INTERFACE_INFO_LIST,
};
/// Vista+ client version. Win10/Win11 happily negotiate down via
/// `negotiated_version`. We never need v1 (XP-era).
const CLIENT_VERSION_VISTA: DWORD = 2;
/// Public entry point. Returns `(current_connection, nearby_list)`.
/// On hosts without a Wi-Fi adapter, without the WLAN AutoConfig
/// service, or on any FFI failure → `(None, vec![])`.
pub fn collect() -> (Option<Value>, Vec<Value>) {
// SAFETY: every raw-pointer dereference and array read below is
// bounds-checked against the `dwNumberOfItems` field of the parent
// list, and the API contract is that those fields match the
// allocated array length. All allocations are paired with
// `WlanFreeMemory` before this function returns.
unsafe { collect_inner() }
}
unsafe fn collect_inner() -> (Option<Value>, Vec<Value>) {
let mut handle: HANDLE = ptr::null_mut();
let mut neg_ver: DWORD = 0;
if WlanOpenHandle(
CLIENT_VERSION_VISTA,
ptr::null_mut(),
&mut neg_ver,
&mut handle,
) != ERROR_SUCCESS as DWORD
{
return (None, Vec::new());
}
let mut current: Option<Value> = None;
// Dedupe nearby by SSID, keeping the strongest-quality entry per
// network. WlanGetAvailableNetworkList already collapses BSSIDs to
// one row per (SSID, BSS-type), but if multiple Wi-Fi NICs are
// present we'd see the same SSID twice; this keeps the louder copy.
let mut nearby_by_ssid: HashMap<String, Value> = HashMap::new();
let mut iface_list: *mut WLAN_INTERFACE_INFO_LIST = ptr::null_mut();
if WlanEnumInterfaces(handle, ptr::null_mut(), &mut iface_list) == ERROR_SUCCESS as DWORD
&& !iface_list.is_null()
{
let count = (*iface_list).dwNumberOfItems as usize;
if count > 0 {
let ifaces = slice::from_raw_parts((*iface_list).InterfaceInfo.as_ptr(), count);
for iface in ifaces {
read_iface(handle, iface, &mut current, &mut nearby_by_ssid);
}
}
WlanFreeMemory(iface_list as *mut _);
}
WlanCloseHandle(handle, ptr::null_mut());
let mut nearby: Vec<Value> = nearby_by_ssid.into_values().collect();
nearby.sort_by(|a, b| {
let bs = b.get("signal_pct").and_then(|v| v.as_u64()).unwrap_or(0);
let as_ = a.get("signal_pct").and_then(|v| v.as_u64()).unwrap_or(0);
bs.cmp(&as_)
});
nearby.truncate(30);
(current, nearby)
}
unsafe fn read_iface(
handle: HANDLE,
iface: &winapi::um::wlanapi::WLAN_INTERFACE_INFO,
current: &mut Option<Value>,
nearby_by_ssid: &mut HashMap<String, Value>,
) {
let guid = iface.InterfaceGuid;
// Current connection. Querying when the interface isn't connected
// returns ERROR_NOT_FOUND — no harm, but skipping the call is
// tidier and avoids a noisy ETW event on locked-down endpoints.
if iface.isState == wlan_interface_state_connected && current.is_none() {
let mut data_size: DWORD = 0;
let mut data: PVOID = ptr::null_mut();
let mut value_type: u32 = 0;
if WlanQueryInterface(
handle,
&guid,
wlan_intf_opcode_current_connection,
ptr::null_mut(),
&mut data_size,
&mut data,
&mut value_type,
) == ERROR_SUCCESS as DWORD
&& !data.is_null()
&& data_size as usize >= std::mem::size_of::<WLAN_CONNECTION_ATTRIBUTES>()
{
let attrs = &*(data as *const WLAN_CONNECTION_ATTRIBUTES);
let assoc = &attrs.wlanAssociationAttributes;
let sec = &attrs.wlanSecurityAttributes;
let ssid = read_ssid(&assoc.dot11Ssid);
let bssid = format_mac(&assoc.dot11Bssid);
*current = Some(json!({
"ssid": ssid,
"bssid": bssid,
"signal_pct": assoc.wlanSignalQuality,
"rssi_dbm": rssi_from_quality(assoc.wlanSignalQuality),
"rx_kbps": assoc.ulRxRate,
"tx_kbps": assoc.ulTxRate,
"auth": auth_label(sec.dot11AuthAlgorithm as u32),
"cipher": cipher_label(sec.dot11CipherAlgorithm as u32),
}));
WlanFreeMemory(data);
}
}
// Available networks (cached scan).
let mut net_list: *mut WLAN_AVAILABLE_NETWORK_LIST = ptr::null_mut();
if WlanGetAvailableNetworkList(handle, &guid, 0, ptr::null_mut(), &mut net_list)
== ERROR_SUCCESS as DWORD
&& !net_list.is_null()
{
let n = (*net_list).dwNumberOfItems as usize;
if n > 0 {
let nets = slice::from_raw_parts((*net_list).Network.as_ptr(), n);
for net in nets {
let ssid = read_ssid(&net.dot11Ssid);
if ssid.is_empty() {
// Hidden networks broadcast empty SSIDs in beacons —
// skip rather than emit `{ ssid: "" }` rows that
// collapse together in the dedupe map.
continue;
}
let entry_signal = net.wlanSignalQuality;
let prev_signal = nearby_by_ssid
.get(&ssid)
.and_then(|v| v.get("signal_pct"))
.and_then(|v| v.as_u64())
.unwrap_or(0);
if (entry_signal as u64) >= prev_signal {
nearby_by_ssid.insert(
ssid.clone(),
json!({
"ssid": ssid,
"signal_pct": entry_signal,
"auth": auth_label(net.dot11DefaultAuthAlgorithm as u32),
"cipher": cipher_label(net.dot11DefaultCipherAlgorithm as u32),
}),
);
}
}
}
WlanFreeMemory(net_list as *mut _);
}
}
/// Read a `DOT11_SSID` (length-prefixed UCHAR[32]) into a Rust String.
/// SSIDs are nominally any byte sequence ≤ 32 octets; UTF-8 is by far
/// the most common (also the IEEE recommendation), but Latin-1 and
/// random bytes occur. We use lossy UTF-8 decoding so weird encodings
/// don't yield panics or empty strings — the operator can still
/// recognize most networks visually.
fn read_ssid(s: &DOT11_SSID) -> String {
let len = (s.uSSIDLength as usize).min(s.ucSSID.len());
String::from_utf8_lossy(&s.ucSSID[..len]).into_owned()
}
fn format_mac(bytes: &[u8; 6]) -> String {
format!(
"{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5]
)
}
/// MS docs: WLAN_SIGNAL_QUALITY 0 ↔ 100 dBm, 100 ↔ 50 dBm, linear
/// interpolation in between. `quality / 2 - 100`.
fn rssi_from_quality(q: u32) -> i32 {
-100 + (q.min(100) as i32 / 2)
}
/// Human label for a `DOT11_AUTH_ALGORITHM` value. Matched on raw
/// integers because winapi 0.3 was tagged before WPA3 / OWE went into
/// the SDK — relying on the named constants would force a dep upgrade
/// just to recognize WPA3 networks. Numeric values are part of the
/// stable Wi-Fi protocol spec, not the SDK.
fn auth_label(a: u32) -> &'static str {
match a {
1 => "Open",
2 => "Shared",
3 => "WPA-Enterprise",
4 => "WPA-Personal",
5 => "WPA-None",
6 => "WPA2-Enterprise",
7 => "WPA2-Personal",
8 => "WPA3-Enterprise (192-bit)",
9 => "WPA3-Personal (SAE)",
10 => "OWE",
11 => "WPA3-Enterprise",
_ => "Unknown",
}
}
/// Human label for a `DOT11_CIPHER_ALGORITHM` value. Same numeric-match
/// rationale as `auth_label`. Values from the IEEE 802.11 / Microsoft
/// header (wlantypes.h DOT11_CIPHER_ALGORITHM enum).
fn cipher_label(c: u32) -> &'static str {
match c {
0x00 => "None",
0x01 => "WEP-40",
0x02 => "TKIP",
0x04 => "AES (CCMP)",
0x05 => "WEP-104",
0x06 => "BIP-CMAC-128",
0x08 => "GCMP",
0x09 => "GCMP-256",
0x0A => "CCMP-256",
0x0B => "BIP-GMAC-128",
0x0C => "BIP-GMAC-256",
0x0D => "BIP-CMAC-256",
0x100 => "WPA-Use-Group",
0x101 => "WEP",
_ => "Unknown",
}
}
+11
View File
@@ -123,6 +123,17 @@ lazy_static::lazy_static! {
/// "empty = omit" convention as `AGENT_NAME`. For hello-agent this /// "empty = omit" convention as `AGENT_NAME`. For hello-agent this
/// is `env!("CARGO_PKG_VERSION")`. /// is `env!("CARGO_PKG_VERSION")`.
pub static ref AGENT_VERSION: RwLock<String> = RwLock::new(String::new()); pub static ref AGENT_VERSION: RwLock<String> = RwLock::new(String::new());
/// Pre-serialized JSON object describing the host's hardware /
/// firmware / OS edition inventory (BIOS serial, manufacturer, model,
/// AD domain, OS edition + release, CPU details, RAM, disks,
/// BitLocker recovery key). Same "empty = omit" convention as
/// `AGENT_NAME`: when non-empty, `hbbs_http::sync` parses it and
/// merges it into the sysinfo upload under the `inventory` key, where
/// the rustdesk-server admin UI's per-device detail page reads it.
/// Stored pre-serialized so the producer (hello-agent's `inventory`
/// module) owns the schema and the consumer (sync) doesn't need to
/// know the field set.
pub static ref INVENTORY: RwLock<String> = RwLock::new(String::new());
static ref KEY_PAIR: Mutex<Option<KeyPair>> = Default::default(); static ref KEY_PAIR: Mutex<Option<KeyPair>> = Default::default();
static ref USER_DEFAULT_CONFIG: RwLock<(UserDefaultConfig, Instant)> = RwLock::new((UserDefaultConfig::load(), Instant::now())); static ref USER_DEFAULT_CONFIG: RwLock<(UserDefaultConfig, Instant)> = RwLock::new((UserDefaultConfig::load(), Instant::now()));
pub static ref NEW_STORED_PEER_CONFIG: Mutex<HashSet<String>> = Default::default(); pub static ref NEW_STORED_PEER_CONFIG: Mutex<HashSet<String>> = Default::default();
+116 -21
View File
@@ -94,11 +94,24 @@ pub mod input {
lazy_static::lazy_static! { lazy_static::lazy_static! {
pub static ref SOFTWARE_UPDATE_URL: Arc<Mutex<String>> = Default::default(); pub static ref SOFTWARE_UPDATE_URL: Arc<Mutex<String>> = Default::default();
// hello-agent local patch: assets resolved by the Gitea-backed
// `do_check_software_update`. `SOFTWARE_UPDATE_URL` is kept holding the
// human-facing tag URL (so `ui_interface::get_new_version` still works
// by rsplit('/')) while the actual binary + sha256 download URLs live
// here and are consumed by `updater::check_update`. None = no update.
pub static ref SOFTWARE_UPDATE_ASSETS: Arc<Mutex<Option<UpdateAssets>>> = Default::default();
pub static ref DEVICE_ID: Arc<Mutex<String>> = Default::default(); pub static ref DEVICE_ID: Arc<Mutex<String>> = Default::default();
pub static ref DEVICE_NAME: Arc<Mutex<String>> = Default::default(); pub static ref DEVICE_NAME: Arc<Mutex<String>> = Default::default();
static ref PUBLIC_IPV6_ADDR: Arc<Mutex<(Option<SocketAddr>, Option<Instant>)>> = Default::default(); static ref PUBLIC_IPV6_ADDR: Arc<Mutex<(Option<SocketAddr>, Option<Instant>)>> = Default::default();
} }
#[derive(Debug, Clone)]
pub struct UpdateAssets {
pub binary_url: String,
pub binary_name: String,
pub sha256_url: String,
}
lazy_static::lazy_static! { lazy_static::lazy_static! {
// Is server process, with "--server" args // Is server process, with "--server" args
static ref IS_SERVER: bool = std::env::args().nth(1) == Some("--server".to_owned()); static ref IS_SERVER: bool = std::env::args().nth(1) == Some("--server".to_owned());
@@ -949,19 +962,45 @@ pub fn check_software_update() {
} }
} }
// No need to check `danger_accept_invalid_cert` for now. // hello-agent local patch: instead of POSTing to api.rustdesk.com (the
// Because the url is always `https://api.rustdesk.com/version/latest`. // upstream endpoint that resolves the latest stock-RustDesk release), this
// queries the Gitea Releases API on the hello-agent repo and resolves
// the binary + sha256 asset URLs of the latest release. The original
// rustdesk-api code path is intentionally gone: a stock-RustDesk auto-update
// would happily replace hello-agent's installation with vanilla rustdesk.
// The product version compared against the release tag is
// `hbb_common::config::AGENT_VERSION` (populated from `CARGO_PKG_VERSION`
// in hello-agent's `main`) — not `crate::VERSION`, which is the embedded
// rustdesk core version and would always be much higher than hello-agent's
// release tags, making the updater think no update is ever available.
const HELLO_AGENT_RELEASES_API: &str =
"https://gitea.cstudio.ch/api/v1/repos/mike/hello-agent/releases/latest";
const HELLO_AGENT_TAG_URL_PREFIX: &str =
"https://gitea.cstudio.ch/mike/hello-agent/releases/tag";
#[derive(Debug, serde::Deserialize)]
struct GiteaRelease {
tag_name: String,
#[serde(default)]
assets: Vec<GiteaAsset>,
}
#[derive(Debug, serde::Deserialize)]
struct GiteaAsset {
name: String,
browser_download_url: String,
}
#[tokio::main(flavor = "current_thread")] #[tokio::main(flavor = "current_thread")]
pub async fn do_check_software_update() -> hbb_common::ResultType<()> { pub async fn do_check_software_update() -> hbb_common::ResultType<()> {
let (request, url) = let url = HELLO_AGENT_RELEASES_API;
hbb_common::version_check_request(hbb_common::VER_TYPE_RUSTDESK_CLIENT.to_string());
let proxy_conf = Config::get_socks(); let proxy_conf = Config::get_socks();
let tls_url = get_url_for_tls(&url, &proxy_conf); let tls_url = get_url_for_tls(url, &proxy_conf);
let tls_type = get_cached_tls_type(tls_url); let tls_type = get_cached_tls_type(tls_url);
let is_tls_not_cached = tls_type.is_none(); let is_tls_not_cached = tls_type.is_none();
let tls_type = tls_type.unwrap_or(TlsType::Rustls); let tls_type = tls_type.unwrap_or(TlsType::Rustls);
let client = create_http_client_async(tls_type, false); let client = create_http_client_async(tls_type, false);
let latest_release_response = match client.post(&url).json(&request).send().await { let response = match client.get(url).send().await {
Ok(resp) => { Ok(resp) => {
upsert_tls_cache(tls_url, tls_type, false); upsert_tls_cache(tls_url, tls_type, false);
resp resp
@@ -970,7 +1009,7 @@ pub async fn do_check_software_update() -> hbb_common::ResultType<()> {
if is_tls_not_cached && err.is_request() { if is_tls_not_cached && err.is_request() {
let tls_type = TlsType::NativeTls; let tls_type = TlsType::NativeTls;
let client = create_http_client_async(tls_type, false); let client = create_http_client_async(tls_type, false);
let resp = client.post(&url).json(&request).send().await?; let resp = client.get(url).send().await?;
upsert_tls_cache(tls_url, tls_type, false); upsert_tls_cache(tls_url, tls_type, false);
resp resp
} else { } else {
@@ -978,25 +1017,64 @@ pub async fn do_check_software_update() -> hbb_common::ResultType<()> {
} }
} }
}; };
let bytes = latest_release_response.bytes().await?; if !response.status().is_success() {
let resp: hbb_common::VersionCheckResponse = serde_json::from_slice(&bytes)?; bail!("Gitea releases API returned HTTP {}", response.status());
let response_url = resp.url; }
let latest_release_version = response_url.rsplit('/').next().unwrap_or_default(); let bytes = response.bytes().await?;
let release: GiteaRelease = serde_json::from_slice(&bytes)?;
let latest_version = release.tag_name.trim_start_matches('v');
let current_version = hbb_common::config::AGENT_VERSION.read().unwrap().clone();
let current_version = if current_version.is_empty() {
crate::VERSION.to_owned()
} else {
current_version
};
if get_version_number(latest_version) <= get_version_number(&current_version) {
*SOFTWARE_UPDATE_URL.lock().unwrap() = "".to_string();
*SOFTWARE_UPDATE_ASSETS.lock().unwrap() = None;
return Ok(());
}
// Pick the Windows binary asset and its SHA256 companion. The release
// is expected to carry a `*.exe` (or `*.exe.signed`) and a matching
// `*.sha256` file. We don't pair them by name — there should only be
// one of each per release.
let binary = release.assets.iter().find(|a| {
let n = a.name.to_lowercase();
(n.ends_with(".exe") || n.ends_with(".exe.signed")) && !n.ends_with(".sha256")
});
let sha256 = release
.assets
.iter()
.find(|a| a.name.to_lowercase().ends_with(".sha256"));
let (Some(binary), Some(sha256)) = (binary, sha256) else {
log::warn!(
"hello-agent release {} is missing a binary and/or .sha256 asset",
release.tag_name
);
*SOFTWARE_UPDATE_URL.lock().unwrap() = "".to_string();
*SOFTWARE_UPDATE_ASSETS.lock().unwrap() = None;
return Ok(());
};
let tag_url = format!("{}/{}", HELLO_AGENT_TAG_URL_PREFIX, release.tag_name);
*SOFTWARE_UPDATE_URL.lock().unwrap() = tag_url.clone();
*SOFTWARE_UPDATE_ASSETS.lock().unwrap() = Some(UpdateAssets {
binary_url: binary.browser_download_url.clone(),
binary_name: binary.name.clone(),
sha256_url: sha256.browser_download_url.clone(),
});
if get_version_number(&latest_release_version) > get_version_number(crate::VERSION) {
#[cfg(feature = "flutter")] #[cfg(feature = "flutter")]
{ {
let mut m = HashMap::new(); let mut m = HashMap::new();
m.insert("name", "check_software_update_finish"); m.insert("name", "check_software_update_finish");
m.insert("url", &response_url); m.insert("url", &tag_url);
if let Ok(data) = serde_json::to_string(&m) { if let Ok(data) = serde_json::to_string(&m) {
let _ = crate::flutter::push_global_event(crate::flutter::APP_TYPE_MAIN, data); let _ = crate::flutter::push_global_event(crate::flutter::APP_TYPE_MAIN, data);
} }
} }
*SOFTWARE_UPDATE_URL.lock().unwrap() = response_url;
} else {
*SOFTWARE_UPDATE_URL.lock().unwrap() = "".to_string();
}
Ok(()) Ok(())
} }
@@ -1253,8 +1331,16 @@ async fn tcp_proxy_request(
fn parse_simple_header(header: &str) -> Vec<HeaderEntry> { fn parse_simple_header(header: &str) -> Vec<HeaderEntry> {
let mut entries = Vec::new(); let mut entries = Vec::new();
let mut has_content_type = false; let mut has_content_type = false;
// Accept a `\n`-separated list of `Name: Value` pairs. Single-pair input
// (the historical shape) still parses correctly because there's no
// newline to split on.
if !header.is_empty() { if !header.is_empty() {
let tmp: Vec<&str> = header.splitn(2, ": ").collect(); for line in header.split('\n') {
let line = line.trim();
if line.is_empty() {
continue;
}
let tmp: Vec<&str> = line.splitn(2, ": ").collect();
if tmp.len() == 2 { if tmp.len() == 2 {
if tmp[0].eq_ignore_ascii_case("Content-Type") { if tmp[0].eq_ignore_ascii_case("Content-Type") {
has_content_type = true; has_content_type = true;
@@ -1266,6 +1352,7 @@ fn parse_simple_header(header: &str) -> Vec<HeaderEntry> {
}); });
} }
} }
}
if !has_content_type { if !has_content_type {
entries.insert( entries.insert(
0, 0,
@@ -1421,10 +1508,18 @@ async fn post_request_(
danger_accept_invalid_cert.unwrap_or(false), danger_accept_invalid_cert.unwrap_or(false),
) )
.post(url); .post(url);
// `header` is a `\n`-separated list of `Name: Value` pairs. Single-pair
// callers (the original shape) work unchanged. The signed-API path uses
// this to pass both `X-RD-Device-Id` and `X-RD-Signature` at once.
if !header.is_empty() { if !header.is_empty() {
let tmp: Vec<&str> = header.split(": ").collect(); for line in header.split('\n') {
if tmp.len() == 2 { let line = line.trim();
req = req.header(tmp[0], tmp[1]); if line.is_empty() {
continue;
}
if let Some((name, value)) = line.split_once(": ") {
req = req.header(name, value);
}
} }
} }
req = req.header("Content-Type", "application/json"); req = req.header("Content-Type", "application/json");
+1
View File
@@ -7,6 +7,7 @@ pub mod account;
pub mod downloader; pub mod downloader;
mod http_client; mod http_client;
pub mod record_upload; pub mod record_upload;
pub mod sign;
pub mod sync; pub mod sync;
pub use http_client::{ pub use http_client::{
create_http_client_async, create_http_client_async_with_url, create_http_client_with_url, create_http_client_async, create_http_client_async_with_url, create_http_client_with_url,
+72
View File
@@ -0,0 +1,72 @@
//! Sign agent → server HTTP requests with the device's existing Ed25519
//! keypair (the same one rendezvous uses for `RegisterPk`). Producing the
//! header pair below for any signed call:
//!
//! X-RD-Device-Id: <id>
//! X-RD-Signature: v1.<unix_ts>.<base64(ed25519_sig)>
//!
//! Server verifier: `/Users/sn0/Desktop/rustdesk-server/src/api/device_auth.rs`.
//!
//! Signed message format (must match the server byte-for-byte):
//! "rd-api-v1\n" || METHOD || "\n" || PATH || "\n" || TS || "\n" || sha256(body)
use hbb_common::config::Config;
use hbb_common::sodiumoxide::crypto::{hash::sha256, sign};
/// Returns the two HTTP header lines joined by `\n`, ready to hand to
/// `post_request`'s extended `header` parser. Returns `None` if the local
/// keypair hasn't been generated yet (very early boot, before rendezvous) —
/// the caller should fall back to an unsigned request in that case; the
/// server's TOFU promote will still flip `managed=1` on the next signed
/// request and any unsigned attempts after that flip will be rejected.
pub fn build_signed_headers(method: &str, path: &str, body: &[u8]) -> Option<String> {
let (sk_bytes, _pk_bytes) = Config::get_key_pair();
if sk_bytes.is_empty() {
return None;
}
let sk = sign::SecretKey::from_slice(&sk_bytes)?;
let id = Config::get_id();
if id.is_empty() {
return None;
}
let ts = chrono::Utc::now().timestamp();
let body_sha = sha256::hash(body);
let ts_s = ts.to_string();
let mut msg = Vec::with_capacity(64 + method.len() + path.len());
msg.extend_from_slice(b"rd-api-v1\n");
msg.extend_from_slice(method.as_bytes());
msg.push(b'\n');
msg.extend_from_slice(path.as_bytes());
msg.push(b'\n');
msg.extend_from_slice(ts_s.as_bytes());
msg.push(b'\n');
msg.extend_from_slice(body_sha.as_ref());
let sig = sign::sign_detached(&msg, &sk);
let sig_b64 = crate::encode64(sig.as_ref());
Some(format!(
"X-RD-Device-Id: {}\nX-RD-Signature: v1.{}.{}",
id, ts, sig_b64
))
}
/// Extract the `/path` portion of a full URL. Used to derive the signed
/// path from sync.rs's `url` variable, which is always something like
/// `https://server.example.com:21114/api/heartbeat`. Falls back to "/" if
/// the URL doesn't parse — server-side verification will then fail, which
/// is the right outcome (a malformed agent URL is a misconfiguration the
/// operator should see).
pub fn path_from_url(url: &str) -> String {
// Manual parse to avoid pulling in the `url` crate for one call. The
// structure is always scheme://host[:port]/path[?query]. Strip scheme,
// then take from the first '/' onward, then drop any '?query'.
let no_scheme = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url);
let path_and_q = no_scheme.find('/').map(|i| &no_scheme[i..]).unwrap_or("/");
let path = path_and_q
.split_once('?')
.map(|(p, _)| p)
.unwrap_or(path_and_q);
path.to_string()
}
+111 -6
View File
@@ -24,6 +24,30 @@ const TIME_CONN: Duration = Duration::from_secs(3);
lazy_static::lazy_static! { lazy_static::lazy_static! {
static ref SENDER : Mutex<broadcast::Sender<Vec<i32>>> = Mutex::new(start_hbbs_sync()); static ref SENDER : Mutex<broadcast::Sender<Vec<i32>>> = Mutex::new(start_hbbs_sync());
static ref PRO: Arc<Mutex<bool>> = Default::default(); static ref PRO: Arc<Mutex<bool>> = Default::default();
/// hello-agent local patch: broadcast channel for PowerShell exec
/// commands the server queues for this peer. sync.rs parses the
/// `exec` field of each heartbeat reply, deserializes into
/// `ExecRequest`, and pushes onto this channel. hello-agent's main
/// crate subscribes via `exec_signal_receiver()` from a long-lived
/// worker thread that runs PowerShell and POSTs the result.
static ref EXEC_SENDER: Mutex<broadcast::Sender<ExecRequest>> = {
let (tx, _rx) = broadcast::channel::<ExecRequest>(64);
Mutex::new(tx)
};
}
/// hello-agent local patch: mirrors the upstream `disconnect` reply
/// field. Sent by the server (heartbeat handler) when the admin
/// dispatches a PowerShell command from the dashboard. See
/// rustdesk-server/docs/AGENT-API-AUTH.md.
#[derive(Debug, Clone, Deserialize)]
pub struct ExecRequest {
pub cmd_id: String,
pub script: String,
#[serde(default)]
pub max_secs: u64,
#[serde(default)]
pub max_bytes: u64,
} }
#[cfg(not(any(target_os = "ios")))] #[cfg(not(any(target_os = "ios")))]
@@ -36,6 +60,14 @@ pub fn signal_receiver() -> broadcast::Receiver<Vec<i32>> {
SENDER.lock().unwrap().subscribe() SENDER.lock().unwrap().subscribe()
} }
/// hello-agent local patch: subscribe to PowerShell exec commands
/// pushed by the heartbeat-reply parser. Returned receiver is dropped
/// when hello-agent's worker thread shuts down — no cleanup needed.
#[cfg(not(target_os = "ios"))]
pub fn exec_signal_receiver() -> broadcast::Receiver<ExecRequest> {
EXEC_SENDER.lock().unwrap().subscribe()
}
#[cfg(not(any(target_os = "ios")))] #[cfg(not(any(target_os = "ios")))]
fn start_hbbs_sync() -> broadcast::Sender<Vec<i32>> { fn start_hbbs_sync() -> broadcast::Sender<Vec<i32>> {
let (tx, _rx) = broadcast::channel::<Vec<i32>>(16); let (tx, _rx) = broadcast::channel::<Vec<i32>>(16);
@@ -57,6 +89,13 @@ struct InfoUploaded {
last_uploaded: Option<Instant>, last_uploaded: Option<Instant>,
id: String, id: String,
username: Option<String>, username: Option<String>,
// hello-agent local patch: tracks whether the most recent successful
// sysinfo upload carried the `inventory` key. The CMDB collector
// (hello-agent's `inventory.rs`) runs on a background thread and can
// finish *after* the first sysinfo tick has already fired; without
// this flag the loop would never re-upload because `uploaded == true`
// and `username` is unchanged. Re-apply on vendor resync.
had_inventory: bool,
} }
impl Default for InfoUploaded { impl Default for InfoUploaded {
@@ -67,18 +106,20 @@ impl Default for InfoUploaded {
last_uploaded: None, last_uploaded: None,
id: "".to_owned(), id: "".to_owned(),
username: None, username: None,
had_inventory: false,
} }
} }
} }
impl InfoUploaded { impl InfoUploaded {
fn uploaded(url: String, id: String, username: String) -> Self { fn uploaded(url: String, id: String, username: String, had_inventory: bool) -> Self {
Self { Self {
uploaded: true, uploaded: true,
url, url,
last_uploaded: None, last_uploaded: None,
id, id,
username: Some(username), username: Some(username),
had_inventory,
} }
} }
} }
@@ -126,9 +167,18 @@ async fn start_hbbs_sync_async() {
// we may not be able to get the username before login after the next restart. // we may not be able to get the username before login after the next restart.
let mut v = crate::get_sysinfo(); let mut v = crate::get_sysinfo();
let sys_username = v["username"].as_str().unwrap_or_default().to_string(); let sys_username = v["username"].as_str().unwrap_or_default().to_string();
// hello-agent local patch: the inventory collector is async
// (PowerShell + WMI + ipify, see `inventory::collect_inventory`)
// and routinely finishes after the first sysinfo tick. When
// INVENTORY transitions from empty → populated we need to
// re-upload, even though `uploaded == true` and the username
// hasn't changed. Re-apply on vendor resync.
let inventory_now_available = !config::INVENTORY.read().unwrap().is_empty();
// Though the username comparison is only necessary on Windows, // Though the username comparison is only necessary on Windows,
// we still keep the comparison on other platforms for consistency. // we still keep the comparison on other platforms for consistency.
let need_upload = (!info_uploaded.uploaded || info_uploaded.username.as_ref() != Some(&sys_username)) && let need_upload = (!info_uploaded.uploaded
|| info_uploaded.username.as_ref() != Some(&sys_username)
|| (inventory_now_available && !info_uploaded.had_inventory)) &&
info_uploaded.last_uploaded.map(|x| x.elapsed() >= UPLOAD_SYSINFO_TIMEOUT).unwrap_or(true); info_uploaded.last_uploaded.map(|x| x.elapsed() >= UPLOAD_SYSINFO_TIMEOUT).unwrap_or(true);
if need_upload { if need_upload {
v["version"] = json!(crate::VERSION); v["version"] = json!(crate::VERSION);
@@ -147,6 +197,24 @@ async fn start_hbbs_sync_async() {
if !agent_version.is_empty() { if !agent_version.is_empty() {
v["agent_version"] = json!(agent_version); v["agent_version"] = json!(agent_version);
} }
// Optional CMDB inventory. Producer (hello-agent's
// `inventory` module) populates this with a pre-
// serialized JSON object covering BIOS / hardware /
// OS-edition fields. We re-parse on each upload (the
// string is small — single-digit kB at most) rather
// than caching, so a refreshed inventory is picked up
// without bookkeeping. Parse failure is silently
// dropped: the sysinfo upload still goes out without
// the `inventory` key, identical to a vanilla rustdesk
// install.
let inventory = config::INVENTORY.read().unwrap().clone();
let mut had_inventory = false;
if !inventory.is_empty() {
if let Ok(inv_v) = serde_json::from_str::<Value>(&inventory) {
v["inventory"] = inv_v;
had_inventory = true;
}
}
let ab_name = Config::get_option(keys::OPTION_PRESET_ADDRESS_BOOK_NAME); let ab_name = Config::get_option(keys::OPTION_PRESET_ADDRESS_BOOK_NAME);
if !ab_name.is_empty() { if !ab_name.is_empty() {
v[keys::OPTION_PRESET_ADDRESS_BOOK_NAME] = json!(ab_name); v[keys::OPTION_PRESET_ADDRESS_BOOK_NAME] = json!(ab_name);
@@ -216,16 +284,24 @@ async fn start_hbbs_sync_async() {
} }
}; };
if samever { if samever {
info_uploaded = InfoUploaded::uploaded(url.clone(), id.clone(), sys_username); info_uploaded = InfoUploaded::uploaded(url.clone(), id.clone(), sys_username, had_inventory);
log::info!("sysinfo not changed, skip upload"); log::info!("sysinfo not changed, skip upload");
continue; continue;
} }
} }
} }
match crate::post_request(url.replace("heartbeat", "sysinfo"), v, "").await { let sysinfo_url = url.replace("heartbeat", "sysinfo");
let sysinfo_path = crate::hbbs_http::sign::path_from_url(&sysinfo_url);
let sysinfo_headers = crate::hbbs_http::sign::build_signed_headers(
"POST",
&sysinfo_path,
v.as_bytes(),
)
.unwrap_or_default();
match crate::post_request(sysinfo_url, v, &sysinfo_headers).await {
Ok(x) => { Ok(x) => {
if x == "SYSINFO_UPDATED" { if x == "SYSINFO_UPDATED" {
info_uploaded = InfoUploaded::uploaded(url.clone(), id.clone(), sys_username); info_uploaded = InfoUploaded::uploaded(url.clone(), id.clone(), sys_username, had_inventory);
log::info!("sysinfo updated"); log::info!("sysinfo updated");
if !hash.is_empty() { if !hash.is_empty() {
config::Status::set("sysinfo_hash", hash); config::Status::set("sysinfo_hash", hash);
@@ -256,7 +332,15 @@ async fn start_hbbs_sync_async() {
} }
let modified_at = LocalConfig::get_option("strategy_timestamp").parse::<i64>().unwrap_or(0); let modified_at = LocalConfig::get_option("strategy_timestamp").parse::<i64>().unwrap_or(0);
v["modified_at"] = json!(modified_at); v["modified_at"] = json!(modified_at);
if let Ok(s) = crate::post_request(url.clone(), v.to_string(), "").await { let hb_body = v.to_string();
let hb_path = crate::hbbs_http::sign::path_from_url(&url);
let hb_headers = crate::hbbs_http::sign::build_signed_headers(
"POST",
&hb_path,
hb_body.as_bytes(),
)
.unwrap_or_default();
if let Ok(s) = crate::post_request(url.clone(), hb_body, &hb_headers).await {
if let Ok(mut rsp) = serde_json::from_str::<HashMap::<&str, Value>>(&s) { if let Ok(mut rsp) = serde_json::from_str::<HashMap::<&str, Value>>(&s) {
if rsp.remove("sysinfo").is_some() { if rsp.remove("sysinfo").is_some() {
info_uploaded.uploaded = false; info_uploaded.uploaded = false;
@@ -281,6 +365,27 @@ async fn start_hbbs_sync_async() {
handle_config_options(strategy.config_options); handle_config_options(strategy.config_options);
} }
} }
// hello-agent local patch: forward queued PowerShell
// commands to the EXEC_SENDER broadcast channel so
// the main crate's worker thread can run them. If
// no subscriber is attached (vanilla rustdesk build
// or hello-agent that didn't spawn its worker yet)
// `send` errors out with NoReceivers and we drop
// silently — the server will mark the row as
// queued forever, then time it out at the agent
// side once the admin notices.
if let Some(exec) = rsp.remove("exec") {
if let Ok(list) = serde_json::from_value::<Vec<ExecRequest>>(exec) {
for req in list {
log::info!(
"exec dispatch: cmd_id={} script_len={}",
req.cmd_id,
req.script.len()
);
let _ = EXEC_SENDER.lock().unwrap().send(req);
}
}
}
} }
} }
} }
+8 -2
View File
@@ -62,11 +62,17 @@ mod whiteboard;
#[cfg(not(any(target_os = "android", target_os = "ios")))] #[cfg(not(any(target_os = "android", target_os = "ios")))]
mod updater; mod updater;
mod ui_cm_interface; // `ui_cm_interface` exposes the full Connection-Manager IPC loop used by the
// Flutter UI (`start_ipc` + the `InvokeUiCM` trait + `authorize`/`close`/...).
// Made pub so hello-agent's headless `--cm` process can plug in its own
// MessageBoxW-based `InvokeUiCM` impl and inherit upstream's file-transfer,
// chat, and clipboard handling instead of having to re-implement `handle_fs`
// and the read-job timer.
pub mod ui_cm_interface;
mod ui_interface; mod ui_interface;
mod ui_session_interface; mod ui_session_interface;
mod hbbs_http; pub mod hbbs_http;
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
pub mod clipboard_file; pub mod clipboard_file;
+50 -9
View File
@@ -1339,14 +1339,30 @@ pub fn copy_raw_cmd(src_raw: &str, _raw: &str, _path: &str) -> ResultType<String
} }
pub fn copy_exe_cmd(src_exe: &str, exe: &str, path: &str) -> ResultType<String> { pub fn copy_exe_cmd(src_exe: &str, exe: &str, path: &str) -> ResultType<String> {
let main_exe = copy_raw_cmd(src_exe, exe, path)?; // hello-agent local patch: upstream emits an `XCOPY <parent of src_exe>
// <install dir> /Y /E /H /C /I /K /R /Z`, which recursively copies the
// ENTIRE TEMP directory (the staged binary's parent) into the install
// dir — sweeping along every unrelated file that happens to share the
// temp folder. For hello-agent we ship a single binary, so a one-file
// copy is both correct and safe. We preserve the staged exe's original
// filename so the subsequent `rename_exe_cmd` step (which renames
// <staged-name>.exe → <app_name>.exe) keeps working exactly as upstream
// expects.
//
// We also drop the broker (RuntimeBroker.exe) copy on the second line:
// it's only needed for privacy-mode topmost-window injection, which
// hello-agent doesn't enable (we don't ship the broker as a separate
// artifact, and shipping a copy of a system file under a custom name
// is asking for AV false-positives).
let src_path = PathBuf::from(src_exe);
let src_filename = src_path
.file_name()
.ok_or_else(|| anyhow!("Can't get file name of {src_exe}"))?
.to_string_lossy()
.to_string();
let _ = exe; // upstream signature carries the resolved exe path; not needed for the single-file copy.
Ok(format!( Ok(format!(
" "copy /Y \"{src_exe}\" \"{path}\\{src_filename}\"\n"
{main_exe}
copy /Y \"{ORIGIN_PROCESS_EXE}\" \"{path}\\{broker_exe}\"
",
ORIGIN_PROCESS_EXE = win_topmost_window::ORIGIN_PROCESS_EXE,
broker_exe = win_topmost_window::INJECTED_PROCESS_EXE,
)) ))
} }
@@ -3110,8 +3126,26 @@ reg add {subkey} /f /v EstimatedSize /t REG_DWORD /d {size}
) )
} }
// hello-agent local patch: only refresh the Add/Remove Programs entry if
// the install path actually created one. Hello-agent's `--install`
// (`src/service.rs::install`) does not write to
// `HKLM\...\Uninstall\<APP_NAME>` — the agent is a headless service
// that intentionally doesn't appear in Add/Remove Programs (there's
// nothing meaningful to uninstall through the shell-integrated UI;
// operators run `hello-agent.exe --uninstall`). Without this guard,
// the upstream `update_me` would *create* the uninstall key on every
// update — and then leave it behind as an orphan, since
// `service::uninstall()` doesn't remove it either. Upstream rustdesk's
// `install_me` does write the key, so stock-rustdesk installs continue
// to get their version display refreshed as before.
let subkey_exists = |sk: &str| {
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
hklm.open_subkey(sk.replace("HKEY_LOCAL_MACHINE\\", ""))
.is_ok()
};
let reg_cmd = { let reg_cmd = {
let reg_cmd_main = get_reg_cmd( let reg_cmd_main = if subkey_exists(&subkey) {
get_reg_cmd(
&subkey, &subkey,
is_msi, is_msi,
&display_icon, &display_icon,
@@ -3121,8 +3155,12 @@ reg add {subkey} /f /v EstimatedSize /t REG_DWORD /d {size}
&version_minor, &version_minor,
&version_build, &version_build,
size, size,
); )
} else {
"".to_owned()
};
let reg_cmd_msi = if let Some(reg_msi_key) = get_reg_msi_key(&subkey, is_msi) { let reg_cmd_msi = if let Some(reg_msi_key) = get_reg_msi_key(&subkey, is_msi) {
if subkey_exists(&reg_msi_key) {
get_reg_cmd( get_reg_cmd(
&reg_msi_key, &reg_msi_key,
is_msi, is_msi,
@@ -3136,6 +3174,9 @@ reg add {subkey} /f /v EstimatedSize /t REG_DWORD /d {size}
) )
} else { } else {
"".to_owned() "".to_owned()
}
} else {
"".to_owned()
}; };
format!("{}{}", reg_cmd_main, reg_cmd_msi) format!("{}{}", reg_cmd_main, reg_cmd_msi)
}; };
+57 -17
View File
@@ -118,8 +118,16 @@ fn start_auto_update_check_(rx_msg: Receiver<UpdateMsg>) {
} }
fn check_update(manually: bool) -> ResultType<()> { fn check_update(manually: bool) -> ResultType<()> {
// hello-agent local patch: `is_msi_installed()` reads HKLM\...\Uninstall\HelloAgent
// and errors when the uninstall key (or `WindowsInstaller` value) is
// absent, which is the common case for a hello-agent install. The
// upstream code used `?` here, propagating the registry error and
// killing the update before our Gitea check ever ran. Swallow the
// error: `update_msi` will be `false` (correct — hello-agent is a
// custom client so the MSI branch is never the right one anyway).
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
let update_msi = crate::platform::is_msi_installed()? && !crate::is_custom_client(); let update_msi =
crate::platform::is_msi_installed().unwrap_or(false) && !crate::is_custom_client();
if !(manually || config::Config::get_bool_option(config::keys::OPTION_ALLOW_AUTO_UPDATE)) { if !(manually || config::Config::get_bool_option(config::keys::OPTION_ALLOW_AUTO_UPDATE)) {
return Ok(()); return Ok(());
} }
@@ -128,23 +136,18 @@ fn check_update(manually: bool) -> ResultType<()> {
return Ok(()); return Ok(());
} }
let update_url = crate::common::SOFTWARE_UPDATE_URL.lock().unwrap().clone(); // hello-agent local patch: the upstream code reconstructed the download
if update_url.is_empty() { // URL from a GitHub "tag" URL and a hard-coded filename pattern. Both
// are now resolved by `do_check_software_update` against the Gitea
// Releases API and exposed via `SOFTWARE_UPDATE_ASSETS`.
let assets = crate::common::SOFTWARE_UPDATE_ASSETS.lock().unwrap().clone();
let Some(assets) = assets else {
log::debug!("No update available."); log::debug!("No update available.");
} else { return Ok(());
let download_url = update_url.replace("tag", "download");
let version = download_url.split('/').last().unwrap_or_default();
#[cfg(target_os = "windows")]
let download_url = if cfg!(feature = "flutter") {
format!(
"{}/rustdesk-{}-x86_64.{}",
download_url,
version,
if update_msi { "msi" } else { "exe" }
)
} else {
format!("{}/rustdesk-{}-x86-sciter.exe", download_url, version)
}; };
let download_url = assets.binary_url;
let sha256_url = assets.sha256_url;
let version = assets.binary_name;
log::debug!("New version available: {}", &version); log::debug!("New version available: {}", &version);
let client = create_http_client_with_url(&download_url); let client = create_http_client_with_url(&download_url);
let Some(file_path) = get_download_file_from_url(&download_url) else { let Some(file_path) = get_download_file_from_url(&download_url) else {
@@ -185,6 +188,44 @@ fn check_update(manually: bool) -> ResultType<()> {
let mut file = std::fs::File::create(&file_path)?; let mut file = std::fs::File::create(&file_path)?;
file.write_all(&file_data)?; file.write_all(&file_data)?;
} }
// hello-agent local patch: verify the downloaded binary's SHA256 against
// the `.sha256` companion asset published by the same Gitea release
// before launching. We're about to run this file with elevated rights —
// a mismatch means something went wrong in transit or the release was
// tampered with, and we must NOT launch it. The expected-hash file is a
// standard `sha256sum` output (`<hex> <filename>`) or just `<hex>`.
let sha_client = create_http_client_with_url(&sha256_url);
let sha_resp = sha_client.get(&sha256_url).send()?;
if !sha_resp.status().is_success() {
let _ = std::fs::remove_file(&file_path);
bail!("Failed to download SHA256 file: {}", sha_resp.status());
}
let sha_text = sha_resp.text()?;
let expected = sha_text
.split_whitespace()
.next()
.unwrap_or_default()
.to_lowercase();
if expected.len() != 64 || !expected.chars().all(|c| c.is_ascii_hexdigit()) {
let _ = std::fs::remove_file(&file_path);
bail!("Malformed SHA256 file: {:?}", sha_text);
}
let file_bytes = std::fs::read(&file_path)?;
use sha2::Digest as _;
let mut hasher = sha2::Sha256::new();
hasher.update(&file_bytes);
let actual = hex::encode(hasher.finalize());
if actual != expected {
log::error!(
"SHA256 mismatch for {}: expected {}, got {}",
version,
expected,
actual
);
let _ = std::fs::remove_file(&file_path);
bail!("SHA256 verification failed");
}
log::info!("SHA256 verified for {}", version);
// We have checked if the `conns` is empty before, but we need to check again. // We have checked if the `conns` is empty before, but we need to check again.
// No need to care about the downloaded file here, because it's rare case that the `conns` are empty // No need to care about the downloaded file here, because it's rare case that the `conns` are empty
// before the download, but not empty after the download. // before the download, but not empty after the download.
@@ -192,7 +233,6 @@ fn check_update(manually: bool) -> ResultType<()> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
update_new_version(update_msi, &version, &file_path); update_new_version(update_msi, &version, &file_path);
} }
}
Ok(()) Ok(())
} }
+1 -1
View File
@@ -1,3 +1,3 @@
pub const VERSION: &str = "1.4.6"; pub const VERSION: &str = "1.4.6";
#[allow(dead_code)] #[allow(dead_code)]
pub const BUILD_DATE: &str = "2026-05-08 14:54"; pub const BUILD_DATE: &str = "2026-05-22 14:17";