Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f868efa432 | |||
| 6bdf1058fa | |||
| 6807fe2bc0 | |||
| fb00ac1101 | |||
| 8cff0c1863 | |||
| d10e547b70 | |||
| 8025f8558a | |||
| e815776329 | |||
| b59be25a16 | |||
| a2c79e56d3 |
@@ -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
+2
-1
@@ -3197,13 +3197,14 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hello-agent"
|
name = "hello-agent"
|
||||||
version = "0.1.0"
|
version = "0.1.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"env_logger 0.10.2",
|
"env_logger 0.10.2",
|
||||||
"hbb_common",
|
"hbb_common",
|
||||||
"log",
|
"log",
|
||||||
"rustdesk",
|
"rustdesk",
|
||||||
|
"serde_json 1.0.118",
|
||||||
"tokio",
|
"tokio",
|
||||||
"winapi",
|
"winapi",
|
||||||
"windows-service",
|
"windows-service",
|
||||||
|
|||||||
+9
-3
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "hello-agent"
|
name = "hello-agent"
|
||||||
version = "0.1.0"
|
version = "0.1.6"
|
||||||
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,20 @@ 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"
|
||||||
|
|
||||||
[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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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" {
|
||||||
|
|||||||
Executable
+264
@@ -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"
|
||||||
Executable
+291
@@ -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"
|
||||||
@@ -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
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+109
-172
@@ -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 {
|
||||||
|
ui_handler: HeadlessCm::default(),
|
||||||
|
};
|
||||||
|
// Returns only on listener error (e.g. another --cm already holds the
|
||||||
|
// pipe) or process shutdown. Either way there's nothing to do after.
|
||||||
|
ui_cm_interface::start_ipc(cm);
|
||||||
|
trace("start_ipc returned");
|
||||||
|
}
|
||||||
|
|
||||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
/// `InvokeUiCM` adapter for hello-agent. Stateless except for a small map
|
||||||
.enable_all()
|
/// of `(connection id) -> (peer_id, name)` we keep so we can render a
|
||||||
.build()
|
/// "session ended" notification at remove time (the `Client` is dropped
|
||||||
{
|
/// from upstream's `CLIENTS` registry before our `remove_connection` hook
|
||||||
Ok(rt) => rt,
|
/// is called, so we can't fish the peer info out of there).
|
||||||
Err(e) => {
|
#[derive(Clone, Default)]
|
||||||
trace(&format!("build runtime: {e}"));
|
struct HeadlessCm {
|
||||||
|
/// Tracks peers we approved. Connections that the user denied are not
|
||||||
|
/// inserted here, so they don't trigger a "session ended" banner the
|
||||||
|
/// user has no context for.
|
||||||
|
approved: Arc<Mutex<HashMap<i32, (String, String)>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InvokeUiCM for HeadlessCm {
|
||||||
|
/// Called by upstream's IPC loop the moment a peer's Login frame is
|
||||||
|
/// received and the client has been registered in the global CLIENTS
|
||||||
|
/// map. We must NOT block here — the same task that called us is
|
||||||
|
/// also the one that pumps the `_cm` pipe, so blocking on a user
|
||||||
|
/// click would prevent the IPC loop from ever delivering the
|
||||||
|
/// `Data::Authorize` we send back.
|
||||||
|
fn add_connection(&self, client: &Client) {
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
trace("runtime built; entering serve()");
|
|
||||||
|
|
||||||
if let Err(e) = rt.block_on(serve()) {
|
// Render the approval MessageBox on a fresh OS thread so the IPC
|
||||||
trace(&format!("serve exited: {e:#}"));
|
// task that called us stays responsive. On Yes we register the
|
||||||
} else {
|
// peer in `approved` and call `authorize(id)` which sends
|
||||||
trace("serve returned cleanly");
|
// `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);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Bind `_cm`, accept connections from `--server`'s `start_ipc` for as
|
fn remove_connection(&self, id: i32, _close: bool) {
|
||||||
/// long as the user session lasts. Each connection corresponds to one
|
trace(&format!("remove_connection: id={id}"));
|
||||||
/// peer requesting approval; we handle them concurrently.
|
let entry = self.approved.lock().unwrap().remove(&id);
|
||||||
async fn serve() -> Result<()> {
|
if let Some((peer_id, name)) = entry {
|
||||||
trace(&format!("calling new_listener({POSTFIX})"));
|
std::thread::spawn(move || show_session_ended(&peer_id, &name));
|
||||||
let mut incoming = match ipc::new_listener(POSTFIX).await {
|
|
||||||
Ok(i) => {
|
|
||||||
trace("new_listener succeeded");
|
|
||||||
i
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
trace(&format!("new_listener failed: {e}"));
|
|
||||||
return Err(anyhow::anyhow!("new_listener({POSTFIX}): {e}"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
trace("entering accept loop");
|
|
||||||
while let Some(result) = incoming.next().await {
|
|
||||||
match result {
|
|
||||||
Ok(stream) => {
|
|
||||||
trace("accepted incoming connection");
|
|
||||||
let conn = ipc::Connection::new(stream);
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Err(e) = handle_one(conn).await {
|
|
||||||
trace(&format!("handle_one error: {e:#}"));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
trace(&format!("accept error: {e}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
trace("accept loop exited");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_one(mut conn: ipc::Connection) -> Result<()> {
|
|
||||||
// Frame ordering on the `_cm` pipe is NOT "Login first, then chatter".
|
|
||||||
// For an installed/portable controlled side, the server first emits
|
|
||||||
// `Data::DataPortableService(CmShowElevation(...))` so the Flutter CM
|
|
||||||
// can render its elevation banner. The `Data::Login` we care about
|
|
||||||
// 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
@@ -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)");
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
+91
-7
@@ -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,47 @@
|
|||||||
|
|
||||||
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;
|
||||||
|
#[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 +79,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 +104,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 +152,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.
|
||||||
config_import::apply_defaults_if_empty();
|
//
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
|
||||||
match parsed.action {
|
match parsed.action {
|
||||||
Action::Install => {
|
Action::Install => {
|
||||||
@@ -169,6 +223,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 +265,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.
|
||||||
|
|||||||
+275
-69
@@ -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!(
|
log::warn!("Process::kill failed for pid {}", pid.as_u32());
|
||||||
"taskkill returned {code:?}: stdout={} stderr={}",
|
None
|
||||||
stdout.trim(),
|
|
||||||
stderr.trim(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.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;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
log::warn!("could not invoke taskkill: {e}");
|
}
|
||||||
|
log::warn!(
|
||||||
|
"some {target} processes were still alive after 5 s; subsequent file ops may fail with sharing violation"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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\""
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
anyhow!("open SCM: {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,13 @@ 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();
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
Vendored
+129
-34
@@ -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,24 +1017,63 @@ 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)?;
|
||||||
|
|
||||||
if get_version_number(&latest_release_version) > get_version_number(crate::VERSION) {
|
let latest_version = release.tag_name.trim_start_matches('v');
|
||||||
#[cfg(feature = "flutter")]
|
let current_version = hbb_common::config::AGENT_VERSION.read().unwrap().clone();
|
||||||
{
|
let current_version = if current_version.is_empty() {
|
||||||
let mut m = HashMap::new();
|
crate::VERSION.to_owned()
|
||||||
m.insert("name", "check_software_update_finish");
|
|
||||||
m.insert("url", &response_url);
|
|
||||||
if let Ok(data) = serde_json::to_string(&m) {
|
|
||||||
let _ = crate::flutter::push_global_event(crate::flutter::APP_TYPE_MAIN, data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*SOFTWARE_UPDATE_URL.lock().unwrap() = response_url;
|
|
||||||
} else {
|
} else {
|
||||||
|
current_version
|
||||||
|
};
|
||||||
|
if get_version_number(latest_version) <= get_version_number(¤t_version) {
|
||||||
*SOFTWARE_UPDATE_URL.lock().unwrap() = "".to_string();
|
*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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
#[cfg(feature = "flutter")]
|
||||||
|
{
|
||||||
|
let mut m = HashMap::new();
|
||||||
|
m.insert("name", "check_software_update_finish");
|
||||||
|
m.insert("url", &tag_url);
|
||||||
|
if let Ok(data) = serde_json::to_string(&m) {
|
||||||
|
let _ = crate::flutter::push_global_event(crate::flutter::APP_TYPE_MAIN, data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1253,17 +1331,26 @@ 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') {
|
||||||
if tmp.len() == 2 {
|
let line = line.trim();
|
||||||
if tmp[0].eq_ignore_ascii_case("Content-Type") {
|
if line.is_empty() {
|
||||||
has_content_type = true;
|
continue;
|
||||||
|
}
|
||||||
|
let tmp: Vec<&str> = line.splitn(2, ": ").collect();
|
||||||
|
if tmp.len() == 2 {
|
||||||
|
if tmp[0].eq_ignore_ascii_case("Content-Type") {
|
||||||
|
has_content_type = true;
|
||||||
|
}
|
||||||
|
entries.push(HeaderEntry {
|
||||||
|
name: tmp[0].into(),
|
||||||
|
value: tmp[1].into(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
entries.push(HeaderEntry {
|
|
||||||
name: tmp[0].into(),
|
|
||||||
value: tmp[1].into(),
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !has_content_type {
|
if !has_content_type {
|
||||||
@@ -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");
|
||||||
|
|||||||
Vendored
+1
@@ -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
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+8
-2
@@ -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;
|
||||||
|
|||||||
+61
-20
@@ -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,21 +3126,27 @@ 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) {
|
||||||
&subkey,
|
|
||||||
is_msi,
|
|
||||||
&display_icon,
|
|
||||||
&version,
|
|
||||||
&build_date,
|
|
||||||
&version_major,
|
|
||||||
&version_minor,
|
|
||||||
&version_build,
|
|
||||||
size,
|
|
||||||
);
|
|
||||||
let reg_cmd_msi = if let Some(reg_msi_key) = get_reg_msi_key(&subkey, is_msi) {
|
|
||||||
get_reg_cmd(
|
get_reg_cmd(
|
||||||
®_msi_key,
|
&subkey,
|
||||||
is_msi,
|
is_msi,
|
||||||
&display_icon,
|
&display_icon,
|
||||||
&version,
|
&version,
|
||||||
@@ -3137,6 +3159,25 @@ reg add {subkey} /f /v EstimatedSize /t REG_DWORD /d {size}
|
|||||||
} else {
|
} else {
|
||||||
"".to_owned()
|
"".to_owned()
|
||||||
};
|
};
|
||||||
|
let reg_cmd_msi = if let Some(reg_msi_key) = get_reg_msi_key(&subkey, is_msi) {
|
||||||
|
if subkey_exists(®_msi_key) {
|
||||||
|
get_reg_cmd(
|
||||||
|
®_msi_key,
|
||||||
|
is_msi,
|
||||||
|
&display_icon,
|
||||||
|
&version,
|
||||||
|
&build_date,
|
||||||
|
&version_major,
|
||||||
|
&version_minor,
|
||||||
|
&version_build,
|
||||||
|
size,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
"".to_owned()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"".to_owned()
|
||||||
|
};
|
||||||
format!("{}{}", reg_cmd_main, reg_cmd_msi)
|
format!("{}{}", reg_cmd_main, reg_cmd_msi)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Vendored
+101
-61
@@ -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,70 +136,102 @@ 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();
|
let download_url = assets.binary_url;
|
||||||
#[cfg(target_os = "windows")]
|
let sha256_url = assets.sha256_url;
|
||||||
let download_url = if cfg!(feature = "flutter") {
|
let version = assets.binary_name;
|
||||||
format!(
|
log::debug!("New version available: {}", &version);
|
||||||
"{}/rustdesk-{}-x86_64.{}",
|
let client = create_http_client_with_url(&download_url);
|
||||||
download_url,
|
let Some(file_path) = get_download_file_from_url(&download_url) else {
|
||||||
version,
|
bail!("Failed to get the file path from the URL: {}", download_url);
|
||||||
if update_msi { "msi" } else { "exe" }
|
};
|
||||||
)
|
let mut is_file_exists = false;
|
||||||
|
if file_path.exists() {
|
||||||
|
// Check if the file size is the same as the server file size
|
||||||
|
// If the file size is the same, we don't need to download it again.
|
||||||
|
let file_size = std::fs::metadata(&file_path)?.len();
|
||||||
|
let response = client.head(&download_url).send()?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
bail!("Failed to get the file size: {}", response.status());
|
||||||
|
}
|
||||||
|
let total_size = response
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_LENGTH)
|
||||||
|
.and_then(|ct_len| ct_len.to_str().ok())
|
||||||
|
.and_then(|ct_len| ct_len.parse::<u64>().ok());
|
||||||
|
let Some(total_size) = total_size else {
|
||||||
|
bail!("Failed to get content length");
|
||||||
|
};
|
||||||
|
if file_size == total_size {
|
||||||
|
is_file_exists = true;
|
||||||
} else {
|
} else {
|
||||||
format!("{}/rustdesk-{}-x86-sciter.exe", download_url, version)
|
std::fs::remove_file(&file_path)?;
|
||||||
};
|
|
||||||
log::debug!("New version available: {}", &version);
|
|
||||||
let client = create_http_client_with_url(&download_url);
|
|
||||||
let Some(file_path) = get_download_file_from_url(&download_url) else {
|
|
||||||
bail!("Failed to get the file path from the URL: {}", download_url);
|
|
||||||
};
|
|
||||||
let mut is_file_exists = false;
|
|
||||||
if file_path.exists() {
|
|
||||||
// Check if the file size is the same as the server file size
|
|
||||||
// If the file size is the same, we don't need to download it again.
|
|
||||||
let file_size = std::fs::metadata(&file_path)?.len();
|
|
||||||
let response = client.head(&download_url).send()?;
|
|
||||||
if !response.status().is_success() {
|
|
||||||
bail!("Failed to get the file size: {}", response.status());
|
|
||||||
}
|
|
||||||
let total_size = response
|
|
||||||
.headers()
|
|
||||||
.get(reqwest::header::CONTENT_LENGTH)
|
|
||||||
.and_then(|ct_len| ct_len.to_str().ok())
|
|
||||||
.and_then(|ct_len| ct_len.parse::<u64>().ok());
|
|
||||||
let Some(total_size) = total_size else {
|
|
||||||
bail!("Failed to get content length");
|
|
||||||
};
|
|
||||||
if file_size == total_size {
|
|
||||||
is_file_exists = true;
|
|
||||||
} else {
|
|
||||||
std::fs::remove_file(&file_path)?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if !is_file_exists {
|
}
|
||||||
let response = client.get(&download_url).send()?;
|
if !is_file_exists {
|
||||||
if !response.status().is_success() {
|
let response = client.get(&download_url).send()?;
|
||||||
bail!(
|
if !response.status().is_success() {
|
||||||
"Failed to download the new version file: {}",
|
bail!(
|
||||||
response.status()
|
"Failed to download the new version file: {}",
|
||||||
);
|
response.status()
|
||||||
}
|
);
|
||||||
let file_data = response.bytes()?;
|
|
||||||
let mut file = std::fs::File::create(&file_path)?;
|
|
||||||
file.write_all(&file_data)?;
|
|
||||||
}
|
|
||||||
// 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
|
|
||||||
// before the download, but not empty after the download.
|
|
||||||
if has_no_active_conns() {
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
update_new_version(update_msi, &version, &file_path);
|
|
||||||
}
|
}
|
||||||
|
let file_data = response.bytes()?;
|
||||||
|
let mut file = std::fs::File::create(&file_path)?;
|
||||||
|
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.
|
||||||
|
// 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.
|
||||||
|
if has_no_active_conns() {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
update_new_version(update_msi, &version, &file_path);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
-1
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user