Compare commits
39 Commits
master
...
v1.1.17-pro
| Author | SHA1 | Date | |
|---|---|---|---|
| d941ae9739 | |||
| bc61ec6046 | |||
| 8fa1b3e609 | |||
| d3c1128f23 | |||
| b044ab4de9 | |||
| 7b6526a2e8 | |||
| aeee852835 | |||
| 3ab67e80e1 | |||
| 62a8870ea2 | |||
| ac058d31c2 | |||
| 6a0b698384 | |||
| 475da0e950 | |||
| 21b25bcc1b | |||
| cd461a4507 | |||
| c2d320b782 | |||
| e22e4f6fb6 | |||
| 5ec9776207 | |||
| 8298252b06 | |||
| 1e961cdd92 | |||
| a7b3e83f02 | |||
| f7c359a8a0 | |||
| 0dda056bda | |||
| 9d53999eea | |||
| c1eaac1cb3 | |||
| 8ad3f43d21 | |||
| 7e2c7a7e4c | |||
| aa40784dc6 | |||
| 4ccfe7a0e6 | |||
| 782e4c545e | |||
| 4308a2f112 | |||
| d07e98e607 | |||
| 8b0219a877 | |||
| 4730c46f46 | |||
| 98b55e138e | |||
| e183b386a1 | |||
| 940b407560 | |||
| 5b288d671c | |||
| 8ecf05b106 | |||
| 3e89d61566 |
@@ -0,0 +1,42 @@
|
||||
# Copy to .env and edit. docker-compose reads it automatically.
|
||||
|
||||
# --- Required ----------------------------------------------------------------
|
||||
|
||||
# Public domain clients connect to. hbbs advertises this as the relay address.
|
||||
RUSTDESK_DOMAIN=rd.gamecom.ch
|
||||
|
||||
# --- Bootstrap admin ---------------------------------------------------------
|
||||
# Seeded into the users table on the FIRST startup only (when users is empty).
|
||||
# Subsequent restarts ignore these — change the password via the admin UI.
|
||||
# Without these set on first boot, you'll have no way to log in.
|
||||
|
||||
RUSTDESK_BOOTSTRAP_ADMIN_USERNAME=admin
|
||||
RUSTDESK_BOOTSTRAP_ADMIN_PASSWORD=changeme
|
||||
|
||||
# --- Optional runtime --------------------------------------------------------
|
||||
|
||||
# Pre-shared key. "-" lets hbbs auto-generate on first run; "_" forces
|
||||
# encrypted-only mode without an explicit key.
|
||||
#RUSTDESK_KEY=-
|
||||
|
||||
# HTTP management API / admin UI port (pro-features). Set to 0 to disable.
|
||||
#RUSTDESK_HTTP_PORT=21114
|
||||
|
||||
# Force relay for all sessions even on LAN.
|
||||
#RUSTDESK_ALWAYS_USE_RELAY=Y
|
||||
|
||||
# When the admin UI shows a device's unattended (per-boot) password.
|
||||
# logged-out only when nobody is logged in on the device (default)
|
||||
# always also while an interactive user is logged in
|
||||
#RUSTDESK_UNATTENDED_PWD_VISIBILITY=logged-out
|
||||
|
||||
#RUST_LOG=info
|
||||
|
||||
# --- Optional build source ---------------------------------------------------
|
||||
# Override the upstream repo / branch the image is built from.
|
||||
|
||||
#RUSTDESK_GIT_URL=https://gitea.cstudio.ch/mike/rustdesk-server.git
|
||||
#RUSTDESK_GIT_BRANCH=pro-features
|
||||
|
||||
# --- Database connectivity ---------------------------------------------------
|
||||
DATABASE_URL=sqlite://./db_v2.sqlite3
|
||||
@@ -0,0 +1,122 @@
|
||||
name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [pro-features]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
# Cargo.lock is lockfile v4, which requires Rust >= 1.78. Upstream's
|
||||
# .github/workflows/build.yaml pins 1.90; mirror that here.
|
||||
RUST_VERSION: "1.90"
|
||||
|
||||
jobs:
|
||||
build-amd64:
|
||||
name: build-linux-amd64
|
||||
# Same self-hosted runner as the rustdesk client build. provision.sh on the
|
||||
# host installs the Rust toolchain and devscripts/debhelper used here.
|
||||
runs-on: [self-hosted, Linux, X64, ubuntu-22.04]
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Verify host toolchain
|
||||
shell: bash
|
||||
run: |
|
||||
required=(git bash rustc cargo rustup pkg-config debuild dh dpkg-deb)
|
||||
missing=()
|
||||
for t in "${required[@]}"; do
|
||||
if command -v "$t" >/dev/null 2>&1; then
|
||||
printf '%-20s %s\n' "$t" "$(command -v "$t")"
|
||||
else
|
||||
missing+=("$t")
|
||||
printf '%-20s MISSING\n' "$t"
|
||||
fi
|
||||
done
|
||||
if [[ ${#missing[@]} -gt 0 ]]; then
|
||||
echo "Missing tools: ${missing[*]}. Install via: sudo apt install -y devscripts build-essential debhelper pkg-config"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Read version from Cargo.toml
|
||||
shell: bash
|
||||
run: |
|
||||
# Single source of truth: the top-level Cargo.toml's first `version =`
|
||||
# line (hbbs package). debian/changelog is patched to match below so
|
||||
# the produced .deb filenames carry the same version.
|
||||
display=$(grep -m1 -E '^version[[:space:]]*=' Cargo.toml \
|
||||
| sed -E 's/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/')
|
||||
[[ -n "$display" ]] || { echo "Could not read version from Cargo.toml"; exit 1; }
|
||||
echo "Version : $display"
|
||||
echo "VERSION_DISPLAY=$display" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Patch debian/changelog with display version
|
||||
shell: bash
|
||||
run: |
|
||||
# The .deb filename is derived from the first changelog entry's
|
||||
# parenthesized version, NOT from Cargo.toml. Rewrite whatever
|
||||
# version is in the leading entry to match $VERSION_DISPLAY.
|
||||
sed -i -E "0,/^rustdesk-server \([^)]+\)/{s/^rustdesk-server \([^)]+\)/rustdesk-server (${VERSION_DISPLAY})/}" debian/changelog
|
||||
head -1 debian/changelog
|
||||
|
||||
- name: Ensure Rust toolchain configured
|
||||
shell: bash
|
||||
run: |
|
||||
rustup toolchain install "$RUST_VERSION" --profile minimal --component rustfmt
|
||||
rustup default "$RUST_VERSION"
|
||||
rustup target add x86_64-unknown-linux-gnu
|
||||
rustc --version
|
||||
cargo --version
|
||||
|
||||
- name: Build hbbs / hbbr / rustdesk-utils
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
# Native build for the runner's amd64 host. --all-features matches
|
||||
# what upstream's GitHub workflow uses for its musl cross builds.
|
||||
cargo build --release --all-features
|
||||
mkdir -p debian-build/amd64/bin
|
||||
cp -v target/release/hbbs debian-build/amd64/bin/
|
||||
cp -v target/release/hbbr debian-build/amd64/bin/
|
||||
cp -v target/release/rustdesk-utils debian-build/amd64/bin/
|
||||
chmod -v a+x debian-build/amd64/bin/*
|
||||
|
||||
- name: Build .deb packages (amd64)
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
# Mirrors the deb-package job in upstream's .github/workflows/build.yaml:
|
||||
# stage debian/ and systemd/ next to the pre-built bin/ tree, then run
|
||||
# debuild -b so dh's auto_build step is a no-op (no source detected)
|
||||
# and dh_install just packages the binaries listed in the .install files.
|
||||
cp -vr debian systemd debian-build/amd64/
|
||||
sed "s/{{ ARCH }}/amd64/" debian/control.tpl > debian-build/amd64/debian/control
|
||||
(cd debian-build/amd64 && debuild -i -us -uc -b -aamd64)
|
||||
|
||||
mkdir -p ./SignOutput
|
||||
mv -v ./debian-build/rustdesk-server-hbbs_${VERSION_DISPLAY}_amd64.deb ./SignOutput/
|
||||
mv -v ./debian-build/rustdesk-server-hbbr_${VERSION_DISPLAY}_amd64.deb ./SignOutput/
|
||||
mv -v ./debian-build/rustdesk-server-utils_${VERSION_DISPLAY}_amd64.deb ./SignOutput/
|
||||
|
||||
- name: Report signing status of build artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
# .deb files are typically signed with debsign or via the apt repo
|
||||
# signing pipeline, not the .deb itself. Just list contents for now.
|
||||
for f in ./SignOutput/*.deb; do
|
||||
[[ -f "$f" ]] || continue
|
||||
size=$(stat -c%s "$f")
|
||||
printf '[UNSIGNED] %s (%d bytes)\n' "$(basename "$f")" "$size"
|
||||
done
|
||||
echo "::warning title=Unsigned .deb::Wire up debsigs / repo signing before distributing."
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: rustdesk-server-linux-amd64-${{ github.sha }}
|
||||
path: SignOutput/rustdesk-server-*.deb
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
[submodule "libs/hbb_common"]
|
||||
path = libs/hbb_common
|
||||
url = https://github.com/rustdesk/hbb_common
|
||||
url = https://gitea.cstudio.ch/mike/hbb_common.git
|
||||
|
||||
Generated
+141
-4
@@ -195,6 +195,12 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base32"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.13.0"
|
||||
@@ -414,6 +420,12 @@ dependencies = [
|
||||
"toml 0.5.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
@@ -567,12 +579,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.3"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2fb860ca6fafa5552fb6d0e816a69c8e49f0908bf524e30a90d97c85892d506"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -668,6 +681,22 @@ version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
|
||||
|
||||
[[package]]
|
||||
name = "email-encoding"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a87260449b06739ee78d6281c68d2a0ff3e3af64a78df63d3a1aeb3c06997c8a"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email_address"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
@@ -1068,7 +1097,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hbbs"
|
||||
version = "1.1.15"
|
||||
version = "1.1.17-pro"
|
||||
dependencies = [
|
||||
"async-speed-limit",
|
||||
"async-trait",
|
||||
@@ -1084,15 +1113,18 @@ dependencies = [
|
||||
"hbb_common",
|
||||
"headers",
|
||||
"http",
|
||||
"http-body",
|
||||
"ipnetwork",
|
||||
"jsonwebtoken",
|
||||
"lazy_static",
|
||||
"lettre",
|
||||
"local-ip-address",
|
||||
"mac_address",
|
||||
"machine-uid 0.2.0",
|
||||
"minreq",
|
||||
"once_cell",
|
||||
"ping",
|
||||
"qrcode",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"rust-ini",
|
||||
@@ -1101,7 +1133,11 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sodiumoxide",
|
||||
"sqlx",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"toml 0.7.8",
|
||||
"totp-rs",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tungstenite",
|
||||
"uuid",
|
||||
@@ -1163,6 +1199,15 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "0.2.7"
|
||||
@@ -1294,6 +1339,16 @@ dependencies = [
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6"
|
||||
dependencies = [
|
||||
"unicode-bidi",
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.8.1"
|
||||
@@ -1438,6 +1493,33 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
|
||||
|
||||
[[package]]
|
||||
name = "lettre"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76bd09637ae3ec7bd605b8e135e757980b3968430ff2b1a4a94fb7769e50166d"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.21.7",
|
||||
"email-encoding",
|
||||
"email_address",
|
||||
"fastrand",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"httpdate",
|
||||
"idna 0.3.0",
|
||||
"mime",
|
||||
"nom 7.1.1",
|
||||
"once_cell",
|
||||
"quoted_printable",
|
||||
"rustls 0.21.12",
|
||||
"rustls-pemfile 1.0.0",
|
||||
"socket2 0.4.4",
|
||||
"tokio",
|
||||
"tokio-rustls 0.24.1",
|
||||
"webpki-roots 0.23.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lexical-core"
|
||||
version = "0.7.6"
|
||||
@@ -2056,6 +2138,12 @@ version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9e1dcb320d6839f6edb64f7a4a59d39b30480d4d1765b56873f7c858538a5fe"
|
||||
|
||||
[[package]]
|
||||
name = "qrcode"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
|
||||
|
||||
[[package]]
|
||||
name = "quickcheck"
|
||||
version = "1.0.3"
|
||||
@@ -2083,6 +2171,12 @@ dependencies = [
|
||||
"proc-macro2 1.0.93",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quoted_printable"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a3866219251662ec3b26fc217e3e05bf9c4f84325234dfb96bf0bf840889e49"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
@@ -2404,6 +2498,16 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.100.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f6a5fc258f1c1276dfe3016516945546e2d5383911efc0fc4f1cdc5df3a4ae3"
|
||||
dependencies = [
|
||||
"ring 0.16.20",
|
||||
"untrusted 0.7.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.101.7"
|
||||
@@ -2558,6 +2662,17 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.2"
|
||||
@@ -3141,6 +3256,19 @@ dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "totp-rs"
|
||||
version = "5.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2b36a9dd327e9f401320a2cb4572cc76ff43742bcfc3291f871691050f140ba"
|
||||
dependencies = [
|
||||
"base32",
|
||||
"constant_time_eq",
|
||||
"hmac",
|
||||
"sha1",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.4.12"
|
||||
@@ -3338,7 +3466,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
"idna 0.2.3",
|
||||
"matches",
|
||||
"percent-encoding",
|
||||
]
|
||||
@@ -3503,6 +3631,15 @@ dependencies = [
|
||||
"webpki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b03058f88386e5ff5310d9111d53f48b17d732b401aeb83a8d5190f2ac459338"
|
||||
dependencies = [
|
||||
"rustls-webpki 0.100.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.25.4"
|
||||
|
||||
+8
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hbbs"
|
||||
version = "1.1.15"
|
||||
version = "1.1.17-pro"
|
||||
authors = ["rustdesk <info@rustdesk.com>"]
|
||||
edition = "2021"
|
||||
build = "build.rs"
|
||||
@@ -18,6 +18,11 @@ path = "src/utils.rs"
|
||||
|
||||
[dependencies]
|
||||
hbb_common = { path = "libs/hbb_common" }
|
||||
tokio = { version = "1", features = ["fs", "io-util"] }
|
||||
totp-rs = { version = "5.4", default-features = false }
|
||||
qrcode = { version = "0.14", default-features = false, features = ["svg"] }
|
||||
lettre = { version = "0.10", default-features = false, features = ["smtp-transport", "tokio1-rustls-tls", "builder"] }
|
||||
toml = "0.7"
|
||||
serde_derive = "1.0"
|
||||
serde = "1.0"
|
||||
serde_json = "1.0"
|
||||
@@ -45,7 +50,9 @@ tokio-tungstenite = "0.17"
|
||||
tungstenite = "0.17"
|
||||
regex = "1.4"
|
||||
tower-http = { version = "0.3", features = ["fs", "trace", "cors"] }
|
||||
tower = { version = "0.4", features = ["util"] }
|
||||
http = "0.2"
|
||||
http-body = "0.4"
|
||||
flexi_logger = { version = "0.22", features = ["async", "use_chrono_for_offset", "dont_minimize_extra_stacks"] }
|
||||
ipnetwork = "0.20"
|
||||
local-ip-address = "0.5.1"
|
||||
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>RustDesk — Connect</title>
|
||||
<link rel="stylesheet" href="/admin/connect/assets/bundle.css" />
|
||||
<!--
|
||||
The Rust handler at src/api/admin/pages/connect.rs replaces
|
||||
{{CUSTOM_CONFIG}} with a JSON object the SPA reads on boot. Same
|
||||
pattern as the deploy page and rustdesk.com/web/.
|
||||
-->
|
||||
<script id="custom-config" type="application/json">{{CUSTOM_CONFIG}}</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/admin/connect/assets/bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,294 @@
|
||||
<!doctype html>
|
||||
<html lang="{{LANG_CODE}}" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>{{T_APP_TITLE}}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<script src="/admin/assets/tailwindcss.js"></script>
|
||||
<script src="/admin/assets/htmx.min.js"></script>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
|
||||
.nav-link.active { background: rgb(15 23 42); color: rgb(125 211 252); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="h-full bg-slate-950 text-slate-100">
|
||||
<!--
|
||||
Single-page shell. The sidebar drives navigation via HTMX:
|
||||
each link does an `hx-get` of an HTML fragment URL that returns the
|
||||
body of the page. The fragments live under /admin/pages/ and are
|
||||
server-rendered Rust handlers that return Html<String>.
|
||||
This keeps the UI a flat directory of static files plus a small
|
||||
set of fragment endpoints — no SPA, no Node, no build step.
|
||||
-->
|
||||
<div class="min-h-full flex">
|
||||
<aside class="w-56 shrink-0 bg-slate-900 border-r border-slate-800 flex flex-col">
|
||||
<div class="px-4 py-5 border-b border-slate-800">
|
||||
<h1 class="text-base font-semibold">{{T_APP_TITLE}}</h1>
|
||||
<p id="me-display" class="text-xs text-slate-500 mt-1" hx-get="/admin/me" hx-trigger="load" hx-swap="innerHTML">…</p>
|
||||
</div>
|
||||
<nav class="flex-1 px-2 py-3 space-y-1">
|
||||
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
||||
hx-get="/admin/pages/users" hx-target="#main" hx-push-url="#users">{{T_NAV_USERS}}</a>
|
||||
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
||||
hx-get="/admin/pages/devices" hx-target="#main" hx-push-url="#devices">{{T_NAV_DEVICES}}</a>
|
||||
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
||||
hx-get="/admin/pages/groups" hx-target="#main" hx-push-url="#groups">{{T_NAV_GROUPS}}</a>
|
||||
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
||||
hx-get="/admin/pages/strategies" hx-target="#main" hx-push-url="#strategies">{{T_NAV_STRATEGIES}}</a>
|
||||
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
||||
hx-get="/admin/pages/address-books" hx-target="#main" hx-push-url="#address-books">{{T_NAV_AB}}</a>
|
||||
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
||||
hx-get="/admin/pages/audit" hx-target="#main" hx-push-url="#audit">{{T_NAV_AUDIT}}</a>
|
||||
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
||||
hx-get="/admin/pages/deploy" hx-target="#main" hx-push-url="#deploy">{{T_NAV_DEPLOY}}</a>
|
||||
</nav>
|
||||
<div class="px-2 py-3 border-t border-slate-800 space-y-1">
|
||||
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-400 hover:bg-slate-800"
|
||||
hx-get="/admin/pages/profile" hx-target="#main" hx-push-url="#profile">{{T_NAV_PROFILE}}</a>
|
||||
<button
|
||||
class="w-full text-left px-3 py-1.5 text-sm rounded text-slate-400 hover:bg-slate-800"
|
||||
hx-post="/admin/logout"
|
||||
hx-on::after-request="window.location.href = '/admin/login.html'"
|
||||
>{{T_NAV_SIGNOUT}}</button>
|
||||
<div class="pt-2">
|
||||
<label class="block text-[10px] uppercase tracking-wide text-slate-600 px-3 mb-1">{{T_LANGUAGE}}</label>
|
||||
<select
|
||||
class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-slate-300"
|
||||
onchange="document.cookie='admin_lang='+this.value+'; path=/; max-age=31536000; samesite=strict'; window.location.reload();"
|
||||
>
|
||||
<option value="en"{{LANG_SEL_EN}}>English</option>
|
||||
<option value="de"{{LANG_SEL_DE}}>Deutsch</option>
|
||||
<option value="es"{{LANG_SEL_ES}}>Español</option>
|
||||
<option value="fr"{{LANG_SEL_FR}}>Français</option>
|
||||
<option value="ro"{{LANG_SEL_RO}}>Română</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="text-[10px] text-slate-600 text-center pt-2">v{{APP_VERSION}}</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main id="main" class="flex-1 p-6 overflow-x-hidden">
|
||||
<div class="text-slate-500 text-sm">{{T_LOADING}}</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Toast container used by all admin handlers via hx-trigger="load delay:1s" -->
|
||||
<div id="toast"
|
||||
class="fixed bottom-4 right-4 max-w-sm space-y-2 pointer-events-none"></div>
|
||||
|
||||
<!-- Load fragment + highlight active link based on the URL hash.
|
||||
Sub-routes like #users/new map to dedicated fragment URLs but
|
||||
keep the parent section's nav-link highlighted. -->
|
||||
<script>
|
||||
// Hash → fragment URL for routes that aren't owned by a sidebar
|
||||
// nav-link (e.g. forms on their own page). The first path segment
|
||||
// also tells us which nav-link to highlight.
|
||||
const SUB_ROUTES = {
|
||||
'#users/new': '/admin/pages/users/new',
|
||||
};
|
||||
function topLevelHash(hash) {
|
||||
const slash = hash.indexOf('/');
|
||||
return slash === -1 ? hash : hash.slice(0, slash);
|
||||
}
|
||||
function linkForHash() {
|
||||
const hash = location.hash || '#users';
|
||||
const top = topLevelHash(hash);
|
||||
return document.querySelector('.nav-link[hx-push-url="' + top + '"]')
|
||||
|| document.querySelector('.nav-link[hx-push-url="#users"]');
|
||||
}
|
||||
function refreshActive() {
|
||||
const active = linkForHash();
|
||||
document.querySelectorAll('.nav-link').forEach(a => {
|
||||
a.classList.toggle('active', a === active);
|
||||
});
|
||||
}
|
||||
// Dynamic deep-links: `#devices/<id>` and `#devices/<id>/exec`.
|
||||
// The detail / exec fragments are designed to swap into the
|
||||
// devices index page's `#devices-region` section, so when we
|
||||
// land here from a page refresh we have to chain two ajax calls:
|
||||
// first render the parent page (which provides `#devices-region`),
|
||||
// then swap the fragment into it. htmx.ajax has returned a Promise
|
||||
// since 1.9.4, so the `.then` chain is safe at our pinned 1.9.10.
|
||||
const DEEP_LINK_PATTERNS = [
|
||||
{
|
||||
re: /^#devices\/([^/]+)\/exec$/,
|
||||
parent: '/admin/pages/devices',
|
||||
fragment: id => `/admin/pages/devices/${encodeURIComponent(id)}/exec`,
|
||||
},
|
||||
{
|
||||
re: /^#devices\/([^/]+)$/,
|
||||
parent: '/admin/pages/devices',
|
||||
fragment: id => `/admin/pages/devices/${encodeURIComponent(id)}/detail`,
|
||||
},
|
||||
];
|
||||
function loadDeepLink(hash) {
|
||||
for (const p of DEEP_LINK_PATTERNS) {
|
||||
const m = hash.match(p.re);
|
||||
if (!m) continue;
|
||||
const id = decodeURIComponent(m[1]);
|
||||
Promise.resolve(
|
||||
htmx.ajax('GET', p.parent, { target: '#main', swap: 'innerHTML' })
|
||||
).then(() =>
|
||||
htmx.ajax('GET', p.fragment(id), {
|
||||
target: '#devices-region',
|
||||
swap: 'innerHTML',
|
||||
})
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function loadFromHash() {
|
||||
const hash = location.hash || '#users';
|
||||
if (loadDeepLink(hash)) {
|
||||
refreshActive();
|
||||
return;
|
||||
}
|
||||
const subUrl = SUB_ROUTES[hash];
|
||||
if (subUrl) {
|
||||
htmx.ajax('GET', subUrl, { target: '#main', swap: 'innerHTML' });
|
||||
} else {
|
||||
const link = linkForHash();
|
||||
if (link) {
|
||||
htmx.ajax('GET', link.getAttribute('hx-get'),
|
||||
{ target: '#main', swap: 'innerHTML' });
|
||||
}
|
||||
}
|
||||
refreshActive();
|
||||
}
|
||||
window.addEventListener('hashchange', loadFromHash);
|
||||
document.body.addEventListener('htmx:afterSwap', refreshActive);
|
||||
loadFromHash();
|
||||
|
||||
// Bounce to login if any HTMX request comes back 401.
|
||||
document.body.addEventListener('htmx:responseError', (evt) => {
|
||||
if (evt.detail.xhr.status === 401) {
|
||||
window.location.href = '/admin/login.html';
|
||||
}
|
||||
});
|
||||
|
||||
// Plain-link confirmation prompt. HTMX has hx-confirm for its own
|
||||
// requests; this is the equivalent for raw `<a href>` anchors that
|
||||
// can't go through HTMX (e.g. "Connect (web client)", which opens
|
||||
// a new tab and triggers a popup on the controlled machine — easy
|
||||
// to fire by accident from the row action menu). Use:
|
||||
//
|
||||
// <a href="..." data-confirm="..." onclick="return confirmFromDataAttr(this)">
|
||||
//
|
||||
// The message lives in the data-attribute (only HTML-escaped, no
|
||||
// JS-string escaping) which keeps the server-side renderer simple.
|
||||
function confirmFromDataAttr(el) {
|
||||
const msg = el && el.dataset ? el.dataset.confirm : '';
|
||||
return !msg || window.confirm(msg);
|
||||
}
|
||||
window.confirmFromDataAttr = confirmFromDataAttr;
|
||||
|
||||
// Close any open per-row action popover when a click happens outside it.
|
||||
// The action dropdowns are <details class="... relative"> with an
|
||||
// absolutely-positioned panel; the deploy page uses <details> too but
|
||||
// without `relative`, so the selector is specific to the popover style.
|
||||
document.addEventListener('click', (e) => {
|
||||
document.querySelectorAll('details.relative[open]').forEach(d => {
|
||||
if (!d.contains(e.target)) d.removeAttribute('open');
|
||||
});
|
||||
});
|
||||
|
||||
// Read the current value of the users-search input (if present).
|
||||
// Used by usersColumnToggle/usersPageSize so a column or page-size
|
||||
// change preserves the active filter.
|
||||
function usersSearchValue() {
|
||||
const el = document.getElementById('users-search');
|
||||
return el ? el.value : '';
|
||||
}
|
||||
|
||||
// Users table column-visibility toggle. The popover in the page header
|
||||
// emits checkboxes with onchange="usersColumnToggle(this)" — we POST
|
||||
// the new state to the server (which persists it in user_prefs) and
|
||||
// swap in the re-rendered table so the popover stays open.
|
||||
function usersColumnToggle(input) {
|
||||
const col = input.dataset.col;
|
||||
if (!col) return;
|
||||
htmx.ajax('POST', '/admin/pages/users/columns', {
|
||||
target: '#users-region',
|
||||
swap: 'innerHTML',
|
||||
values: {
|
||||
col: col,
|
||||
visible: input.checked ? '1' : '0',
|
||||
q: usersSearchValue(),
|
||||
},
|
||||
});
|
||||
}
|
||||
window.usersColumnToggle = usersColumnToggle;
|
||||
|
||||
// Users table per-page selector. Driven by the <select> in the
|
||||
// pagination footer — POSTs to persist the choice and re-renders the
|
||||
// table at page 1 (size change shifts which rows are on which page).
|
||||
function usersPageSize(size) {
|
||||
htmx.ajax('POST', '/admin/pages/users/page-size', {
|
||||
target: '#users-region',
|
||||
swap: 'innerHTML',
|
||||
values: { size: size, q: usersSearchValue() },
|
||||
});
|
||||
}
|
||||
window.usersPageSize = usersPageSize;
|
||||
|
||||
// Devices table — mirrors the users helpers above. Same persistence
|
||||
// model (per-user prefs in `user_prefs`) and the same fragment-swap
|
||||
// approach so the columns popover and search input stay put while
|
||||
// pagination/columns/page-size all preserve the active filter.
|
||||
function devicesSearchValue() {
|
||||
const el = document.getElementById('devices-search');
|
||||
return el ? el.value : '';
|
||||
}
|
||||
function devicesColumnToggle(input) {
|
||||
const col = input.dataset.col;
|
||||
if (!col) return;
|
||||
htmx.ajax('POST', '/admin/pages/devices/columns', {
|
||||
target: '#devices-region',
|
||||
swap: 'innerHTML',
|
||||
values: {
|
||||
col: col,
|
||||
visible: input.checked ? '1' : '0',
|
||||
q: devicesSearchValue(),
|
||||
},
|
||||
});
|
||||
}
|
||||
window.devicesColumnToggle = devicesColumnToggle;
|
||||
function devicesPageSize(size) {
|
||||
htmx.ajax('POST', '/admin/pages/devices/page-size', {
|
||||
target: '#devices-region',
|
||||
swap: 'innerHTML',
|
||||
values: { size: size, q: devicesSearchValue() },
|
||||
});
|
||||
}
|
||||
window.devicesPageSize = devicesPageSize;
|
||||
|
||||
// The devices table lives inside an `overflow-x-auto` wrapper so wide
|
||||
// column sets get a horizontal scrollbar instead of pushing the page
|
||||
// out. CSS forces overflow-y to auto on the same axis, which would
|
||||
// clip the per-row action popover (a `<details>` > `<div>` inside a
|
||||
// <td>). On toggle we flip the popover to `position: fixed` and pin
|
||||
// it to the summary's viewport rect so it escapes the scroll context.
|
||||
// Inline `ontoggle=` is preserved across htmx swaps without re-binding.
|
||||
function actionMenuToggle(details) {
|
||||
const popover = details.querySelector('[data-action-menu]');
|
||||
if (!popover) return;
|
||||
if (!details.open) {
|
||||
popover.style.position = '';
|
||||
popover.style.top = '';
|
||||
popover.style.left = '';
|
||||
popover.style.right = '';
|
||||
return;
|
||||
}
|
||||
const summary = details.querySelector('summary');
|
||||
if (!summary) return;
|
||||
const rect = summary.getBoundingClientRect();
|
||||
popover.style.position = 'fixed';
|
||||
popover.style.top = rect.bottom + 4 + 'px';
|
||||
popover.style.right = (window.innerWidth - rect.right - 8) + 'px';
|
||||
popover.style.left = 'auto';
|
||||
}
|
||||
window.actionMenuToggle = actionMenuToggle;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,126 @@
|
||||
<!doctype html>
|
||||
<html lang="{{LANG_CODE}}" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>{{T_SIGNIN}} — {{T_TITLE}}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<script src="/admin/assets/tailwindcss.js"></script>
|
||||
<script src="/admin/assets/htmx.min.js"></script>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="h-full bg-slate-950 text-slate-100 flex items-center justify-center">
|
||||
<main class="w-full max-w-sm px-6">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-2xl font-semibold">{{T_TITLE}}</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">{{T_SUBTITLE}}</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="space-y-4 bg-slate-900 border border-slate-800 rounded-lg p-6 shadow-xl"
|
||||
hx-post="/admin/login"
|
||||
hx-target="#err"
|
||||
hx-swap="innerHTML"
|
||||
hx-on::before-swap="
|
||||
/* The auth handler returns 401 with an HTML error fragment for
|
||||
bad credentials / disabled / not-admin / bad-TOTP. HTMX skips
|
||||
the swap on 4xx by default, so force it back on. */
|
||||
if (event.detail.xhr.status >= 400 && event.detail.xhr.status < 500) {
|
||||
event.detail.shouldSwap = true;
|
||||
event.detail.isError = false;
|
||||
}
|
||||
"
|
||||
hx-on::after-request="
|
||||
const xhr = event.detail.xhr;
|
||||
if (event.detail.successful && (xhr.responseText || '').trim() === '') {
|
||||
/* Empty 2xx body = real login. The TOTP-required path returns 2xx
|
||||
with an HTML prompt fragment, which we MUST NOT redirect away
|
||||
from. */
|
||||
window.location.href = '/admin/';
|
||||
}
|
||||
"
|
||||
>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="username">{{T_USERNAME}}</label>
|
||||
<input
|
||||
id="username" name="username" type="text" required autocomplete="username"
|
||||
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="password">{{T_PASSWORD}}</label>
|
||||
<input
|
||||
id="password" name="password" type="password" required autocomplete="current-password"
|
||||
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="tfa-section" class="hidden">
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="tfaCode">{{T_TOTP_LABEL}}</label>
|
||||
<input
|
||||
id="tfaCode" name="tfaCode" type="text" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code"
|
||||
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm tracking-widest text-center focus:outline-none focus:border-sky-500"
|
||||
/>
|
||||
<input id="secret" name="secret" type="hidden" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium rounded px-4 py-2 transition"
|
||||
>
|
||||
{{T_SIGNIN}}
|
||||
</button>
|
||||
|
||||
<div id="err" class="text-sm text-rose-400 min-h-[1.25em]"></div>
|
||||
</form>
|
||||
|
||||
<!-- OIDC providers (rendered only when /admin/oidc/providers is non-empty) -->
|
||||
<div id="oidc-block" class="mt-6 hidden">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="flex-1 h-px bg-slate-800"></div>
|
||||
<span class="text-xs text-slate-500">{{T_OR}}</span>
|
||||
<div class="flex-1 h-px bg-slate-800"></div>
|
||||
</div>
|
||||
<div id="oidc-buttons" class="space-y-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<label class="text-[10px] uppercase tracking-wide text-slate-600 mr-2">{{T_LANGUAGE}}</label>
|
||||
<select
|
||||
class="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-slate-300"
|
||||
onchange="document.cookie='admin_lang='+this.value+'; path=/; max-age=31536000; samesite=strict'; window.location.reload();"
|
||||
>
|
||||
<option value="en"{{LANG_SEL_EN}}>English</option>
|
||||
<option value="de"{{LANG_SEL_DE}}>Deutsch</option>
|
||||
<option value="es"{{LANG_SEL_ES}}>Español</option>
|
||||
<option value="fr"{{LANG_SEL_FR}}>Français</option>
|
||||
<option value="ro"{{LANG_SEL_RO}}>Română</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="mt-4 text-center text-[10px] text-slate-600">v{{APP_VERSION}}</p>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Fetch enabled providers and render one button each. The button just
|
||||
// navigates to /admin/login/oidc/<name>, which 302s the browser to the
|
||||
// IdP. After the IdP redirects to /oidc/callback, the server sets our
|
||||
// session cookie and redirects to /admin/.
|
||||
var SIGNIN_WITH = {{T_SIGNIN_WITH_JSON}};
|
||||
fetch('/admin/oidc/providers').then(r => r.json()).then(list => {
|
||||
if (!Array.isArray(list) || list.length === 0) return;
|
||||
const block = document.getElementById('oidc-block');
|
||||
const root = document.getElementById('oidc-buttons');
|
||||
list.forEach(p => {
|
||||
const a = document.createElement('a');
|
||||
a.href = '/admin/login/oidc/' + encodeURIComponent(p.name);
|
||||
a.className = 'block w-full text-center bg-slate-800 hover:bg-slate-700 border border-slate-700 text-sm rounded px-4 py-2 transition';
|
||||
a.textContent = SIGNIN_WITH + ' ' + (p.display_name || p.name);
|
||||
root.appendChild(a);
|
||||
});
|
||||
block.classList.remove('hidden');
|
||||
}).catch(() => { /* silently hide block on any error */ });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
rustdesk-server (1.1.15) UNRELEASED; urgency=medium
|
||||
rustdesk-server (1.1.17-pro) UNRELEASED; urgency=medium
|
||||
|
||||
* Fix: 127.0.0.1 is not loopback (#515)
|
||||
* Higher default bandwidth
|
||||
|
||||
+64
-7
@@ -1,21 +1,72 @@
|
||||
version: '3'
|
||||
# Builds a minimal debian image that, on every container start, pulls the
|
||||
# hbbs/hbbr/utils .deb from a Gitea Actions artifact and installs it before
|
||||
# launching the binary. Both services share the same image; only the command
|
||||
# differs.
|
||||
#
|
||||
# The .deb is fetched at runtime (not build time), so `docker compose up`
|
||||
# always picks up the newest successful run on $GITEA_BRANCH. To pin a
|
||||
# specific zip and skip auto-discovery, set ARTIFACT_URL in your shell or
|
||||
# .env, e.g.:
|
||||
# ARTIFACT_URL=https://gitea.cstudio.ch/mike/rustdesk-server/actions/runs/173/artifacts/rustdesk-server-linux-amd64-1e961cdd929f7af97148b76d9de79998a89402a3 \
|
||||
# docker compose up -d
|
||||
#
|
||||
|
||||
networks:
|
||||
rustdesk-net:
|
||||
external: false
|
||||
|
||||
# The image only needs to bundle curl/jq/unzip/tini/dpkg + the fetch scripts —
|
||||
# no build args required. The actual .deb download happens at container start
|
||||
# (see docker/entrypoint.sh), driven by the env vars in x-rustdesk-env below.
|
||||
x-rustdesk-build: &rustdesk-build
|
||||
context: ./docker
|
||||
dockerfile: Dockerfile.deb
|
||||
|
||||
# Runtime env. Two groups:
|
||||
# 1) Artifact-fetch config consumed by entrypoint.sh on every container
|
||||
# start — set ARTIFACT_URL to pin a specific zip, otherwise the script
|
||||
# picks the newest successful run on $GITEA_BRANCH.
|
||||
# 2) hbbs/hbbr knobs. Most settings (relay, bootstrap admin, key, http
|
||||
# port) are passed via CLI flags below — the binary's env-var
|
||||
# convention transforms `--foo-bar` into `FOO-BAR` (literal dashes,
|
||||
# uppercase), which is awkward in YAML, so flags are clearer.
|
||||
x-rustdesk-env: &rustdesk-env
|
||||
GITEA_URL: "${GITEA_URL:-https://gitea.cstudio.ch}"
|
||||
GITEA_OWNER: "${GITEA_OWNER:-mike}"
|
||||
GITEA_REPO: "${GITEA_REPO:-rustdesk-server}"
|
||||
GITEA_BRANCH: "${GITEA_BRANCH:-pro-features}"
|
||||
ARTIFACT_URL: "${ARTIFACT_URL:-}"
|
||||
RUST_LOG: "${RUST_LOG:-info}"
|
||||
# Force relay for all sessions even on LAN. Uncomment to enable.
|
||||
# ALWAYS_USE_RELAY: "Y"
|
||||
# Override DB path. Default: ./db_v2.sqlite3 in WORKDIR
|
||||
# (= /var/lib/rustdesk-server/db_v2.sqlite3 in this image).
|
||||
# DB_URL: "/var/lib/rustdesk-server/db_v2.sqlite3"
|
||||
|
||||
services:
|
||||
hbbs:
|
||||
container_name: hbbs
|
||||
build: *rustdesk-build
|
||||
image: rustdesk-server-cst:latest
|
||||
platform: linux/amd64
|
||||
command:
|
||||
- hbbs
|
||||
- --bootstrap-admin-username=${RUSTDESK_BOOTSTRAP_ADMIN_USERNAME:-admin}
|
||||
- --bootstrap-admin-password=${RUSTDESK_BOOTSTRAP_ADMIN_PASSWORD:-changeme}
|
||||
# - --key=- # "-" auto-generates a key; "_" forces encrypted-only with no explicit key
|
||||
# - --http-port=21114 # admin HTTP API/UI port; 0 disables
|
||||
# When the admin UI shows a device's unattended password.
|
||||
# logged-out (default) = only when nobody is logged in; always = also while a user is logged in.
|
||||
- --unattended-pwd-visibility=${RUSTDESK_UNATTENDED_PWD_VISIBILITY:-logged-out}
|
||||
environment: *rustdesk-env
|
||||
ports:
|
||||
- 21114:21114
|
||||
- 21115:21115
|
||||
- 21116:21116
|
||||
- 21116:21116/udp
|
||||
- 21118:21118
|
||||
image: rustdesk/rustdesk-server:latest
|
||||
command: hbbs -r rustdesk.example.com:21117
|
||||
volumes:
|
||||
- ./data:/root
|
||||
- ./data:/var/lib/rustdesk-server
|
||||
networks:
|
||||
- rustdesk-net
|
||||
depends_on:
|
||||
@@ -24,13 +75,19 @@ services:
|
||||
|
||||
hbbr:
|
||||
container_name: hbbr
|
||||
# Same build + image tag as hbbs — compose builds once and both reuse it.
|
||||
build: *rustdesk-build
|
||||
image: rustdesk-server-cst:latest
|
||||
platform: linux/amd64
|
||||
command:
|
||||
- hbbr
|
||||
# - --key=- # match the key set on hbbs (if any)
|
||||
environment: *rustdesk-env
|
||||
ports:
|
||||
- 21117:21117
|
||||
- 21119:21119
|
||||
image: rustdesk/rustdesk-server:latest
|
||||
command: hbbr
|
||||
volumes:
|
||||
- ./data:/root
|
||||
- ./data:/var/lib/rustdesk-server
|
||||
networks:
|
||||
- rustdesk-net
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Minimal debian image with the tooling needed to download + install the
|
||||
# hbbs/hbbr/rustdesk-utils .deb at *container start* (not build time).
|
||||
# The actual fetch happens in /usr/local/sbin/entrypoint.sh, which calls
|
||||
# fetch-artifact.sh on every start — see docker-compose.yml for the runtime
|
||||
# env vars (GITEA_URL, GITEA_OWNER, GITEA_REPO, GITEA_BRANCH, ARTIFACT_URL).
|
||||
#
|
||||
# The Gitea workflow only produces amd64 .debs, so pin the image platform.
|
||||
# On non-amd64 hosts (e.g. Apple Silicon) Docker will emulate via qemu.
|
||||
FROM --platform=linux/amd64 debian:bookworm-slim
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl jq unzip tini \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY fetch-artifact.sh /usr/local/sbin/fetch-artifact.sh
|
||||
COPY entrypoint.sh /usr/local/sbin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/sbin/fetch-artifact.sh /usr/local/sbin/entrypoint.sh
|
||||
|
||||
WORKDIR /var/lib/rustdesk-server
|
||||
|
||||
# 21114 admin http, 21115 nat test, 21116/tcp+udp signal, 21117 relay,
|
||||
# 21118 web socket signal, 21119 web socket relay.
|
||||
EXPOSE 21114 21115 21116 21116/udp 21117 21118 21119
|
||||
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/sbin/entrypoint.sh"]
|
||||
CMD ["hbbs"]
|
||||
@@ -0,0 +1,48 @@
|
||||
# Multi-stage build: clones rustdesk-server from a Git remote and builds the
|
||||
# hbbs / hbbr / rustdesk-utils binaries from source.
|
||||
#
|
||||
# Build args:
|
||||
# RUSTDESK_GIT_URL Git URL to clone (default: gitea.cstudio.ch fork)
|
||||
# RUSTDESK_GIT_BRANCH Branch / tag / ref to check out (default: pro-features)
|
||||
# RUST_VERSION Rust toolchain image tag (default: 1-bookworm)
|
||||
|
||||
ARG RUST_VERSION=1-bookworm
|
||||
|
||||
FROM rust:${RUST_VERSION} AS builder
|
||||
|
||||
ARG RUSTDESK_GIT_URL=https://gitea.cstudio.ch/mike/rustdesk-server.git
|
||||
ARG RUSTDESK_GIT_BRANCH=pro-features
|
||||
# sqlx::query! macros verify SQL at compile time against the checked-in
|
||||
# db_v2.sqlite3. Override only if you point cargo at a different DB.
|
||||
ARG DATABASE_URL=
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git ca-certificates pkg-config cmake \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
RUN git clone --recurse-submodules --shallow-submodules \
|
||||
--branch "${RUSTDESK_GIT_BRANCH}" --single-branch \
|
||||
"${RUSTDESK_GIT_URL}" .
|
||||
|
||||
RUN if [ -n "${DATABASE_URL}" ]; then export DATABASE_URL="${DATABASE_URL}"; fi \
|
||||
&& cargo build --release --bins
|
||||
|
||||
# Runtime stage
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates tini \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /src/target/release/hbbs /usr/local/bin/hbbs
|
||||
COPY --from=builder /src/target/release/hbbr /usr/local/bin/hbbr
|
||||
COPY --from=builder /src/target/release/rustdesk-utils /usr/local/bin/rustdesk-utils
|
||||
COPY --from=builder /src/admin_ui /opt/rustdesk/admin_ui
|
||||
|
||||
WORKDIR /root
|
||||
EXPOSE 21114 21115 21116 21116/udp 21117 21118 21119
|
||||
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
CMD ["hbbs"]
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runtime entrypoint: pulls the latest .deb artifact from Gitea Actions and
|
||||
# installs it on every container start, then execs the CMD (hbbs / hbbr).
|
||||
#
|
||||
# Configuration comes from the environment — see docker-compose.yml:
|
||||
# ARTIFACT_URL pinned zip URL; if unset the script discovers the newest
|
||||
# successful run on GITEA_BRANCH
|
||||
# GITEA_URL, GITEA_OWNER, GITEA_REPO, GITEA_BRANCH
|
||||
# required when ARTIFACT_URL is unset
|
||||
#
|
||||
# Container fails to start if the fetch fails — that's intentional: we always
|
||||
# want a fresh artifact, never a stale one.
|
||||
set -euo pipefail
|
||||
|
||||
/usr/local/sbin/fetch-artifact.sh
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
# Fetches a Gitea Actions artifact zip and installs every .deb inside.
|
||||
#
|
||||
# Two modes:
|
||||
# 1. $ARTIFACT_URL is set → download that zip directly.
|
||||
# 2. Otherwise → discover the newest successful run on $GITEA_BRANCH via the
|
||||
# `/api/v1/.../actions/tasks` endpoint and download
|
||||
# `<run.url>/artifacts/<ARTIFACT_PREFIX><head_sha>`. We use the web
|
||||
# download URL rather than `/api/v1/.../actions/artifacts`, which on this
|
||||
# Gitea instance returns an empty list even when uploads have succeeded.
|
||||
set -euo pipefail
|
||||
|
||||
ARTIFACT_URL="${ARTIFACT_URL:-}"
|
||||
ARTIFACT_PREFIX="${ARTIFACT_PREFIX:-rustdesk-server-linux-amd64-}"
|
||||
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
if [[ -n "$ARTIFACT_URL" ]]; then
|
||||
zip_url="$ARTIFACT_URL"
|
||||
echo "==> Using pinned ARTIFACT_URL: $zip_url"
|
||||
else
|
||||
: "${GITEA_URL:?GITEA_URL required when ARTIFACT_URL is unset}"
|
||||
: "${GITEA_OWNER:?GITEA_OWNER required when ARTIFACT_URL is unset}"
|
||||
: "${GITEA_REPO:?GITEA_REPO required when ARTIFACT_URL is unset}"
|
||||
: "${GITEA_BRANCH:?GITEA_BRANCH required when ARTIFACT_URL is unset}"
|
||||
|
||||
api="${GITEA_URL%/}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}"
|
||||
echo "==> Listing workflow runs at $api/actions/tasks (branch=$GITEA_BRANCH)"
|
||||
list="$(curl -fsSL "$api/actions/tasks?limit=20")"
|
||||
|
||||
# Newest successful run on $GITEA_BRANCH. The .url field is the html run
|
||||
# page (e.g. .../actions/runs/173) — append /artifacts/<name> for the zip.
|
||||
read -r run_url head_sha < <(jq -r --arg branch "$GITEA_BRANCH" '
|
||||
.workflow_runs
|
||||
| map(select(.head_branch == $branch and .status == "success"))
|
||||
| sort_by(.updated_at)
|
||||
| last
|
||||
| if . == null then "" else "\(.url) \(.head_sha)" end
|
||||
' <<<"$list")
|
||||
|
||||
if [[ -z "${run_url:-}" || "$run_url" == "null" ]]; then
|
||||
echo "ERROR: no successful run on branch '$GITEA_BRANCH'." >&2
|
||||
jq -r '.workflow_runs[] | " url=\(.url) branch=\(.head_branch) status=\(.status) updated=\(.updated_at)"' <<<"$list" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
zip_url="$run_url/artifacts/${ARTIFACT_PREFIX}${head_sha}"
|
||||
echo "==> Discovered $zip_url"
|
||||
fi
|
||||
|
||||
echo "==> Downloading $zip_url"
|
||||
curl -fsSL -o "$work/artifact.zip" "$zip_url"
|
||||
|
||||
mkdir -p "$work/deb"
|
||||
unzip -o "$work/artifact.zip" -d "$work/deb"
|
||||
mapfile -t debs < <(find "$work/deb" -type f -name '*.deb' | sort)
|
||||
if [[ ${#debs[@]} -eq 0 ]]; then
|
||||
echo "ERROR: artifact zip contained no .deb files" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf ' - %s\n' "${debs[@]}"
|
||||
|
||||
# Postinst scripts call deb-systemd-invoke/systemctl; block them from starting
|
||||
# anything while we're inside a build layer.
|
||||
echo '#!/bin/sh' >/usr/sbin/policy-rc.d
|
||||
echo 'exit 101' >>/usr/sbin/policy-rc.d
|
||||
chmod +x /usr/sbin/policy-rc.d
|
||||
|
||||
# The .debs declare "Depends: systemd", which would drag full systemd into the
|
||||
# image. The binaries themselves don't need it at runtime — only the bundled
|
||||
# .service files reference it — so install with --force-depends.
|
||||
dpkg -i --force-depends "${debs[@]}"
|
||||
|
||||
rm -f /usr/sbin/policy-rc.d
|
||||
@@ -0,0 +1,356 @@
|
||||
# Agent API authentication
|
||||
|
||||
Reference for the per-device signature gate on the agent-facing HTTP
|
||||
API. Seven endpoints are gated:
|
||||
|
||||
- `POST /api/heartbeat`
|
||||
- `POST /api/sysinfo`
|
||||
- `POST /api/unattended-password`
|
||||
- `POST /api/agent/exec-result` — managed-only (no legacy/unsigned path)
|
||||
- `POST /api/agent/login-event` — user-logon / logoff events observed
|
||||
by the agent. Same TOFU lifecycle as heartbeat / sysinfo: stock
|
||||
RustDesk doesn't post here at all, so in practice every caller is a
|
||||
managed agent; the legacy/unsigned path is kept for symmetry.
|
||||
- `POST /api/agent/metrics` — continuous CPU / memory / top-process
|
||||
samples (≈1 / minute). Surfaced on the admin Devices detail page as
|
||||
a 24 h sparkline + live snapshot card.
|
||||
- `POST /api/agent/perf-events` — sparse Windows-event-log entries
|
||||
flagged by `Microsoft-Windows-Diagnostics-Performance/Operational`,
|
||||
`Microsoft-Windows-Resource-Exhaustion-Detector/Operational`, and
|
||||
hand-picked `System` IDs (41 / 6008 / 1001 — unexpected reboot /
|
||||
dirty shutdown / BSOD). Server dedups via UNIQUE (peer_id, provider,
|
||||
record_id).
|
||||
|
||||
For the operator workflow — turning it on, the dashboard toggle, what
|
||||
happens when a managed agent is uninstalled — see the matching section
|
||||
in [CONFIGURATION.md](CONFIGURATION.md).
|
||||
|
||||
## Why this exists
|
||||
|
||||
All three endpoints originally accepted any caller who supplied an `id`
|
||||
and `uuid` in the JSON body. Knowing those two values (plaintext on the
|
||||
device, sent over the rendezvous wire) was enough to inject arbitrary
|
||||
inventory or heartbeat state for that device — including BIOS serials,
|
||||
BitLocker recovery keys, the active console user, network interfaces,
|
||||
connection lists, and the per-boot unattended-access password the
|
||||
admin UI surfaces to support staff.
|
||||
|
||||
The fix reuses the Ed25519 keypair that the agent **already** generates
|
||||
on first run and registers with the rendezvous server via `RegisterPk`.
|
||||
Every signed HTTP request is verified against the public key the
|
||||
rendezvous handshake stored in `peer.pk`, so the trust root is the same
|
||||
one the relay encryption already depends on. No new credential to
|
||||
provision, no new secret to leak.
|
||||
|
||||
## Trust root
|
||||
|
||||
```
|
||||
First run Rendezvous (port 21116, TCP/protobuf)
|
||||
agent generates sk,pk ───── RegisterPk(id, pk) ─────► server stores
|
||||
in hello-agent.toml peer.pk
|
||||
|
||||
Every subsequent request HTTP API (port 21114)
|
||||
agent signs body server verifies sig
|
||||
with sk ───── POST /api/heartbeat ─────► against peer.pk
|
||||
───── POST /api/sysinfo ─────► (when peer.managed=1)
|
||||
───── POST /api/unattended-password ─────►
|
||||
───── POST /api/agent/exec-result ─────► (always required)
|
||||
```
|
||||
|
||||
The same secret key signs both the rendezvous identity proof and the
|
||||
HTTP-API payload — there's only one credential per device.
|
||||
|
||||
## Per-peer `managed` flag
|
||||
|
||||
The gate is per-device, controlled by the `peer.managed` column
|
||||
(`INTEGER NOT NULL DEFAULT 0`, added by a soft `ALTER` at startup).
|
||||
|
||||
| `managed` | Server behaviour |
|
||||
|-----------|----------------------------------------------------------------------------------|
|
||||
| `0` | Legacy path. Signed requests are still verified if present, but absence is OK. |
|
||||
| `1` | Signature required. Any unsigned request claiming this `id` returns 401. |
|
||||
|
||||
How the flag transitions:
|
||||
|
||||
- **TOFU promote (0 → 1).** The first request that arrives with a valid
|
||||
signature flips `managed` to 1. Hello-agent signs from boot one, so
|
||||
the first heartbeat after a hello-agent install transparently locks
|
||||
the peer down. No admin action required.
|
||||
- **Admin promote (0 → 1).** `PUT /api/peers/:id/managed {"managed":true}`
|
||||
or the **Require signed API** action in the dashboard's Devices row
|
||||
menu. Useful for pre-enrolling a peer record before the agent has
|
||||
posted anything.
|
||||
- **Admin downgrade (1 → 0).** Same endpoint, `{"managed":false}`, or
|
||||
**Allow unsigned API** in the dashboard. Use when the managed agent
|
||||
has been replaced with stock RustDesk on that device. The dashboard
|
||||
toggle requires a confirm because the operation reopens the
|
||||
spoofing surface.
|
||||
- **Never auto-downgraded.** A failed signature on a `managed=1` peer
|
||||
is a 401, full stop — there is no "fall back to unsigned" path.
|
||||
- **Invalid sig on a `managed=0` peer is also 401**, never silently
|
||||
treated as legacy. This prevents an attacker from probing for the
|
||||
legacy path by deliberately sending a broken signature.
|
||||
|
||||
## Wire format
|
||||
|
||||
A signed agent request carries two headers in addition to the JSON body:
|
||||
|
||||
```
|
||||
X-RD-Device-Id: <id>
|
||||
X-RD-Signature: v1.<unix_ts>.<base64(ed25519_sig)>
|
||||
```
|
||||
|
||||
The signed message is the byte concatenation:
|
||||
|
||||
```
|
||||
"rd-api-v1\n" || METHOD || "\n" || PATH || "\n" || TS || "\n" || sha256(BODY)
|
||||
```
|
||||
|
||||
Where:
|
||||
|
||||
- `METHOD` is the uppercase HTTP method (`POST`).
|
||||
- `PATH` is the request path with leading slash and no query string
|
||||
(`/api/heartbeat`, `/api/sysinfo`, `/api/unattended-password`).
|
||||
- `TS` is the same decimal Unix timestamp that appears in the header.
|
||||
- `sha256(BODY)` is the raw 32-byte SHA-256 of the request body — *not*
|
||||
hex-encoded, *not* base64-encoded. It is concatenated as binary.
|
||||
- The signature is detached Ed25519 over that 32-byte-plus-prefix
|
||||
message, base64-encoded with the standard alphabet and no
|
||||
URL-safe substitutions.
|
||||
|
||||
The `v1.` prefix on the header value reserves a rotation point. The
|
||||
server rejects any other version string.
|
||||
|
||||
### Why this shape
|
||||
|
||||
- **Domain separator (`rd-api-v1\n`)** prevents the same `sk` being
|
||||
tricked into signing data interpretable as another protocol.
|
||||
- **Method + path** stop a captured `POST /api/sysinfo` signature from
|
||||
being replayed as some future `POST /api/disconnect`.
|
||||
- **`sha256(body)`** lets us sign without holding the body twice in
|
||||
memory on the verify side, and survives any future proxy
|
||||
re-chunking.
|
||||
- **Timestamp in both the header and the signed message** makes the
|
||||
skew check trivial without re-parsing the signature value.
|
||||
|
||||
## Server-side verification
|
||||
|
||||
The extractor [`api::device_auth::verify`](../src/api/device_auth.rs)
|
||||
runs before each agent handler:
|
||||
|
||||
1. **Parse headers.** Both `X-RD-Device-Id` and `X-RD-Signature` must
|
||||
be present, or both absent. Mixed states are 401.
|
||||
2. **Validate the signature envelope.** Version must be `v1`. The
|
||||
timestamp must be within ±300 seconds of the server's clock. The
|
||||
base64 decode must succeed.
|
||||
3. **Replay-check.** A keyed-by-`(id, ts, sig-prefix)` LRU cache (size
|
||||
16 384, sliding 600-second TTL, sweep-on-insert) rejects exact
|
||||
replays inside the window. If the cache is full, we accept and skip
|
||||
the cache — DoS-by-cache-exhaustion is uninteresting compared to
|
||||
the rest of the surface.
|
||||
4. **Look up `peer.pk` and `peer.managed`** in one query.
|
||||
5. **Verify the detached Ed25519 signature** against the canonical
|
||||
signed-message bytes (see *Wire format* above).
|
||||
6. **TOFU promote.** A valid signature on a `managed=0` peer flips the
|
||||
flag to 1 in the same request. The promote is best-effort — if the
|
||||
DB write fails, the original request is still served, the next
|
||||
heartbeat will retry.
|
||||
7. **Bind the trusted id to the body.** After the handler parses JSON,
|
||||
the body's `id` field must match the header's `X-RD-Device-Id`.
|
||||
Mismatch is 401 — this is the gate that stops a signed request from
|
||||
being repurposed to write to a different peer's row.
|
||||
|
||||
If no signature headers are present and the peer is `managed=0`, the
|
||||
verifier returns `LegacyUnsigned`; the handler then calls
|
||||
`enforce_managed_for_id(body.id)` after parsing the body, which still
|
||||
rejects unsigned requests for any *other* peer that has since become
|
||||
managed.
|
||||
|
||||
## Agent-side signing
|
||||
|
||||
The signer is one small module: [`vendor/rustdesk/src/hbbs_http/sign.rs`](https://example.invalid/sign.rs)
|
||||
in the hello-agent vendor tree. It reads the existing
|
||||
`Config::get_key_pair()` (returns `(sk, pk)` from `hello-agent.toml`)
|
||||
and the existing `Config::get_id()`, builds the canonical message, and
|
||||
calls `sodiumoxide::crypto::sign::sign_detached`. Returns the two
|
||||
header lines joined by `\n`, ready for the multi-header parser in
|
||||
`common.rs::post_request_`.
|
||||
|
||||
The agent always tries to sign. If the keypair hasn't been generated
|
||||
yet (extremely early boot, before rendezvous has run), the signer
|
||||
returns `None`, the request goes out unsigned, and:
|
||||
|
||||
- If `peer.managed=0`: server accepts it (legacy path).
|
||||
- If `peer.managed=1`: server returns 401, the agent's next heartbeat
|
||||
retries.
|
||||
|
||||
This is the only condition under which a hello-agent build sends an
|
||||
unsigned request, and it self-resolves on the next sync tick.
|
||||
|
||||
## Operational gotchas
|
||||
|
||||
- **Stock RustDesk clients keep working** because they post unsigned
|
||||
and their peer rows stay at `managed=0`. The first time you install
|
||||
hello-agent on a device, the existing `peer.pk` row gets reused (the
|
||||
agent re-generated a keypair iff `hello-agent.toml` was wiped). The
|
||||
first signed heartbeat then promotes the row.
|
||||
- **`hello-agent --uninstall` preserves the keypair.** A reinstall is
|
||||
transparent — signing keeps working.
|
||||
- **Wiping `hello-agent.toml` between sessions** does mean the next
|
||||
boot generates a new keypair. The rendezvous server will treat that
|
||||
as a key roll (`register_pk of … due to key not confirmed`) and
|
||||
store the new `pk`. The signed HTTP API picks up the new key as soon
|
||||
as that rendezvous step completes — usually within a few seconds.
|
||||
See [the stale-key recovery note in hello-agent's README](https://example.invalid/README.md)
|
||||
for the supporter-side symptoms of a key drift.
|
||||
- **Clock skew over ±5 minutes** will reject signatures. If your
|
||||
fleet shows scattered 401s on heartbeat, check NTP on the affected
|
||||
hosts. The server side is the canonical clock.
|
||||
- **Replay cache survives only inside a single hbbs process.** A
|
||||
restart clears it. Combined with the 300-second skew window this
|
||||
means a captured signature is replayable across a restart if and
|
||||
only if both restarts happen inside that window — an acceptable
|
||||
trade-off for keeping the cache in-memory.
|
||||
- **One server, mixed fleet.** Stock clients and hello-agent clients
|
||||
can target the same hbbs without any flag-level config. The gate is
|
||||
per-peer.
|
||||
|
||||
## Failure modes & log lines
|
||||
|
||||
| Symptom | Likely cause |
|
||||
|-------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------|
|
||||
| Heartbeats from a known peer suddenly return 401 | Peer was just promoted (TOFU or admin) and the agent build doesn't sign yet → upgrade agent. |
|
||||
| Heartbeats fail intermittently with 401 | Clock skew > 5 min, or NAT churn replaying a captured request inside the window. |
|
||||
| `peer X TOFU-promoted to managed=1` in hbbs log | Normal — first valid signature from a previously-unsigned peer. |
|
||||
| `admin <user> set peer X managed=<bool> via dashboard` | Normal — operator used the Devices toggle. |
|
||||
| `peer_set_managed(X) failed: …` | DB write failed during TOFU promote. The request was still served; next request will retry. |
|
||||
| Admin row shows **Unsigned** for a peer running hello-agent | Agent hasn't completed its first signed POST yet (keypair race), or it's running a build |
|
||||
| | that pre-dates the signing patch — check `vendor/rustdesk/src/hbbs_http/sign.rs` is present. |
|
||||
|
||||
## Remote PowerShell exec
|
||||
|
||||
Layered on top of the signature gate. An admin in the dashboard sends a
|
||||
script to a peer; the agent runs it as its service account; output and
|
||||
exit code come back into the dashboard within ~1 s (the heartbeat
|
||||
interval).
|
||||
|
||||
### Three independent gates
|
||||
|
||||
A dispatch must pass **all three** server-side checks before a row is
|
||||
queued — the agent never sees a script it shouldn't have:
|
||||
|
||||
1. **`AuthedUser.is_admin`** — only admins can dispatch.
|
||||
2. **`peer.managed = 1`** — the same flag the signed-API gate uses.
|
||||
This means TOFU has already promoted the peer (or an admin explicitly
|
||||
flipped it). Stock RustDesk clients are uninvited.
|
||||
3. **Strategy `enable-remote-exec = "Y"`** — the resolved strategy for
|
||||
the peer must explicitly opt in. Defaults to off. Set it on a strategy,
|
||||
assign the strategy to the peer (or its group / owner), exec is now
|
||||
live for that scope. *Server-side only — the key is never pushed to
|
||||
the client.* See [STRATEGIES.md](STRATEGIES.md).
|
||||
|
||||
### Wire path
|
||||
|
||||
```
|
||||
Admin UI ──POST /admin/pages/devices/:id/exec──► Server inserts exec_history(status='queued')
|
||||
│
|
||||
▼
|
||||
Agent's next heartbeat reply carries `exec: [{cmd_id, script, max_secs, max_bytes}]`;
|
||||
the server flips the row to 'running' atomically (exec_pop_queued_for_peer).
|
||||
│
|
||||
▼
|
||||
Agent runs `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command -`,
|
||||
writes the script to stdin, captures stdout+stderr up to 1 MiB, kills on 5-minute wall clock.
|
||||
│
|
||||
▼
|
||||
Admin UI ◄──poll /admin/pages/devices/:id/exec/:cmd_id/poll── Server ◄──POST /api/agent/exec-result (signed)── Agent
|
||||
```
|
||||
|
||||
### Limits
|
||||
|
||||
| Setting | Default | Where |
|
||||
|----------------|--------:|------------------------------------------------------------|
|
||||
| Script size | 32 KiB | `src/api/admin/pages/exec.rs::MAX_SCRIPT_BYTES` |
|
||||
| Wall-clock | 300 s | `src/api/heartbeat.rs::EXEC_MAX_SECS` (sent to agent) |
|
||||
| Output capture | 1 MiB | `src/api/heartbeat.rs::EXEC_MAX_BYTES` (sent to agent) |
|
||||
| In-flight/peer | 1 | `exec_in_flight_count > 0` blocks new dispatch |
|
||||
|
||||
The agent enforces wall-clock and output-capture locally — server caps
|
||||
are advisory unless you also harden the agent. If you don't trust your
|
||||
own agent build, the server caps still bound storage and replay-cost.
|
||||
|
||||
### Result POST authentication
|
||||
|
||||
`POST /api/agent/exec-result` is the only agent endpoint that **always**
|
||||
requires a signature, even when the peer happens to be `managed=0`.
|
||||
There's no legacy compatibility story for exec — if the agent can't
|
||||
sign, the result POST is rejected outright and the row sits in `running`
|
||||
until an admin notices. Reason: an attacker who can spoof `(id, uuid)`
|
||||
shouldn't be able to forge "I executed your command and here's the
|
||||
output" for a device they don't actually control.
|
||||
|
||||
### Operational notes
|
||||
|
||||
- **The dispatch row stays `running` until the agent posts a result.**
|
||||
If the agent crashes mid-script there's no automatic timeout cleanup
|
||||
yet (planned: a hourly task that flips long-stuck `running` rows to
|
||||
`errored`). Admins can dispatch a fresh command after the in-flight
|
||||
one ages past 5 minutes by waiting; the in-flight check is wall-clock
|
||||
based on `issued_at`.
|
||||
- **Output may contain secrets.** A `Get-Content` of a credential file
|
||||
goes straight into the `exec_history` table and the admin UI. The
|
||||
current schema has no per-row access control beyond "is_admin"; if
|
||||
you need finer scoping, audit log retention plus your `users` table
|
||||
ACL is the only knob.
|
||||
- **No interactive REPL yet.** Each dispatch is one shot: write script,
|
||||
run, read result. Multi-command sessions or interactive prompts
|
||||
(Read-Host, sudo-style passwords) will hang and time out. This is by
|
||||
design for v1 — Option B in the original architecture discussion.
|
||||
|
||||
## File map
|
||||
|
||||
Server:
|
||||
|
||||
| Path | Purpose |
|
||||
|-------------------------------------------|------------------------------------------------------------------|
|
||||
| `src/api/device_auth.rs` | The verifier (extractor + replay cache + TOFU promote). |
|
||||
| `src/api/heartbeat.rs`, `src/api/sysinfo.rs`, `src/api/unattended.rs` | Wired to call `verify` then `enforce_managed_for_id`. |
|
||||
| `src/api/agent_exec.rs` | `POST /api/agent/exec-result` (sig-required, no legacy path). |
|
||||
| `src/api/peers.rs::set_managed` | `PUT /api/peers/:id/managed` admin endpoint. |
|
||||
| `src/api/admin/pages/devices.rs::toggle_managed` | Dashboard action handler. |
|
||||
| `src/api/admin/pages/exec.rs` | Per-device exec page (form + history + HTMX poll fragment). |
|
||||
| `src/api/strategy/mod.rs::allows_remote_exec` | Resolves the per-peer strategy and reads `enable-remote-exec`. |
|
||||
| `src/database.rs::M2_SOFT_ALTERS` | `ALTER TABLE peer ADD COLUMN managed`. |
|
||||
| `src/database.rs::M5_SCHEMA` | `CREATE TABLE exec_history` + indexes. |
|
||||
| `src/database.rs::peer_get_auth, peer_set_managed` | DB helpers (untyped `sqlx::query` so they survive the no-DB-migrated dev build). |
|
||||
| `src/database.rs::exec_create, exec_pop_queued_for_peer, exec_finish, exec_get_by_cmd_id, exec_in_flight_count, exec_list_for_peer` | Exec lifecycle helpers. |
|
||||
|
||||
Agent — hello-agent vendor tree:
|
||||
|
||||
| Path | Purpose |
|
||||
|------------------------------------------------------------|---------------------------------------------------------------|
|
||||
| `vendor/rustdesk/src/hbbs_http/sign.rs` | The signer. |
|
||||
| `vendor/rustdesk/src/hbbs_http/sync.rs` (call sites + `EXEC_SENDER`) | Heartbeat + sysinfo POSTs sign; heartbeat reply forwards queued `exec` requests to the broadcast channel. |
|
||||
| `vendor/rustdesk/src/common.rs::post_request_, parse_simple_header` | Header parser now accepts `\n`-separated `Name: Value` pairs (backward-compatible). |
|
||||
| `vendor/rustdesk/src/lib.rs` | `pub mod hbbs_http` — required so hello-agent can reach both `::sign` and `::sync::exec_signal_receiver`. |
|
||||
|
||||
Agent — hello-agent crate (outside the vendor tree):
|
||||
|
||||
| Path | Purpose |
|
||||
|-------------------------------------|-----------------------------------------------------------------------------------------|
|
||||
| `src/unattended_password.rs::try_report` | Reports the per-boot password to `/api/unattended-password`; signs the POST. |
|
||||
| `src/exec.rs` | PowerShell runner. Subscribes to the sync layer's broadcast channel, spawns `powershell.exe`, captures stdout/stderr with caps, signs and POSTs the result to `/api/agent/exec-result`. Started from `run_server()` in main.rs. |
|
||||
|
||||
## Out of scope
|
||||
|
||||
Other agent / management endpoints exist on the same server. They are
|
||||
deliberately *not* covered by this gate because their trust model is
|
||||
different:
|
||||
|
||||
| Endpoint | Why it isn't signature-gated |
|
||||
|--------------------------------|-----------------------------------------------------------------------------------------------------------|
|
||||
| `POST /api/devices/cli` | Enrollment via `rustdesk --assign --token <T> …`. Already authenticated by a user/admin bearer session; the operator's job is to *supply* an arbitrary `(id, uuid)` for binding. Requiring the device's `sk` would defeat the use case. |
|
||||
| `GET /api/sysinfo_ver` | Returns a single public version string. No body, no DB write — no spoof surface to gate. |
|
||||
| `POST /api/record` | Session-recording upload. Disabled by default in the OSS uploader; managed builds use it under a separate auth model. Out of scope for the current sweep. |
|
||||
| `POST /api/login`, `/api/login-options`, `/api/currentUser`, `/api/logout` | User session management — separate auth model (password + TOTP / OIDC). |
|
||||
| Everything under `/api/ab/*`, `/api/audit/*`, `/api/peers*`, `/api/2fa/*`, `/api/oidc/*`, `/admin/*` | Already gated by `AuthedUser` (cookie or bearer). |
|
||||
@@ -0,0 +1,759 @@
|
||||
# RustDesk Server (hbbs) — Configuration Guide
|
||||
|
||||
This document covers the runtime flags exposed by `hbbs`, the file formats
|
||||
it reads (notably `oidc.toml`), and the operator workflows that string
|
||||
those together — bootstrap admin, OIDC sign-in, TOTP, address books,
|
||||
strategies, recordings, the admin dashboard.
|
||||
|
||||
The matching desktop-client API surface is documented separately in the
|
||||
`rustdesk` repo at `docs/CONSOLE_API.md`.
|
||||
|
||||
---
|
||||
|
||||
## CLI flags
|
||||
|
||||
Pass flags directly on the command line. There is no config file for
|
||||
hbbs itself (only the optional `oidc.toml` referenced from a flag).
|
||||
|
||||
### Networking & rendezvous
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `--port=<NUM>` | `21116` | TCP/UDP rendezvous port. |
|
||||
| `--rendezvous-servers=<HOSTS>` | unset | Peer rendezvous servers (comma-separated). |
|
||||
| `--relay-servers=<HOSTS>` | unset | Default relay hosts handed to clients. |
|
||||
| `--rmem=<BYTES>` | platform default | UDP recv buffer size. Bump along with `net.core.rmem_max`. |
|
||||
| `--mask=<CIDR>` | unset | LAN mask (e.g. `192.168.0.0/16`) used to flag local connections. |
|
||||
| `--key=<B64>` | derived from `id_ed25519` | Force a specific public key; clients must match. Leave unset to auto-load `id_ed25519` next to the binary. |
|
||||
|
||||
### HTTP API & dashboard
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `--http-port=<NUM>` | `21114` | HTTP API port (`/api/*`) and admin dashboard (`/admin/*`). `0` disables both. |
|
||||
| `--http-listen=<HOST>` | wildcard | Bind address for `--http-port`. Set to `127.0.0.1` (or `::1`) when nginx/Caddy fronts this port for TLS so the reverse proxy can claim the public port without colliding. See "TLS deployment with nginx" below. |
|
||||
| `--ws-listen=<HOST>` | wildcard | Bind address for the browser-facing WebSocket rendezvous port (`port + 2`, default 21118). Same usage pattern as `--http-listen`. The plain TCP/UDP rendezvous ports (21115/21116) intentionally stay on the wildcard — desktop clients don't go through the reverse proxy. |
|
||||
| `--admin-ui-dir=<PATH>` | `./admin_ui` | Hint at where the dashboard's static HTML lives. The HTML is *embedded* in the binary; this flag is informational. Setting it to empty (`--admin-ui-dir=`) disables the dashboard entirely. |
|
||||
| `--public-base-url=<URL>` | unset | The externally-reachable HTTP base of this server, e.g. `https://rustdesk.example.com:21114`. **Required when OIDC is enabled** — used to build `/oidc/callback` redirect URIs. |
|
||||
|
||||
The matching flag on **hbbr** is `--ws-listen=<HOST>` (binds the relay's WS port, default 21119). hbbr's plain TCP relay port (21117) stays on the wildcard for desktop clients. See `./hbbr --help` for the full list.
|
||||
|
||||
### Bootstrap admin
|
||||
|
||||
| Flag | Purpose |
|
||||
|---|---|
|
||||
| `--bootstrap-admin-username=<USER>` | On first startup, if the `users` table is empty *and* both flags are set, insert one admin user. Subsequent restarts ignore these flags (no overwrite). |
|
||||
| `--bootstrap-admin-password=<PASS>` | Same. Bcrypt-hashed at insert time. |
|
||||
|
||||
If you forget to bootstrap, hbbs logs a warning at startup ("no users in users table"); recover by either restarting with the flags or `INSERT INTO users` directly via `sqlite3`.
|
||||
|
||||
### Address books
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `--ab-legacy-mode=<on\|off>` | `off` | When `on`, `/api/ab/personal` returns 404. Forces clients into the legacy single-blob AB mode. |
|
||||
| `--ab-max-peers-per-book=<NUM>` | `100` | Surfaced via `/api/ab/settings.max_peer_one_ab`. Soft cap; the client uses it for UI hints. |
|
||||
|
||||
### Recordings
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `--recording-dir=<PATH>` | `./recordings` | Root for `/api/record` uploads. One subdirectory per peer. |
|
||||
| `--recording-max-size-mb=<NUM>` | unset (=unlimited) | Per-file ceiling. Aborts oversized parts. |
|
||||
|
||||
> **Note:** Stock OSS RustDesk clients **do not upload** recordings to `/api/record` — the uploader's `ENABLE` flag at `src/hbbs_http/record_upload.rs` has no setter in OSS source. Server-side recording requires a custom client build that flips that flag. The `Recordings` admin tab will stay empty for stock clients; the endpoint is provided for wire parity with Pro clients.
|
||||
|
||||
### Audit retention
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `--audit-retention-days=<NUM>` | `0` (=keep forever) | Hourly task deletes `audit_conn` / `audit_file` / `audit_alarm` rows older than N days. |
|
||||
|
||||
### Email-code login (`/api/login` with `type:"email_code"`)
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `--smtp-host=<HOST>` | unset | If unset, codes are *logged to stdout* (dev mode) instead of mailed. The `email_code` login option is also dropped from `/api/login-options` until SMTP is configured. |
|
||||
| `--smtp-port=<NUM>` | `587` | |
|
||||
| `--smtp-user=<USER>` | unset | Omit for unauthenticated relays. |
|
||||
| `--smtp-pass=<PASS>` | unset | |
|
||||
| `--smtp-from=<ADDR>` | `noreply@<smtp-host>` | From: header. |
|
||||
| `--smtp-tls=<on\|off>` | `on` | STARTTLS on the SMTP transport. |
|
||||
|
||||
### OIDC
|
||||
|
||||
| Flag | Purpose |
|
||||
|---|---|
|
||||
| `--oidc-config=<PATH>` | TOML file (see below). Providers are upserted into `oidc_providers` at startup. Re-run with a different file to change providers; rows missing from the new file remain in the DB but can be `enabled=0`'d via SQL. |
|
||||
| `--public-base-url=<URL>` | **Required** if any provider is configured. Determines the redirect URI registered with the IdP. |
|
||||
|
||||
---
|
||||
|
||||
## OIDC integration
|
||||
|
||||
The server speaks standard OIDC Authorization Code flow with discovery
|
||||
(`/.well-known/openid-configuration`). Tested against Zitadel; should work
|
||||
with any standards-compliant IdP (Keycloak, Auth0, Google, Okta, Authelia,
|
||||
Dex, etc.).
|
||||
|
||||
Two entry points are wired:
|
||||
|
||||
1. **Desktop client** — `/api/login-options` advertises `oidc/<name>` per enabled provider. The Flutter login dialog renders a button per advertised name. Clicking starts the device-flow polling cycle (`/api/oidc/auth` → browser → `/oidc/callback` → `/api/oidc/auth-query` poll).
|
||||
2. **Admin dashboard** — `/admin/login.html` fetches `/admin/oidc/providers` and renders a "Sign in with X" button per provider. Clicking jumps the browser to `/admin/login/oidc/<name>` which 302-redirects to the IdP. After the IdP returns to `/oidc/callback`, the server detects the admin-flow sentinel and finishes by setting the dashboard session cookie + redirecting to `/admin/`.
|
||||
|
||||
### `oidc.toml` schema
|
||||
|
||||
Pass via `--oidc-config /path/to/oidc.toml`.
|
||||
|
||||
```toml
|
||||
[[providers]]
|
||||
# Slug used in URLs (`/admin/login/oidc/<name>`, `/api/login-options`
|
||||
# advertises `oidc/<name>`). Lowercase, no spaces.
|
||||
name = "zitadel"
|
||||
|
||||
# Display label on the sign-in button.
|
||||
display_name = "Sign in with Zitadel"
|
||||
|
||||
# Optional. Square icon URL shown next to the label (not used yet by all
|
||||
# UIs; reserved for future button rendering).
|
||||
# icon_url = "https://example.com/zitadel.svg"
|
||||
|
||||
# OIDC issuer. The server fetches `<issuer_url>/.well-known/openid-configuration`
|
||||
# and caches the discovery doc in-process. Trailing slash is stripped.
|
||||
issuer_url = "https://idp.example.com"
|
||||
|
||||
# Application credentials from the IdP.
|
||||
client_id = "..."
|
||||
client_secret = "..."
|
||||
|
||||
# Scopes requested at the authorization endpoint. Most setups want
|
||||
# "openid email profile". Zitadel additionally needs the project audience
|
||||
# scope to receive role claims (see Role-based admin sync below).
|
||||
scopes = "openid email profile"
|
||||
|
||||
# Optional. If unset, computed as `<--public-base-url>/oidc/callback`.
|
||||
# Override only when you reverse-proxy under a different host.
|
||||
# redirect_url = "https://rustdesk.example.com/oidc/callback"
|
||||
|
||||
# Optional. Defaults to true.
|
||||
enabled = true
|
||||
|
||||
# --- Role-based admin sync (optional) ---
|
||||
# When `admin_role` is set, every successful sign-in via this provider
|
||||
# evaluates the userinfo claim at `roles_claim` and forces the local
|
||||
# user's `is_admin` to (role present in claim). Promotion AND demotion
|
||||
# at the IdP propagate. Leave both unset to manage admin status manually
|
||||
# from the dashboard.
|
||||
# admin_role = "admin"
|
||||
# roles_claim = "roles" # or e.g. "urn:zitadel:iam:org:project:roles"
|
||||
```
|
||||
|
||||
`oidc.toml` may contain multiple `[[providers]]` blocks for multi-IdP setups.
|
||||
|
||||
### Walk-through: Zitadel
|
||||
|
||||
#### In Zitadel
|
||||
|
||||
1. **Project → New project** (or pick an existing one).
|
||||
2. **New application** under the project:
|
||||
- Type: **Web**
|
||||
- Authentication flow: **Code** (Authorization Code with client secret)
|
||||
- Auth method: **Basic** *or* **Post** (server sends `client_id` + `client_secret` in the form body — both modes accept that)
|
||||
- **Redirect URIs**: `<public-base-url>/oidc/callback` — character-exact, including scheme. Zitadel rejects `http://` redirects on non-localhost unless dev mode is on, so use TLS in production.
|
||||
3. **Authorizations** — assign the project's roles to whichever users you want to be admins.
|
||||
4. **Project → General**: turn on **"Assert Roles On Authentication"** so roles flow into the userinfo response.
|
||||
5. Copy **Client ID** and **Client Secret** from the application's overview page.
|
||||
|
||||
#### `oidc.toml`
|
||||
|
||||
```toml
|
||||
[[providers]]
|
||||
name = "zitadel"
|
||||
display_name = "Sign in with Zitadel"
|
||||
issuer_url = "https://your-instance.zitadel.cloud"
|
||||
client_id = "PASTE_FROM_ZITADEL"
|
||||
client_secret = "PASTE_FROM_ZITADEL"
|
||||
# `urn:zitadel:iam:org:project:id:zitadel:aud` is required for the project's
|
||||
# roles to be included in the userinfo response.
|
||||
scopes = "openid email profile urn:zitadel:iam:org:project:id:zitadel:aud"
|
||||
admin_role = "admin"
|
||||
roles_claim = "urn:zitadel:iam:org:project:roles"
|
||||
```
|
||||
|
||||
#### `hbbs` flags
|
||||
|
||||
```sh
|
||||
./hbbs --http-port 21114 \
|
||||
--public-base-url 'https://rustdesk.example.com:21114' \
|
||||
--oidc-config /etc/rustdesk/oidc.toml
|
||||
```
|
||||
|
||||
#### Verify
|
||||
|
||||
After hbbs starts, look for:
|
||||
|
||||
```
|
||||
oidc: provider "zitadel" configured
|
||||
oidc: loaded 1 providers from /etc/rustdesk/oidc.toml
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```sh
|
||||
# 1. Provider visible to the desktop client
|
||||
curl -s http://127.0.0.1:21114/api/login-options
|
||||
# expect a list including "oidc/zitadel"
|
||||
|
||||
# 2. Provider visible to the admin dashboard
|
||||
curl -s http://127.0.0.1:21114/admin/oidc/providers
|
||||
# expect [{"name":"zitadel","display_name":"Sign in with Zitadel",...}]
|
||||
|
||||
# 3. Discovery is reachable (IdP-side)
|
||||
curl -s https://your-instance.zitadel.cloud/.well-known/openid-configuration | jq .issuer
|
||||
```
|
||||
|
||||
### Role-based admin sync
|
||||
|
||||
When `admin_role` is set on a provider, every successful sign-in evaluates
|
||||
the userinfo claim at `roles_claim` (defaults to `"roles"` if unset) and
|
||||
forces `users.is_admin` accordingly. **Promotion and demotion at the IdP
|
||||
propagate on the next login.**
|
||||
|
||||
Two claim shapes are supported:
|
||||
|
||||
- **Object** (Zitadel default at `urn:zitadel:iam:org:project:roles`): role names are keys.
|
||||
```json
|
||||
"urn:zitadel:iam:org:project:roles": {
|
||||
"admin": {"123": "myorg"},
|
||||
"user": {"123": "myorg"}
|
||||
}
|
||||
```
|
||||
- **Array of strings** (generic, common with Keycloak, Auth0 custom claims):
|
||||
```json
|
||||
"roles": ["admin", "user"]
|
||||
```
|
||||
|
||||
Set `admin_role = "admin"` and either set `roles_claim` to the exact claim
|
||||
name (Zitadel) or omit it to default to `"roles"` (generic).
|
||||
|
||||
> **Sharp edge:** when role-sync is configured, manually-granted admin
|
||||
> rights in the dashboard get **revoked** on the next OIDC login if the
|
||||
> role isn't present at the IdP. This is the correct contract for a
|
||||
> single source of truth, but surprising if you forget. Manage admin
|
||||
> status in *one* place at a time.
|
||||
|
||||
### Troubleshooting OIDC
|
||||
|
||||
- **"Sign-in complete" page in browser but desktop client stays at "Waiting account auth"**: usually a state mismatch between server and client. Check `hbbs.log` — the poll endpoint logs every tick at INFO. If you see `status=success` lines that don't stop, suspect a wire-shape mismatch. (This was a real bug we hit and fixed; see git log for `oidc envelope`.)
|
||||
- **Browser shows "identity provider returned an error"**: check `oidc_sessions.error` for the row that just failed. Most common: `redirect_uri` mismatch between Zitadel and `--public-base-url`.
|
||||
- **No "Sign in with X" button in the dashboard or desktop client**: check `oidc_provider_list_enabled()` returns rows. If `--public-base-url` is empty, `/admin/oidc/providers` and `/api/login-options` both suppress OIDC entries (the redirect URI would be unbuildable).
|
||||
- **Admin landing on the "no admin access" error after first OIDC sign-in**: expected if `admin_role` isn't configured. Either configure role-sync (preferred), or have the user sign in once to create their row, then promote them on the Users page. The next OIDC sign-in resolves to that row.
|
||||
|
||||
---
|
||||
|
||||
## TOTP / 2FA
|
||||
|
||||
TOTP enrollment is **self-service**: each user enables it for their own
|
||||
account from the dashboard's "My profile" page. The flow is two-step
|
||||
with QR confirmation, so no secret is stored until the user proves they
|
||||
have a working authenticator:
|
||||
|
||||
1. Sign in to the dashboard → **My profile** (sidebar) → **Enroll TOTP**.
|
||||
2. Server generates a fresh secret and renders an inline SVG QR code +
|
||||
the base32 secret for manual entry. Nothing is written to
|
||||
`user_totp_secrets` at this point.
|
||||
3. User scans the QR into an authenticator (1Password, Authy, Google
|
||||
Authenticator, etc.) and submits the 6-digit code shown.
|
||||
4. Server verifies the code against the pending secret. On match, the
|
||||
secret lands in `user_totp_secrets`. Wrong code re-renders the same
|
||||
QR with an error notice — no need to re-scan.
|
||||
5. Removing TOTP requires the user's current password.
|
||||
|
||||
Admins can disable a user's TOTP from the **Users** page action menu
|
||||
(useful when a user lost their authenticator), but **cannot enroll it
|
||||
on someone else's behalf** — the user has to do that themselves so the
|
||||
secret never travels through admin hands. OIDC-linked accounts skip
|
||||
local TOTP entirely; their MFA lives at the IdP.
|
||||
|
||||
After enrollment, the next desktop-client login flow is:
|
||||
|
||||
1. Username + password → server returns `{"type":"email_check","tfa_type":"tfa_check","secret":<nonce>}`.
|
||||
2. Client opens its verification-code dialog → user enters the 6-digit code → re-POSTs `/api/login` with `type:"email_code"` (yes, that's what the desktop client sends for both email and TOTP second legs), `tfaCode` set, `secret` echoed back.
|
||||
3. Server verifies the code against `user_totp_secrets`, mints an access token, returns `{"type":"access_token", ...}`.
|
||||
|
||||
For dashboard logins, the inline form at `/admin/login.html` shows the TOTP field after the first password submit returns the prompt fragment.
|
||||
|
||||
---
|
||||
|
||||
## Strategies (server-pushed config)
|
||||
|
||||
Strategies push `config_options` to peers via heartbeat replies. They are
|
||||
managed entirely from the dashboard's **Strategies** page. Resolution
|
||||
order per peer:
|
||||
|
||||
1. Direct peer-scoped assignment (`strategy_assignments.peer_id`)
|
||||
2. Device-group assignment via the peer's owner
|
||||
3. User assignment
|
||||
|
||||
The peer's `Config::get_option` calls reflect the resolved values within
|
||||
~15 s of any change to `modified_at` on the strategy row.
|
||||
|
||||
See [STRATEGIES.md](STRATEGIES.md) for the full list of `config_options`
|
||||
keys and what each one does.
|
||||
|
||||
---
|
||||
|
||||
## Remote PowerShell exec (per-peer, strategy-gated)
|
||||
|
||||
Admins can dispatch a PowerShell script to a managed device from the
|
||||
dashboard's **Run command…** action (Devices page row menu, or directly
|
||||
via `/admin/pages/devices/:peer_id/exec`). The agent runs the script as
|
||||
its service account — typically LocalSystem on Windows — and the
|
||||
output streams back into the dashboard within ~1 s.
|
||||
|
||||
This feature is **disabled by default**. To enable it for a peer (or
|
||||
fleet):
|
||||
|
||||
1. Edit (or create) a strategy on the **Strategies** page with the JSON:
|
||||
```json
|
||||
{ "enable-remote-exec": "Y" }
|
||||
```
|
||||
(mix with whatever other strategy options you already push)
|
||||
2. Assign that strategy to the peer, its device group, or its owner.
|
||||
3. The peer's `Auth` column must show **Signed** — exec is refused on
|
||||
`peer.managed=0` peers. See [AGENT-API-AUTH.md](AGENT-API-AUTH.md).
|
||||
|
||||
All three gates (admin role, managed=1, strategy opt-in) are enforced
|
||||
server-side at dispatch time. The strategy key is never pushed to the
|
||||
client — it's checked on the server only and serves purely as the
|
||||
authorization toggle.
|
||||
|
||||
Caps (defaults; live in `src/api/heartbeat.rs` and
|
||||
`src/api/admin/pages/exec.rs`):
|
||||
|
||||
- Script size: **32 KiB** per dispatch.
|
||||
- Wall clock: **5 minutes** per command; the agent kills the process
|
||||
on timeout and marks the row `timed_out`.
|
||||
- Output capture: **1 MiB** combined stdout+stderr; further bytes are
|
||||
drained and discarded, the row gets `truncated=true`.
|
||||
- One in-flight exec per peer at a time.
|
||||
|
||||
See [AGENT-API-AUTH.md](AGENT-API-AUTH.md) for the wire format,
|
||||
authentication, and threat model. Result POSTs are mandatory-signed —
|
||||
there's no legacy/unsigned path for the exec result endpoint.
|
||||
|
||||
---
|
||||
|
||||
## Agent API signing (per-peer)
|
||||
|
||||
`POST /api/heartbeat`, `POST /api/sysinfo`, and
|
||||
`POST /api/unattended-password` are the three agent-facing endpoints
|
||||
that write per-device state. Stock RustDesk and managed builds
|
||||
(hello-agent) both call the first two; only managed builds use the
|
||||
third. Each peer row has a `managed` flag that gates whether the
|
||||
server requires a per-request Ed25519 signature on these endpoints;
|
||||
everything else (`/api/peers`, `/api/ab/*`, audit, recordings, OIDC,
|
||||
etc.) is unaffected. See [AGENT-API-AUTH.md](AGENT-API-AUTH.md) for
|
||||
the full out-of-scope list.
|
||||
|
||||
| `peer.managed` | Heartbeat / sysinfo behaviour |
|
||||
|----------------|----------------------------------------------------------------------------------------|
|
||||
| `0` (default) | Unsigned posts accepted (stock-client compatible). Signed posts still verified. |
|
||||
| `1` | Signature required; unsigned posts return 401. First valid sig auto-promoted to here. |
|
||||
|
||||
Default is `0` after the migration, so **stock RustDesk clients are not
|
||||
affected by the rollout** — they keep posting unsigned, the server keeps
|
||||
accepting. The first valid signature the server sees from a peer is the
|
||||
TOFU promote: that peer's `managed` flips to `1` for good, and unsigned
|
||||
requests claiming that `id` are rejected from then on.
|
||||
|
||||
The wire format and verification details live in
|
||||
[AGENT-API-AUTH.md](AGENT-API-AUTH.md). What you need to know to operate:
|
||||
|
||||
### Dashboard
|
||||
|
||||
The Devices page has a per-row **Auth** column:
|
||||
|
||||
- *Signed* (emerald badge) — `peer.managed = 1`. The peer's heartbeat
|
||||
and sysinfo posts must carry a valid signature; spoofed unsigned
|
||||
requests are rejected.
|
||||
- *Unsigned* (slate badge) — `peer.managed = 0`. Legacy path. Anyone
|
||||
who knows the id+uuid can post inventory and heartbeats as this
|
||||
device.
|
||||
|
||||
The row's action menu has two new entries (mutually exclusive based on
|
||||
current state):
|
||||
|
||||
- **Require signed API** — flips `managed` to 1 (no confirm — it
|
||||
strengthens security). Useful for pre-enrolling a peer record
|
||||
before the agent has booted, or for force-locking a peer if you
|
||||
want to fail fast when an agent is not signing yet.
|
||||
- **Allow unsigned API** — flips `managed` to 0 (confirm dialog,
|
||||
because this reopens the spoofing surface). Use when a managed
|
||||
agent has been uninstalled and replaced with stock RustDesk on the
|
||||
same hardware.
|
||||
|
||||
### API
|
||||
|
||||
`PUT /api/peers/:id/managed` with body `{"managed": true|false}`, gated
|
||||
on the `is_admin` flag of the calling session, returns
|
||||
`{"ok":true,"managed":<bool>}`. Same effect as the dashboard toggle —
|
||||
the dashboard handler just calls this internally after reading the
|
||||
current value to avoid stale-toggle races.
|
||||
|
||||
### Operational notes
|
||||
|
||||
- **Mixed fleets are fine.** Stock and hello-agent clients can target
|
||||
the same hbbs. The gate is per-peer, not per-deployment.
|
||||
- **Replacing hello-agent with stock RustDesk on a device.** The
|
||||
device's `peer.managed` is stuck at 1; the stock client doesn't
|
||||
sign and will start getting 401s. Either re-deploy a signing build
|
||||
*or* flip the peer back to Unsigned in the dashboard.
|
||||
- **TLS still recommended.** Signing protects against id+uuid spoof,
|
||||
not against the unsigned-by-default endpoint surface elsewhere
|
||||
(`/api/login`, `/api/record`, dashboard) — those still rely on
|
||||
whatever TLS termination is in front of hbbs. See *TLS deployment*
|
||||
earlier in this doc.
|
||||
- **Clock skew tolerance is ±5 minutes.** If a host's clock drifts
|
||||
past that, heartbeat starts failing 401. Keep NTP healthy on
|
||||
managed peers; the server's clock is the canonical one.
|
||||
- **The replay cache lives in-memory only.** A hbbs restart clears
|
||||
it. The 5-minute timestamp window bounds the worst-case replay
|
||||
exposure across restarts.
|
||||
|
||||
---
|
||||
|
||||
## Address books
|
||||
|
||||
- **Personal books** are owned per-user and managed from the user's desktop client. The dashboard surfaces them read-only.
|
||||
- **Shared books** are server-side artifacts. Create from the dashboard's **Address books** page → "Manage shares" → grant per-user `read` / `read+write` / `full` access. Clients pick up shared books on their next AB sync (~30 s).
|
||||
|
||||
If you set `--ab-legacy-mode=on`, `/api/ab/personal` 404s and clients fall back to the single-blob `/api/ab` path. Use only if a stock client misbehaves on the modern path.
|
||||
|
||||
---
|
||||
|
||||
## Admin dashboard URLs
|
||||
|
||||
| Path | Auth | What |
|
||||
|---|---|---|
|
||||
| `/admin/`, `/admin/index.html` | none (login page redirects in JS) | Single-page shell |
|
||||
| `/admin/login.html` | none | Sign-in form (password / TOTP / OIDC buttons) |
|
||||
| `/admin/login` | none (POST form) | Password+TOTP submit → sets `rd_admin_session` cookie |
|
||||
| `/admin/logout` | cookie | Clears cookie |
|
||||
| `/admin/me` | cookie | Sidebar's logged-in-as widget |
|
||||
| `/admin/assets/tailwindcss.js` | none | Vendored Tailwind 3.4.16 Play CDN. Long-cache. |
|
||||
| `/admin/assets/htmx.min.js` | none | Vendored htmx.org 1.9.10. Long-cache. |
|
||||
| `/admin/oidc/providers` | none | JSON list of enabled providers, used by login.html |
|
||||
| `/admin/login/oidc/:name` | none | Starts admin OIDC flow (302s to IdP) |
|
||||
| `/admin/pages/users` | cookie + admin | Users page fragment (incl. inline edit-profile / password-reset / TOTP-disable per row) |
|
||||
| `/admin/pages/devices` | cookie + admin | Devices (incl. delete, force-disconnect, force-sysinfo, toggle managed-auth — see [AGENT-API-AUTH.md](AGENT-API-AUTH.md)) |
|
||||
| `/admin/pages/groups` | cookie + admin | Device groups |
|
||||
| `/admin/pages/strategies` | cookie + admin | Strategy management |
|
||||
| `/admin/pages/address-books` | cookie + admin | Personal + shared books |
|
||||
| `/admin/pages/oidc` | cookie + admin | Read-only OIDC provider listing |
|
||||
| `/admin/pages/audit` | cookie + admin | Audit log browser |
|
||||
| `/admin/pages/recordings` | cookie + admin | Recording file listing |
|
||||
| `/admin/pages/deploy` | cookie + admin | `--config` blob + renamed-installer generator |
|
||||
| `/admin/pages/profile` | cookie | Self-service profile (display name, email, password, TOTP enroll/remove). Available to all signed-in users — no admin gate. |
|
||||
| `/admin/pages/profile/update-info` | cookie (POST) | Display name + email update |
|
||||
| `/admin/pages/profile/change-password` | cookie (POST) | Requires current password; refused for OIDC-linked accounts |
|
||||
| `/admin/pages/profile/totp/{start,confirm,remove}` | cookie (POST) | Two-step QR enroll, plus password-gated removal |
|
||||
| `/admin/connect/:peer_id` | cookie + admin | Web-client SPA shell — opens a browser-based remote-control session in a new tab |
|
||||
| `/admin/connect/assets/bundle.{js,css}` | cookie + admin | Compiled web-client SPA bundle |
|
||||
|
||||
The session cookie (`rd_admin_session`) is HttpOnly + SameSite=Strict.
|
||||
The middleware accepts the same cookie *or* `Authorization: Bearer …`,
|
||||
so the same auth covers `/api/*` for the desktop client and `/admin/*`
|
||||
for the dashboard with no separate session model.
|
||||
|
||||
---
|
||||
|
||||
## Web client
|
||||
|
||||
Admins can remote-control any peer directly from the browser — no desktop
|
||||
RustDesk install required. The Devices page row dropdown surfaces a
|
||||
**Connect** button that opens `/admin/connect/<peer_id>` in a new tab.
|
||||
The page is a TypeScript SPA bundled into hbbs (`include_bytes!` from
|
||||
`web_client/dist/`), so there's no separate process or service to run.
|
||||
|
||||
### Routes
|
||||
|
||||
| Route | Auth | Purpose |
|
||||
|---|---|---|
|
||||
| `/admin/connect/:peer_id` | cookie + admin | Server-rendered HTML wrapper that injects per-request config (rendezvous host, relay host, server pk, peer id, admin display name) into a `<script id="custom-config">` tag, then loads `bundle.js`. |
|
||||
| `/admin/connect/assets/bundle.js` | cookie + admin | The compiled SPA. ~535 KB, ~75 KB gzipped. |
|
||||
| `/admin/connect/assets/bundle.css` | cookie + admin | Minimal dark theme + reconnect overlay. |
|
||||
|
||||
### How it talks to peers
|
||||
|
||||
The browser opens **WebSockets directly** to the existing rendezvous
|
||||
(`hbbs:21118`, the `ws_port`) and relay (`hbbr:21119`, `port + 2`)
|
||||
sockets. The wire shape — protobuf `RendezvousMessage` + secretbox-
|
||||
encrypted `Message` stream — is identical to what the desktop client
|
||||
uses; the SPA is just a from-scratch reimplementation of the protocol
|
||||
talking through WS frames instead of TCP.
|
||||
|
||||
Browsers can't do UDP NAT-punching, so the SPA always sets `force_relay
|
||||
= true` in `PunchHoleRequest` and follows the `RelayResponse` branch
|
||||
unconditionally. **Direct peer-to-peer is never attempted.**
|
||||
|
||||
### Browser requirements
|
||||
|
||||
- **Secure context** — page must be served over HTTPS or `http://localhost`. WebCodecs `VideoDecoder` / `AudioDecoder` are gated to secure contexts. Plain `http://lan-ip:21114` will fail; either use TLS in front of hbbs or do `ssh -L 21114:localhost:21114 hbbs-host` and connect via `http://localhost:21114`.
|
||||
- **Browser version** — Chrome 94+, Edge 94+, Firefox 130+, Safari 16.4+. Older browsers lack WebCodecs and will throw "VideoDecoder unavailable" on session start.
|
||||
- **Clipboard permission** — Firefox refuses `navigator.clipboard.readText()` by default, so admin → host paste is silently no-op there. Host → admin paste works (it uses `writeText`, which only needs a recent click). Chrome/Safari prompt once.
|
||||
|
||||
### Network requirements
|
||||
|
||||
- The **relay host** advertised to clients (`--relay-servers=<HOSTS>` on hbbs) must resolve and be reachable from the end-user's browser on port 21119. The relay is what carries the actual session bytes — if a user's browser can't open `ws://<relay-host>:21119/`, the session dies after the rendezvous step. A common gotcha: setting `--relay-servers=hbbr-internal.local` works for desktop clients on the LAN but breaks for browsers off-LAN.
|
||||
- Audit rows are written under the admin's cookie via the existing `/api/audit/conn` endpoint; no new server endpoint.
|
||||
|
||||
### TLS deployment with nginx
|
||||
|
||||
The dashboard and the two browser-facing WebSocket ports (21118 = rendezvous, 21119 = relay) all need TLS in front of them when accessed from a browser, since the page is served over HTTPS and mixed-content `ws://` is blocked. nginx is the canonical setup; Caddy works similarly with much less ceremony.
|
||||
|
||||
#### Port plan
|
||||
|
||||
| Public port | TLS terminator | Backed by |
|
||||
|---|---|---|
|
||||
| 443/tcp | nginx | `127.0.0.1:21114` (hbbs HTTP API + dashboard) |
|
||||
| 21118/tcp | nginx | `127.0.0.1:21118` (hbbs WS rendezvous) |
|
||||
| 21119/tcp | nginx | `127.0.0.1:21119` (hbbr WS relay) |
|
||||
| 21115/tcp | — | hbbs (NAT test, plain TCP, desktop clients only) |
|
||||
| 21116/tcp+udp | — | hbbs (main rendezvous, desktop clients only) |
|
||||
| 21117/tcp | — | hbbr (relay for desktop clients, plain TCP) |
|
||||
|
||||
Desktop clients use plain TCP/UDP on 21115 / 21116 / 21117 and bring their own framing + secretbox encryption — no TLS needed. Only browsers go through nginx.
|
||||
|
||||
#### Pin hbbs / hbbr to localhost
|
||||
|
||||
By default both binaries bind every port to the wildcard (`[::]`), which collides with nginx wanting to take the same public port. Use the bind flags so nginx can claim the public port and forward to localhost:
|
||||
|
||||
```sh
|
||||
# hbbs — desktop-client ports stay on the wildcard, browser ports go local
|
||||
./hbbs --port 21116 \
|
||||
--http-port 21114 --http-listen 127.0.0.1 \
|
||||
--ws-listen 127.0.0.1 \
|
||||
--relay-servers rd.example.com \
|
||||
--public-base-url https://rd.example.com \
|
||||
# ... rest of your flags
|
||||
|
||||
# hbbr — TCP relay (21117) stays public, WS relay (21119) goes local
|
||||
./hbbr --port 21117 --ws-listen 127.0.0.1
|
||||
```
|
||||
|
||||
After restart, `ss -tlnp` should show:
|
||||
|
||||
```
|
||||
LISTEN 127.0.0.1:21114 <-- hbbs HTTP, fronted by nginx 443
|
||||
LISTEN 127.0.0.1:21118 <-- hbbs WS, fronted by nginx 21118
|
||||
LISTEN 127.0.0.1:21119 <-- hbbr WS, fronted by nginx 21119
|
||||
LISTEN 0.0.0.0:21115 <-- hbbs NAT test (public)
|
||||
LISTEN 0.0.0.0:21116 <-- hbbs rendezvous tcp (public)
|
||||
LISTEN 0.0.0.0:21117 <-- hbbr relay tcp (public)
|
||||
# plus 0.0.0.0:21116/udp
|
||||
```
|
||||
|
||||
#### nginx site config
|
||||
|
||||
Three `server { }` blocks. The dashboard one is normal HTTP/2 + reverse-proxy; the two WS blocks need the `Upgrade`/`Connection` headers and a long `proxy_read_timeout` so idle web sessions don't get severed mid-screen-share.
|
||||
|
||||
```nginx
|
||||
# /etc/nginx/sites-available/rustdesk
|
||||
|
||||
# Helper for WS upgrade — referenced by both WS blocks below.
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
# 1. Dashboard + admin API on 443
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name rd.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/rd.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/rd.example.com/privkey.pem;
|
||||
|
||||
# The dashboard streams audit logs / device events via plain HTTP today
|
||||
# but we still need WS-upgrade pass-through here for the /admin/connect/
|
||||
# SPA's own asset requests are HTTP, but if you ever proxy ws under
|
||||
# /ws/* in the future, this stays correct.
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:21114;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
}
|
||||
|
||||
# Force HTTPS — drop this block if you don't need port 80 at all.
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name rd.example.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# 2. WSS rendezvous on 21118
|
||||
server {
|
||||
listen 21118 ssl http2;
|
||||
listen [::]:21118 ssl http2;
|
||||
server_name rd.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/rd.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/rd.example.com/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:21118;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
# Web sessions can sit idle on the rendezvous WS; bump the read
|
||||
# timeout so nginx doesn't reset the connection before the relay
|
||||
# leg finishes negotiating.
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
}
|
||||
|
||||
# 3. WSS relay on 21119
|
||||
server {
|
||||
listen 21119 ssl http2;
|
||||
listen [::]:21119 ssl http2;
|
||||
server_name rd.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/rd.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/rd.example.com/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:21119;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
# Relay carries the live session for as long as the user is
|
||||
# remote-controlling. Pick a value larger than the longest
|
||||
# session you expect (24h here).
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The Let's Encrypt cert covers all three ports — same hostname, just different listen ports. With certbot's nginx plugin the cert was already obtained for the 443 block; the other two blocks just point at the same files.
|
||||
|
||||
Open the firewall for **80, 443, 21115, 21116, 21117, 21118, 21119** (TCP) and **21116** (UDP). Everything else can stay closed.
|
||||
|
||||
Verify after reload: in DevTools → Network, `wss://rd.example.com:21118/` and `wss://rd.example.com:21119/` should each show status `101 Switching Protocols`.
|
||||
|
||||
Common failure modes:
|
||||
|
||||
- **`ERR_SSL_PROTOCOL_ERROR`** on 21118 or 21119 — nginx isn't terminating TLS on that port. Check the listener block + cert paths.
|
||||
- **`ERR_CONNECTION_REFUSED`** — firewall is blocking the public port, OR nginx itself isn't listening on it (check `ss -tlnp`).
|
||||
- **`502 Bad Gateway`** at the dashboard — hbbs isn't running, or `--http-listen` doesn't match what nginx is `proxy_pass`ing to.
|
||||
- **WS upgrade hangs / 200 instead of 101** — `Upgrade` / `Connection` headers aren't being forwarded. The `$connection_upgrade` map at the top of the config is what makes this work; without it, `proxy_set_header Connection "upgrade"` would also work but breaks plain HTTP requests.
|
||||
|
||||
### Features
|
||||
|
||||
| Feature | Status |
|
||||
|---|---|
|
||||
| Video (VP8 / VP9 / H.264) | ✅ — preference is VP8 (lightest software encoder; H.264 path also implemented for hosts with hwcodec) |
|
||||
| Audio (Opus) | ✅ |
|
||||
| Mouse + keyboard input | ✅ — Legacy keyboard mode; Translate mode silently drops Unicode/ControlKey payloads on the host |
|
||||
| Text clipboard sync (both directions) | ✅ — handles both single-format `Clipboard` and `MultiClipboards` (peers ≥ 1.3.0) |
|
||||
| Multi-monitor switching | ✅ — `SwitchDisplay` + `CaptureDisplays` two-message dance for hosts ≥ 1.2.4; mouse coords offset by display's virtual-desktop origin |
|
||||
| Image quality presets (Low/Balanced/Best) | ✅ |
|
||||
| Custom FPS (15/30/60) | ✅ — host caps at 30 unless `allow_more_fps` is advertised |
|
||||
| Mute toggle | ✅ — also tells host to stop encoding audio (saves CPU + relay bandwidth) |
|
||||
| Ctrl+Alt+Del | ✅ — Windows hosts only (server-side `#[cfg(windows)]`) |
|
||||
| Auto-reconnect on transient drops | ✅ — up to 10 attempts, exponential backoff 1s → 30s, dim overlay during retry, user options re-applied on success |
|
||||
| File transfer | ❌ deferred — separate `FileAction` family, double the surface area |
|
||||
| AV1 / H.265 decode | ❌ deferred — VP8/VP9/H.264 covers the common cases |
|
||||
| IME / compose input | ❌ deferred — needs `compositionend` + `KeyEvent.seq` |
|
||||
| Touch gestures | ❌ deferred |
|
||||
| Cursor sprite rendering | ❌ deferred — host-side cursor visible in the video; we don't draw a separate one |
|
||||
|
||||
### Codec selection
|
||||
|
||||
The SPA advertises VP8/VP9/H.264 decode and prefers VP8. The host's
|
||||
codec picker (`libs/scrap/src/common/codec.rs`) honours the preference
|
||||
when the host has the matching encoder available, else falls back to
|
||||
its "auto" path (H.265 → H.264 → AV1/VP9/VP8).
|
||||
|
||||
VP8 is the default because it's the cheapest software encoder; on a
|
||||
host without hwcodec, VP9 software-encode caps screen sharing at single
|
||||
digits FPS, while VP8 keeps headroom for the screen-capture pipeline.
|
||||
On a host *with* hwcodec H.264 (nvenc/qsv on most Windows boxes), flip
|
||||
the preference in `web_client/src/transport/session.ts` to
|
||||
`PreferCodec.H264` — the SPA's H.264 path parses SPS to derive the
|
||||
correct `avc1.PPCCLL` codec string for WebCodecs.
|
||||
|
||||
### Performance gotchas
|
||||
|
||||
The HUD shows three live numbers: `recv` (frames/sec arriving from the
|
||||
relay), `dec` (frames/sec the browser decoded), `draw` (frames/sec
|
||||
painted to canvas). Use them to localise FPS issues:
|
||||
|
||||
- All three low → host is encoding slowly. Either CPU-bound (no
|
||||
hwcodec, VP9 chosen) or QoS-throttled by host based on `TestDelay`
|
||||
RTT measurements.
|
||||
- `recv` high, `dec` low → browser fell back to software decode. Check
|
||||
the codec string at the end of the HUD line; mismatched profile/level
|
||||
(e.g. `avc1.42E01E` for a high-profile stream) forces software.
|
||||
- `dec` high, `draw` low → main thread is overwhelmed (very rare with
|
||||
hardware decode).
|
||||
|
||||
### Building the bundle
|
||||
|
||||
The bundle is committed under `web_client/dist/` so a Cargo build
|
||||
doesn't need a Node toolchain. To regenerate after editing the SPA:
|
||||
|
||||
```sh
|
||||
cd web_client
|
||||
npm install # one-time
|
||||
npm run build # → dist/bundle.{js,css}
|
||||
git add dist/
|
||||
cd .. && cargo build --release -p hbbs
|
||||
```
|
||||
|
||||
To regenerate protobuf bindings after a `libs/hbb_common/protos/*.proto`
|
||||
change:
|
||||
|
||||
```sh
|
||||
cd web_client && npm run protogen
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
SQLite, file `db_v2.sqlite3` in hbbs's working directory. Tables created
|
||||
at startup with `CREATE TABLE IF NOT EXISTS`; column additions use
|
||||
`ALTER TABLE ADD COLUMN` guarded by a duplicate-column-name swallower
|
||||
(SQLite < 3.35 lacks `ADD COLUMN IF NOT EXISTS`).
|
||||
|
||||
Backup is a plain file copy while hbbs is stopped, or `sqlite3
|
||||
db_v2.sqlite3 .dump > backup.sql` while running. There is no
|
||||
multi-instance HA; run a single hbbs against a single SQLite file.
|
||||
|
||||
---
|
||||
|
||||
## Security checklist before exposing to the internet
|
||||
|
||||
- TLS in front of `--http-port` and the WebSocket ports (Caddy / nginx / Traefik). Required for OIDC redirect URIs and for the web client (browsers block mixed `ws://`). See "TLS deployment with nginx" for a worked config.
|
||||
- Pin browser-facing ports to localhost when a reverse proxy is in front: `--http-listen=127.0.0.1` on hbbs, `--ws-listen=127.0.0.1` on both hbbs and hbbr. Keeps the plain-HTTP / plain-ws surface unreachable from the public internet — the proxy is the only path in.
|
||||
- `--public-base-url` set to the *externally* reachable URL, including the scheme.
|
||||
- `--bootstrap-admin-password` rotated immediately after first login (Users page → reset password, or via the admin's own "My profile" page).
|
||||
- `--key` / `id_ed25519` not committed to source control. Treat the private key as a deploy secret.
|
||||
- Audit retention (`--audit-retention-days`) set to a value that matches your data-retention policy.
|
||||
- If running behind a reverse proxy: forward the original `Host:` header so OIDC redirect-URI validation matches, and forward `X-Forwarded-Proto: https` so the dashboard generates `wss://` URLs.
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
# RustDesk Server — Docker Compose Deployment
|
||||
|
||||
The repo ships a `docker-compose.yml` that **builds the server from source**
|
||||
(this fork's `pro-features` branch) and runs `hbbs` + `hbbr` as two
|
||||
containers. No prebuilt image is pulled — every `docker compose build`
|
||||
clones the configured Git URL and runs `cargo build --release` inside the
|
||||
build stage.
|
||||
|
||||
For the runtime flag reference (CLI options accepted by `hbbs` itself), see
|
||||
[CONFIGURATION.md](CONFIGURATION.md). This document only covers the Compose
|
||||
glue.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# edit .env — at minimum set RUSTDESK_DOMAIN, and change
|
||||
# RUSTDESK_BOOTSTRAP_ADMIN_PASSWORD before the first boot
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
The bootstrap admin (default `admin` / `changeme`) is seeded into the
|
||||
`users` table on the **first** boot only — once the row exists, those
|
||||
flags are ignored. If you boot with the default password and forget to
|
||||
change it, rotate it via the admin UI; if you forget the password
|
||||
entirely, delete `./data/db_v2.sqlite3` (loses all server-side state) or
|
||||
edit the `users` row with `sqlite3` directly.
|
||||
|
||||
First build pulls the Rust toolchain image and compiles the workspace; expect
|
||||
several minutes. Subsequent builds reuse the cargo cache layer unless the
|
||||
Git ref or build args change.
|
||||
|
||||
After it boots:
|
||||
|
||||
| Endpoint | Port | Purpose |
|
||||
|------------------------------------------|---------|------------------------------------------|
|
||||
| `tcp://<domain>:21115` | 21115 | NAT test |
|
||||
| `tcp+udp://<domain>:21116` | 21116 | ID / rendezvous (desktop clients) |
|
||||
| `tcp://<domain>:21117` | 21117 | Relay (hbbr) |
|
||||
| `ws://<domain>:21118` | 21118 | Browser-facing rendezvous WebSocket |
|
||||
| `ws://<domain>:21119` | 21119 | Browser-facing relay WebSocket |
|
||||
| `http://<domain>:21114/admin/` | 21114 | Admin dashboard (pro-features) |
|
||||
| `http://<domain>:21114/api/*` | 21114 | Management API (pro-features) |
|
||||
|
||||
Persistent state — including the auto-generated `id_ed25519` keypair and the
|
||||
SQLite database — lives in `./data/` (bind-mounted to `/root` in both
|
||||
containers).
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
| File | Role |
|
||||
|-----------------------------|---------------------------------------------------------------------------------------|
|
||||
| `docker-compose.yml` | Two services (`hbbs`, `hbbr`) sharing one image built from `docker/Dockerfile.source`.|
|
||||
| `docker/Dockerfile.source` | Multi-stage build: clones the repo, runs `cargo build --release`, copies binaries into a `debian:bookworm-slim` runtime. |
|
||||
| `.env.example` | Documented template; copy to `.env`. |
|
||||
| `data/` | Created on first run. Contains keypair + SQLite DB. **Back this up.** |
|
||||
|
||||
The legacy single-stage `docker/Dockerfile` (busybox + s6-overlay, expects
|
||||
prebuilt binaries) and `docker-classic/Dockerfile` are unrelated to this
|
||||
flow and unused by `docker-compose.yml`.
|
||||
|
||||
---
|
||||
|
||||
## Environment variables
|
||||
|
||||
These are read by `docker-compose.yml` from `.env`. Compose ships them
|
||||
through to the container as command-line flags or `environment:` entries,
|
||||
not as raw process env (with the exception of `RUST_LOG` and
|
||||
`ALWAYS_USE_RELAY`, which `hbbs` reads from env directly).
|
||||
|
||||
### Runtime
|
||||
|
||||
| Variable | Default | Effect |
|
||||
|--------------------------------|-----------------|-------------------------------------------------------------------------------------------------------|
|
||||
| `RUSTDESK_DOMAIN` | **required** | Public hostname clients connect to. Passed as `hbbs -r ${RUSTDESK_DOMAIN}:21117`. |
|
||||
| `RUSTDESK_BOOTSTRAP_ADMIN_USERNAME` | `admin` | Seeded as the initial admin on **first boot only** (when the `users` table is empty). Ignored on subsequent restarts. Empty disables the bootstrap. |
|
||||
| `RUSTDESK_BOOTSTRAP_ADMIN_PASSWORD` | `changeme` | Same — bcrypt-hashed at insert. Change this in `.env` before the first `up`, or rotate via the admin UI immediately after. |
|
||||
| `RUSTDESK_KEY` | `-` | Pre-shared key. `-` = auto-generate on first boot (written to `./data/id_ed25519{,.pub}`); `_` = encrypted-only with auto-key; or paste a base64 public key to pin it. Applied to **both** `hbbs` and `hbbr` via `-k`. |
|
||||
| `RUSTDESK_HTTP_PORT` | `21114` | Pro-features admin API + dashboard port. Set to `0` to disable HTTP entirely. The host port published is the same value. |
|
||||
| `RUSTDESK_ALWAYS_USE_RELAY` | `N` | Force every session through the relay even on LAN. Read from env by hbbs (any non-empty/non-`N` value enables). |
|
||||
| `RUST_LOG` | `info` | Log filter. e.g. `debug`, `hbbs=debug,sqlx=warn`. |
|
||||
|
||||
### Build source
|
||||
|
||||
| Variable | Default | Effect |
|
||||
|------------------------|---------------------------------------------------------------|-----------------------------------------------------------------------------------------|
|
||||
| `RUSTDESK_GIT_URL` | `https://gitea.cstudio.ch/mike/rustdesk-server.git` | Repo cloned inside the builder stage. |
|
||||
| `RUSTDESK_GIT_BRANCH` | `pro-features` | Branch / tag / commit to check out (`--branch` so it must be a ref name, not a SHA). |
|
||||
| `DATABASE_URL` | unset (uses the cloned repo's `.env`) | Overrides the `DATABASE_URL` sqlx reads at compile time. Rarely needed — see below. |
|
||||
|
||||
Build-arg changes only take effect when the image is rebuilt:
|
||||
`docker compose build --no-cache hbbs` (or `up -d --build`).
|
||||
|
||||
---
|
||||
|
||||
## Why `DATABASE_URL` is a build-time concern
|
||||
|
||||
`hbbs` uses `sqlx::query!` macros, which verify SQL **at compile time** by
|
||||
running the queries against a real SQLite database. The repo includes a
|
||||
checked-in `db_v2.sqlite3` with the schema pre-applied, and a tracked `.env`
|
||||
file pointing at it (`DATABASE_URL=sqlite://./db_v2.sqlite3`).
|
||||
|
||||
Cargo automatically reads `.env` from the project root, so `cargo build`
|
||||
inside the builder stage Just Works without any explicit configuration.
|
||||
|
||||
You only need to set `DATABASE_URL` in the Compose `.env` if you fork the
|
||||
schema or want to point the compile-time check at a different SQLite file.
|
||||
At **runtime** the binary opens its own DB under `/root/` (your `./data/`
|
||||
bind mount) — that path is not configurable via this variable.
|
||||
|
||||
---
|
||||
|
||||
## Adding extra `hbbs` flags
|
||||
|
||||
`docker-compose.yml` only wires the most common flags. To pass others
|
||||
(SMTP, OIDC, recording dir, audit retention, …), edit the `command:`
|
||||
block of the `hbbs` service. Example — enable SMTP and set the public
|
||||
base URL needed for OIDC callbacks:
|
||||
|
||||
```yaml
|
||||
command: >
|
||||
hbbs
|
||||
-r ${RUSTDESK_DOMAIN:?...}:21117
|
||||
-k ${RUSTDESK_KEY:--}
|
||||
--http-port ${RUSTDESK_HTTP_PORT:-21114}
|
||||
--admin-ui-dir /opt/rustdesk/admin_ui
|
||||
--bootstrap-admin-username=${RUSTDESK_BOOTSTRAP_ADMIN_USERNAME:-}
|
||||
--bootstrap-admin-password=${RUSTDESK_BOOTSTRAP_ADMIN_PASSWORD:-}
|
||||
--public-base-url https://${RUSTDESK_DOMAIN}:${RUSTDESK_HTTP_PORT:-21114}
|
||||
--smtp-host ${SMTP_HOST}
|
||||
--smtp-user ${SMTP_USER}
|
||||
--smtp-pass ${SMTP_PASS}
|
||||
--smtp-from ${SMTP_FROM}
|
||||
```
|
||||
|
||||
Then add the matching variables to `.env`. The full flag list lives in
|
||||
[CONFIGURATION.md](CONFIGURATION.md).
|
||||
|
||||
If you mount an `oidc.toml`, drop it into `./data/` and pass
|
||||
`--oidc-config /root/oidc.toml`.
|
||||
|
||||
---
|
||||
|
||||
## Operational notes
|
||||
|
||||
**Upgrading.** Pull the latest commit on `pro-features` and rebuild:
|
||||
|
||||
```bash
|
||||
docker compose build --pull --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The `--pull` refreshes the Rust toolchain base image; `--no-cache` forces a
|
||||
fresh `git clone` (otherwise Docker will reuse the cached clone layer).
|
||||
Alternatively, bump `RUSTDESK_GIT_BRANCH` to a tag and rebuild — the changed
|
||||
build arg invalidates the clone layer automatically.
|
||||
|
||||
**Logs.** `docker compose logs -f hbbs` (or `hbbr`). Both containers run
|
||||
the binary in the foreground.
|
||||
|
||||
**Persistence.** Everything that matters is in `./data/`:
|
||||
`id_ed25519{,.pub}` (the server keypair — losing this invalidates every
|
||||
existing client) and `db_v2.sqlite3` (users, address books, audit, etc.).
|
||||
Back up the whole directory.
|
||||
|
||||
**TLS.** The server itself speaks plain HTTP on 21114 and plain WebSocket
|
||||
on 21118 / 21119. Front it with nginx or Caddy for TLS — see the "TLS
|
||||
deployment" section in `CONFIGURATION.md`. When you do, set
|
||||
`--http-listen=127.0.0.1` and `--ws-listen=127.0.0.1` in the `command:`
|
||||
block so the reverse proxy can claim the public ports.
|
||||
|
||||
**Building behind a proxy.** Pass `HTTP_PROXY` / `HTTPS_PROXY` build args
|
||||
through Compose:
|
||||
|
||||
```yaml
|
||||
build:
|
||||
<<: *rustdesk-build
|
||||
args:
|
||||
HTTP_PROXY: http://proxy.internal:3128
|
||||
HTTPS_PROXY: http://proxy.internal:3128
|
||||
```
|
||||
|
||||
**Resource hint.** Cold compile takes ~3–5 GB of RAM for the linker step
|
||||
(LTO + `codegen-units = 1`). On a small VPS, build the image on a beefier
|
||||
machine, push to a registry, and pull from the VPS instead — set the
|
||||
`image:` field to a registry tag and drop the `build:` block.
|
||||
@@ -0,0 +1,309 @@
|
||||
# Strategies — server-pushed client config
|
||||
|
||||
Strategies let an admin push client configuration to peers centrally,
|
||||
without touching each device. They are managed from the dashboard's
|
||||
**Strategies** page (`/admin/#strategies`).
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
1. **Storage.** A strategy is a row with a name and a `config_options`
|
||||
JSON object (plus an `extra` object, reserved — currently always
|
||||
`{}`). The Strategies page validates only that `config_options` is a
|
||||
valid JSON **object**; it does **not** validate the keys inside it.
|
||||
2. **Delivery.** On each peer heartbeat the server resolves the
|
||||
applicable strategy and embeds it in the reply as
|
||||
`strategy.config_options`. Changes propagate within ~15 s of the
|
||||
strategy row's `modified_at` changing.
|
||||
3. **Apply.** The client merges every key/value straight into its own
|
||||
options map (the same store behind "advanced settings" and the
|
||||
`--config` deploy blob). A strategy key is therefore *any* key the
|
||||
RustDesk client reads via `Config::get_option`.
|
||||
|
||||
### Resolution order (per peer)
|
||||
|
||||
The first match wins:
|
||||
|
||||
1. Direct peer-scoped assignment (`strategy_assignments.peer_id`)
|
||||
2. Device-group assignment, via the peer's owner
|
||||
3. User assignment
|
||||
|
||||
If nothing matches, an empty config is pushed.
|
||||
|
||||
### Value conventions
|
||||
|
||||
- **All values are strings**, even numbers (`"30"`, not `30`).
|
||||
- **Boolean keys** use `"Y"` (on) / `"N"` (off). Most default to off
|
||||
when unset.
|
||||
- **An empty string `""`** removes the override and lets the client
|
||||
fall back to its built-in default. (If a built-in default settings
|
||||
blob is present, an empty value instead keeps the key present.)
|
||||
- Keys the client version doesn't recognize are stored, pushed, and
|
||||
silently ignored — there is **no error feedback for typos**. Verify
|
||||
against the `keys` module in `libs/hbb_common/src/config.rs` of the
|
||||
client version you deploy.
|
||||
|
||||
### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"enable-keyboard": "N",
|
||||
"enable-file-transfer": "N",
|
||||
"access-mode": "view",
|
||||
"image_quality": "best",
|
||||
"whitelist": "192.168.1.0/24,10.0.0.0/8",
|
||||
"verification-method": "use-permanent-password"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known config keys
|
||||
|
||||
The keys below are the complete set defined in the client's `keys`
|
||||
module (`libs/hbb_common/src/config.rs`). Not all are equally useful in
|
||||
a server-managed strategy — UI-state keys (peer tabs, toolbar, floating
|
||||
window) are listed for completeness but are normally per-user.
|
||||
|
||||
### Access control & permissions
|
||||
|
||||
| Key | Values | Effect |
|
||||
|---|---|---|
|
||||
| `access-mode` | `full` / `view` / `custom` | Overall permission preset for incoming sessions |
|
||||
| `enable-keyboard` | `Y`/`N` | Allow remote keyboard input |
|
||||
| `enable-clipboard` | `Y`/`N` | Allow clipboard sync |
|
||||
| `enable-file-transfer` | `Y`/`N` | Allow file transfer |
|
||||
| `enable-camera` | `Y`/`N` | Allow camera access |
|
||||
| `enable-terminal` | `Y`/`N` | Allow terminal access |
|
||||
| `terminal-persistent` | `Y`/`N` | Keep terminal sessions persistent |
|
||||
| `enable-audio` | `Y`/`N` | Allow audio |
|
||||
| `enable-tunnel` | `Y`/`N` | Allow TCP tunnelling |
|
||||
| `enable-remote-restart` | `Y`/`N` | Allow remote restart of the host |
|
||||
| `enable-record-session` | `Y`/`N` | Allow session recording |
|
||||
| `enable-block-input` | `Y`/`N` | Allow blocking local input |
|
||||
| `enable-privacy-mode` | `Y`/`N` | Allow privacy mode |
|
||||
| `enable-perm-change-in-accept-window` | `Y`/`N` | Allow changing permissions in the accept dialog |
|
||||
| `allow-remote-config-modification` | `Y`/`N` | Allow remote side to change this host's config |
|
||||
| `allow-numeric-one-time-password` | `Y`/`N` | Permit numeric one-time passwords |
|
||||
| `approve-mode` | `password` / `click` / `password-click` | How incoming connections are approved |
|
||||
| `verification-method` | `use-temporary-password` / `use-permanent-password` / `use-both-passwords` | Password verification mode |
|
||||
| `temporary-password-length` | `6` / `8` / `10` | Length of generated one-time passwords |
|
||||
| `allow-only-conn-window-open` | `Y`/`N` | Only accept connections while the connection window is open |
|
||||
| `allow-auto-disconnect` | `Y`/`N` | Auto-disconnect idle sessions |
|
||||
| `auto-disconnect-timeout` | minutes | Idle timeout for auto-disconnect |
|
||||
| `approve-mode` | see above | (alias note: stored as `approve-mode`) |
|
||||
| `enable-trusted-devices` | `Y`/`N` | Enable the trusted-devices feature |
|
||||
| `allow-logon-screen-password` | `Y`/`N` | Allow password entry on the logon screen |
|
||||
| `disable-change-permanent-password` | `Y`/`N` | Prevent the user changing the permanent password |
|
||||
| `disable-change-id` | `Y`/`N` | Prevent the user changing the device ID |
|
||||
| `disable-unlock-pin` | `Y`/`N` | Disable the unlock PIN feature |
|
||||
| `enable-remote-exec` | `Y`/`N` | Allow admins to dispatch PowerShell scripts to this peer via the dashboard's **Run command** action. Server-side only — the value is checked at dispatch time, never pushed to the client. See [AGENT-API-AUTH.md](AGENT-API-AUTH.md) for the auth model. Off by default; only effective on `peer.managed=1` peers. |
|
||||
|
||||
### Network & connectivity
|
||||
|
||||
| Key | Values | Effect |
|
||||
|---|---|---|
|
||||
| `custom-rendezvous-server` | host | ID/rendezvous server address |
|
||||
| `relay-server` | host | Relay server address |
|
||||
| `api-server` | URL | API server address |
|
||||
| `key` | string | Server public key |
|
||||
| `ice-servers` | string | ICE servers for WebRTC |
|
||||
| `direct-server` | `Y`/`N` | Enable direct IP access listener |
|
||||
| `direct-access-port` | port | Port for direct IP access |
|
||||
| `enable-udp-punch` | `Y`/`N` | Enable UDP hole punching |
|
||||
| `enable-ipv6-punch` | `Y`/`N` | Enable IPv6 hole punching |
|
||||
| `disable-udp` | `Y`/`N` | Disable UDP (force TCP) |
|
||||
| `allow-websocket` | `Y`/`N` | Allow WebSocket transport |
|
||||
| `allow-insecure-tls-fallback` | `Y`/`N` | Allow falling back to insecure TLS |
|
||||
| `enable-lan-discovery` | `Y`/`N` | Allow discovery on the local network |
|
||||
| `whitelist` | IPs/CIDRs | Comma/space/`;`-separated allow-list; empty = allow all |
|
||||
| `allow-https-21114` | `Y`/`N` | Use HTTPS on port 21114 |
|
||||
| `use-raw-tcp-for-api` | `Y`/`N` | Use raw TCP for the API connection |
|
||||
| `allow-hostname-as-id` | `Y`/`N` | Allow using the hostname as device ID |
|
||||
| `proxy-url` | URL | Proxy server URL |
|
||||
| `proxy-username` | string | Proxy username |
|
||||
| `proxy-password` | string | Proxy password |
|
||||
|
||||
### Display, codec & quality
|
||||
|
||||
| Key | Values | Effect |
|
||||
|---|---|---|
|
||||
| `view_style` | `original` / `adaptive` | Remote display scaling |
|
||||
| `scroll_style` | `scrollauto` / `scrollbar` | Scroll behaviour |
|
||||
| `image_quality` | `best` / `balanced` / `low` / `custom` | Image quality preset |
|
||||
| `custom_image_quality` | `10`–`4095` | Custom quality value |
|
||||
| `custom-fps` | `5`–`120` | Custom frame rate |
|
||||
| `codec-preference` | `auto` / `vp8` / `vp9` / `av1` / `h264` / `h265` | Preferred video codec |
|
||||
| `enable-hwcodec` | `Y`/`N` | Enable hardware codec |
|
||||
| `enable-abr` | `Y`/`N` | Enable adaptive bitrate |
|
||||
| `i444` | `Y`/`N` | Use YUV 4:4:4 (true colour) |
|
||||
| `av1-test` | `Y`/`N` | Enable AV1 test path |
|
||||
| `zoom-cursor` | `Y`/`N` | Zoom the remote cursor |
|
||||
| `show_remote_cursor` | `Y`/`N` | Show the remote cursor |
|
||||
| `follow_remote_cursor` | `Y`/`N` | Follow the remote cursor |
|
||||
| `follow_remote_window` | `Y`/`N` | Follow the remote active window |
|
||||
| `show_quality_monitor` | `Y`/`N` | Show the quality monitor overlay |
|
||||
| `show_monitors_toolbar` | `Y`/`N` | Show the monitors toolbar |
|
||||
| `collapse_toolbar` | `Y`/`N` | Start with the toolbar collapsed |
|
||||
| `view_only` | `Y`/`N` | Open sessions view-only by default |
|
||||
| `touch-mode` | `Y`/`N` | Enable touch mode |
|
||||
| `displays_as_individual_windows` | `Y`/`N` | Show each remote display in its own window |
|
||||
| `use_all_my_displays_for_the_remote_session` | `Y`/`N` | Use all local displays for the session |
|
||||
| `use-texture-render` | `Y`/`N` | Use texture rendering |
|
||||
| `allow-d3d-render` | `Y`/`N` | Allow Direct3D rendering |
|
||||
| `enable-directx-capture` | `Y`/`N` | Use DirectX for screen capture |
|
||||
| `allow-always-software-render` | `Y`/`N` | Force software rendering |
|
||||
| `allow-linux-headless` | `Y`/`N` | Allow headless mode on Linux |
|
||||
|
||||
### Input
|
||||
|
||||
| Key | Values | Effect |
|
||||
|---|---|---|
|
||||
| `reverse_mouse_wheel` | `Y`/`N` | Reverse mouse-wheel direction |
|
||||
| `swap-left-right-mouse` | `Y`/`N` | Swap mouse buttons |
|
||||
| `trackpad-speed` | `10`–`1000` | Trackpad speed (percent) |
|
||||
| `edge-scroll-edge-thickness` | `20`–`150` | Edge-scroll trigger thickness |
|
||||
|
||||
### Recording, files & clipboard
|
||||
|
||||
| Key | Values | Effect |
|
||||
|---|---|---|
|
||||
| `allow-auto-record-incoming` | `Y`/`N` | Auto-record incoming sessions |
|
||||
| `allow-auto-record-outgoing` | `Y`/`N` | Auto-record outgoing sessions |
|
||||
| `video-save-directory` | path | Directory for recordings |
|
||||
| `enable-file-copy-paste` | `Y`/`N` | Allow file copy/paste |
|
||||
| `file-transfer-max-files` | number | Max files per transfer request |
|
||||
| `one-way-file-transfer` | `Y`/`N` | Restrict file transfer to one direction |
|
||||
| `sync-init-clipboard` | `Y`/`N` | Sync clipboard at session start |
|
||||
| `disable_clipboard` | `Y`/`N` | Disable clipboard |
|
||||
| `disable_audio` | `Y`/`N` | Disable audio |
|
||||
| `one-way-clipboard-redirection` | `Y`/`N` | One-way clipboard redirection |
|
||||
| `lock_after_session_end` | `Y`/`N` | Lock the host after a session ends |
|
||||
| `privacy_mode` | `Y`/`N` | Enable privacy mode for the session |
|
||||
|
||||
### Printer
|
||||
|
||||
| Key | Values | Effect |
|
||||
|---|---|---|
|
||||
| `enable-remote-printer` | `Y`/`N` | Enable remote printing |
|
||||
| `printer-incomming-job-action` | string | Action for incoming print jobs (note: key is spelled `incomming`) |
|
||||
| `allow-printer-auto-print` | `Y`/`N` | Allow automatic printing |
|
||||
| `printer-selected-name` | string | Selected printer name |
|
||||
|
||||
### Update behaviour
|
||||
|
||||
| Key | Values | Effect |
|
||||
|---|---|---|
|
||||
| `enable-check-update` | `Y`/`N` | Check for updates |
|
||||
| `allow-auto-update` | `Y`/`N` | Allow automatic updates |
|
||||
|
||||
### Power & session
|
||||
|
||||
| Key | Values | Effect |
|
||||
|---|---|---|
|
||||
| `keep-screen-on` | string | Keep the screen on |
|
||||
| `keep-awake-during-incoming-sessions` | `Y`/`N` | Keep host awake during incoming sessions |
|
||||
| `keep-awake-during-outgoing-sessions` | `Y`/`N` | Keep client awake during outgoing sessions |
|
||||
| `allow-ask-for-note` | `Y`/`N` | Prompt for a session note |
|
||||
| `pre-elevate-service` | `Y`/`N` | Pre-elevate the service |
|
||||
| `register-device` | `Y`/`N` | Register the device with the API server |
|
||||
|
||||
### UI visibility / branding lockdown
|
||||
|
||||
| Key | Values | Effect |
|
||||
|---|---|---|
|
||||
| `hide-security-settings` | `Y`/`N` | Hide the Security settings tab |
|
||||
| `hide-network-settings` | `Y`/`N` | Hide the Network settings tab |
|
||||
| `hide-server-settings` | `Y`/`N` | Hide the Server settings tab |
|
||||
| `hide-proxy-settings` | `Y`/`N` | Hide the Proxy settings tab |
|
||||
| `hide-remote-printer-settings` | `Y`/`N` | Hide remote-printer settings |
|
||||
| `hide-websocket-settings` | `Y`/`N` | Hide WebSocket settings |
|
||||
| `hide-stop-service` | `Y`/`N` | Hide the "stop service" control |
|
||||
| `hide-tray` | `Y`/`N` | Hide the system-tray icon |
|
||||
| `hide-powered-by-me` | `Y`/`N` | Hide "powered by" branding |
|
||||
| `hide-username-on-card` | `Y`/`N` | Hide username on peer cards |
|
||||
| `hide-help-cards` | `Y`/`N` | Hide help cards |
|
||||
| `hideAbTagsPanel` | `Y`/`N` | Hide the address-book tags panel |
|
||||
| `main-window-always-on-top` | `Y`/`N` | Keep the main window on top |
|
||||
| `theme` | `light` / `dark` / `system` | UI theme |
|
||||
| `lang` | language code | UI language |
|
||||
| `remote-menubar-drag-left` | `Y`/`N` | Allow dragging the left remote menubar |
|
||||
| `remote-menubar-drag-right` | `Y`/`N` | Allow dragging the right remote menubar |
|
||||
| `enable-confirm-closing-tabs` | `Y`/`N` | Confirm before closing tabs |
|
||||
| `enable-open-new-connections-in-tabs` | `Y`/`N` | Open new connections in tabs |
|
||||
| `disable-group-panel` | `Y`/`N` | Hide the group panel |
|
||||
| `disable-discovery-panel` | `Y`/`N` | Hide the discovery panel |
|
||||
|
||||
### Address book
|
||||
|
||||
| Key | Values | Effect |
|
||||
|---|---|---|
|
||||
| `sync-ab-with-recent-sessions` | `Y`/`N` | Sync address book with recent sessions |
|
||||
| `sync-ab-tags` | `Y`/`N` | Sync address-book tags |
|
||||
| `filter-ab-by-intersection` | `Y`/`N` | Filter address book by tag intersection |
|
||||
|
||||
### Deep links
|
||||
|
||||
| Key | Values | Effect |
|
||||
|---|---|---|
|
||||
| `allow-deep-link-password` | `Y`/`N` | Allow passwords in deep links |
|
||||
| `allow-deep-link-server-settings` | `Y`/`N` | Allow server settings in deep links |
|
||||
|
||||
### Preset values (deploy/provisioning)
|
||||
|
||||
These pre-fill fields during enrollment/deploy rather than gating behaviour.
|
||||
|
||||
| Key | Effect |
|
||||
|---|---|
|
||||
| `display-name` | Device display name |
|
||||
| `avatar` | Device avatar |
|
||||
| `default-connect-password` | Default connection password |
|
||||
| `remove-preset-password-warning` | Suppress the preset-password warning |
|
||||
| `preset-user-name` | Pre-fill username |
|
||||
| `preset-strategy-name` | Pre-fill strategy name |
|
||||
| `preset-device-group-name` | Pre-fill device group |
|
||||
| `preset-device-username` | Pre-fill device username |
|
||||
| `preset-device-name` | Pre-fill device name |
|
||||
| `preset-note` | Pre-fill session note |
|
||||
| `preset-address-book-name` | Pre-fill address-book name |
|
||||
| `preset-address-book-tag` | Pre-fill address-book tag |
|
||||
| `preset-address-book-alias` | Pre-fill address-book alias |
|
||||
| `preset-address-book-password` | Pre-fill address-book password |
|
||||
| `preset-address-book-note` | Pre-fill address-book note |
|
||||
|
||||
### Android / mobile
|
||||
|
||||
| Key | Values | Effect |
|
||||
|---|---|---|
|
||||
| `show-virtual-mouse` | `Y`/`N` | Show the virtual mouse |
|
||||
| `show-virtual-joystick` | `Y`/`N` | Show the virtual joystick (also set `show-virtual-mouse`) |
|
||||
| `disable-floating-window` | `Y`/`N` | Disable the floating window |
|
||||
| `floating-window-size` | string | Floating-window size |
|
||||
| `floating-window-untouchable` | `Y`/`N` | Make the floating window non-interactive |
|
||||
| `floating-window-transparency` | number | Floating-window transparency |
|
||||
| `floating-window-svg` | string | Floating-window SVG icon |
|
||||
| `enable-android-software-encoding-half-scale` | `Y`/`N` | Half-scale Android software encoding |
|
||||
|
||||
### UI state (normally per-user — listed for completeness)
|
||||
|
||||
`remoteMenubarState`, `peer-sorting`, `peer-tab-index`, `peer-tab-order`,
|
||||
`peer-tab-visible`, `peer-card-ui-type`, `current-ab-name`,
|
||||
`enable-flutter-http-on-rust`, `allow-remote-cm-modification`.
|
||||
|
||||
---
|
||||
|
||||
## Notes & caveats
|
||||
|
||||
- The list above reflects the client `keys` module at documentation
|
||||
time. Newer clients may add keys; older clients will ignore keys they
|
||||
don't know. There is no negotiation — match the doc to the client
|
||||
version you actually deploy.
|
||||
- Some keys also exist as hbbs/hbbr command-line flags
|
||||
(`custom-rendezvous-server`, `relay-server`, `key`, …). Pushing them
|
||||
via a strategy overrides the client's local value; it does not change
|
||||
the server.
|
||||
- The `extra` object on a strategy row is reserved and currently always
|
||||
`{}` — there is no UI to populate it.
|
||||
- For the overall configuration picture see
|
||||
[CONFIGURATION.md](CONFIGURATION.md#strategies-server-pushed-config).
|
||||
@@ -0,0 +1,126 @@
|
||||
//! End-to-end smoke test for the HttpProxyRequest fallback.
|
||||
//!
|
||||
//! Mirrors what a logged-in client does when `OPTION_USE_RAW_TCP_FOR_API=Y`:
|
||||
//! 1. Open TCP to hbbs's rendezvous port.
|
||||
//! 2. Read the server-initiated `KeyExchange`.
|
||||
//! 3. Verify the signature with the server's published Ed25519 pubkey.
|
||||
//! 4. Reply with `KeyExchange { keys: [client_box_pk, sealed_sym_key] }`.
|
||||
//! 5. Send `HttpProxyRequest { method, path, headers, body }`.
|
||||
//! 6. Receive `HttpProxyResponse` and print status + body.
|
||||
//!
|
||||
//! Run from the same dir as hbbs's `id_ed25519.pub`:
|
||||
//! cargo run --example http_proxy_test -- 127.0.0.1:21116
|
||||
|
||||
use hbb_common::bytes::Bytes;
|
||||
use hbb_common::protobuf::Message as _;
|
||||
use hbb_common::rendezvous_proto::{
|
||||
rendezvous_message, HttpProxyRequest, KeyExchange, RendezvousMessage,
|
||||
};
|
||||
use hbb_common::tcp::FramedStream;
|
||||
use hbb_common::tokio;
|
||||
use sodiumoxide::crypto::{box_, secretbox, sign};
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() {
|
||||
let addr_arg = std::env::args().nth(1).unwrap_or_else(|| "127.0.0.1:21116".into());
|
||||
let pubkey_path = std::env::args()
|
||||
.nth(2)
|
||||
.unwrap_or_else(|| "id_ed25519.pub".into());
|
||||
|
||||
// 1. Connect.
|
||||
let addr: std::net::SocketAddr = addr_arg.parse().expect("bad addr");
|
||||
let raw = tokio::net::TcpStream::connect(addr).await.expect("connect");
|
||||
let mut fs = FramedStream::from(raw, addr);
|
||||
|
||||
// 2. Read the server-pushed KeyExchange.
|
||||
let bytes = fs
|
||||
.next()
|
||||
.await
|
||||
.expect("server closed")
|
||||
.expect("read err");
|
||||
let msg = RendezvousMessage::parse_from_bytes(&bytes).expect("parse first frame");
|
||||
let kx_in = match msg.union {
|
||||
Some(rendezvous_message::Union::KeyExchange(ex)) => ex,
|
||||
other => panic!(
|
||||
"expected KeyExchange as first frame, got {:?}",
|
||||
other.map(|_| "<some other variant>")
|
||||
),
|
||||
};
|
||||
assert_eq!(
|
||||
kx_in.keys.len(),
|
||||
1,
|
||||
"server KX must carry exactly one signed pubkey"
|
||||
);
|
||||
|
||||
// 3. Verify the signature.
|
||||
let pk_b64 = std::fs::read_to_string(&pubkey_path)
|
||||
.expect("read pubkey")
|
||||
.trim()
|
||||
.to_string();
|
||||
let pk_bytes = base64::decode(&pk_b64).expect("base64 pubkey");
|
||||
assert_eq!(pk_bytes.len(), 32, "Ed25519 pubkey must be 32 bytes");
|
||||
let rs_pk = sign::PublicKey::from_slice(&pk_bytes).expect("pubkey");
|
||||
let their_box_pk_bytes =
|
||||
sign::verify(&kx_in.keys[0], &rs_pk).expect("KX signature mismatch");
|
||||
assert_eq!(their_box_pk_bytes.len(), 32, "box pk must be 32 bytes");
|
||||
let their_box_pk =
|
||||
box_::PublicKey::from_slice(&their_box_pk_bytes).expect("box pk shape");
|
||||
|
||||
// 4. Generate ephemeral keypair + sym key, seal the sym key with NaCl box,
|
||||
// send back KX.
|
||||
let (our_box_pk, our_box_sk) = box_::gen_keypair();
|
||||
let sym_key = secretbox::gen_key();
|
||||
let nonce = box_::Nonce([0u8; 24]);
|
||||
let sealed = box_::seal(&sym_key.0, &nonce, &their_box_pk, &our_box_sk);
|
||||
|
||||
let mut out = RendezvousMessage::new();
|
||||
out.set_key_exchange(KeyExchange {
|
||||
keys: vec![Bytes::from(our_box_pk.0.to_vec()), Bytes::from(sealed)],
|
||||
..Default::default()
|
||||
});
|
||||
fs.send(&out).await.expect("send KX");
|
||||
fs.set_key(sym_key);
|
||||
println!("[ok] secure_tcp handshake complete");
|
||||
|
||||
// 5. HttpProxyRequest — exercise an unauthenticated route first.
|
||||
let mut req_msg = RendezvousMessage::new();
|
||||
req_msg.set_http_proxy_request(HttpProxyRequest {
|
||||
method: "GET".into(),
|
||||
path: "/api/login-options".into(),
|
||||
headers: vec![],
|
||||
body: Bytes::new(),
|
||||
..Default::default()
|
||||
});
|
||||
fs.send(&req_msg).await.expect("send HttpProxyRequest");
|
||||
println!("[ok] sent HttpProxyRequest GET /api/login-options");
|
||||
|
||||
// 6. Receive HttpProxyResponse.
|
||||
let bytes = fs
|
||||
.next()
|
||||
.await
|
||||
.expect("server closed mid-response")
|
||||
.expect("read err");
|
||||
let resp_msg =
|
||||
RendezvousMessage::parse_from_bytes(&bytes).expect("parse response");
|
||||
match resp_msg.union {
|
||||
Some(rendezvous_message::Union::HttpProxyResponse(r)) => {
|
||||
println!("[ok] response status = {}", r.status);
|
||||
println!(
|
||||
"[ok] response body = {}",
|
||||
std::str::from_utf8(&r.body).unwrap_or("<non-utf8>")
|
||||
);
|
||||
for h in &r.headers {
|
||||
println!(" {}: {}", h.name, h.value);
|
||||
}
|
||||
assert_eq!(r.status, 200, "expected HTTP 200 from /api/login-options");
|
||||
assert!(
|
||||
std::str::from_utf8(&r.body)
|
||||
.map(|s| s.contains("account"))
|
||||
.unwrap_or(false),
|
||||
"body should mention `account`"
|
||||
);
|
||||
println!("[pass] full HTTP-over-rendezvous round trip verified");
|
||||
}
|
||||
other => panic!("expected HttpProxyResponse, got {:?}", other.is_some()),
|
||||
}
|
||||
}
|
||||
+1
-1
Submodule libs/hbb_common updated: 83419b6549...0c49f9a29c
@@ -0,0 +1,161 @@
|
||||
//! Legacy single-blob address book — `GET /api/ab` and `POST /api/ab`.
|
||||
//!
|
||||
//! Activated when the operator sets `--ab-legacy-mode=on` (which makes
|
||||
//! `/api/ab/personal` 404 — the documented signal in CONSOLE_API.md §4.2).
|
||||
//! The wire shape is a JSON-string field `data` whose contents are a second
|
||||
//! JSON object: `{tags, peers, tag_colors}`. We translate to/from the
|
||||
//! normalized M2 schema on the personal AB.
|
||||
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use crate::api::state::AppState;
|
||||
use crate::database::AbPeerRow;
|
||||
use axum::extract::Extension;
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub async fn get(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let guid = state
|
||||
.db
|
||||
.ab_get_or_create_personal(user.user_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
// Pull all peers and all tags. Page size 1000 is fine — legacy clients
|
||||
// expected a single blob anyway.
|
||||
let (_total, peers) = state
|
||||
.db
|
||||
.ab_list_peers(&guid, 0, 10_000)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let tags = state
|
||||
.db
|
||||
.ab_list_tags(&guid)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let mut tag_colors = Map::new();
|
||||
let tag_names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
|
||||
for t in &tags {
|
||||
tag_colors.insert(t.name.clone(), Value::from(t.color));
|
||||
}
|
||||
let peer_arr: Vec<Value> = peers
|
||||
.iter()
|
||||
.map(|p| {
|
||||
json!({
|
||||
"id": p.id,
|
||||
"alias": p.alias,
|
||||
"tags": p.tags,
|
||||
"username": p.username,
|
||||
"hostname": p.hostname,
|
||||
"platform": p.platform,
|
||||
"hash": p.hash,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let inner = json!({
|
||||
"tags": tag_names,
|
||||
"peers": peer_arr,
|
||||
"tag_colors": Value::String(serde_json::to_string(&tag_colors).unwrap_or_default()),
|
||||
});
|
||||
Ok(Json(json!({ "data": inner.to_string() })))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct LegacyPostBody {
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
pub async fn put(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
Json(body): Json<LegacyPostBody>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let guid = state
|
||||
.db
|
||||
.ab_get_or_create_personal(user.user_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let inner: Value = serde_json::from_str(&body.data)
|
||||
.map_err(|e| ApiError::BadRequest(format!("data is not valid json: {}", e)))?;
|
||||
// Tag colors are stored as a JSON-encoded string field (Flutter wraps
|
||||
// the map in another JSON layer). Tolerate either an inline map or the
|
||||
// doubly-encoded form.
|
||||
let tag_colors_map: Map<String, Value> = match inner.get("tag_colors") {
|
||||
Some(Value::String(s)) => serde_json::from_str(s).unwrap_or_default(),
|
||||
Some(Value::Object(m)) => m.clone(),
|
||||
_ => Map::new(),
|
||||
};
|
||||
let tag_names: Vec<String> = inner
|
||||
.get("tags")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let tags: Vec<(String, i64)> = tag_names
|
||||
.iter()
|
||||
.map(|n| {
|
||||
let color = tag_colors_map
|
||||
.get(n)
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0);
|
||||
(n.clone(), color)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let peer_arr = inner
|
||||
.get("peers")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let mut peers: Vec<AbPeerRow> = Vec::with_capacity(peer_arr.len());
|
||||
for p in peer_arr {
|
||||
let id = p
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
if id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let tags = p
|
||||
.get("tags")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
peers.push(AbPeerRow {
|
||||
id,
|
||||
alias: get_str(&p, "alias"),
|
||||
note: String::new(),
|
||||
password: String::new(),
|
||||
hash: get_str(&p, "hash"),
|
||||
username: get_str(&p, "username"),
|
||||
hostname: get_str(&p, "hostname"),
|
||||
platform: get_str(&p, "platform"),
|
||||
tags,
|
||||
});
|
||||
}
|
||||
state
|
||||
.db
|
||||
.ab_legacy_replace(&guid, &tags, &peers)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
fn get_str(v: &Value, k: &str) -> String {
|
||||
v.get(k)
|
||||
.and_then(|x| x.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod legacy;
|
||||
pub mod peers;
|
||||
pub mod profiles;
|
||||
pub mod rules;
|
||||
pub mod settings;
|
||||
pub mod tags;
|
||||
@@ -0,0 +1,198 @@
|
||||
use crate::api::ab::rules::{enforce, Rule};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use crate::api::pagination::Page;
|
||||
use crate::api::state::AppState;
|
||||
use crate::database::AbPeerInsert;
|
||||
use axum::extract::{Extension, Path, Query};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// `serde_urlencoded` (axum's query decoder) does not honour
|
||||
/// `#[serde(flatten)]`, so the pagination fields are spelled out inline.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AbQuery {
|
||||
/// guid sent in the query string for `/api/ab/peers?ab=<guid>`.
|
||||
pub ab: String,
|
||||
#[serde(default = "default_current")]
|
||||
pub current: i64,
|
||||
#[serde(default = "default_page_size", rename = "pageSize")]
|
||||
pub page_size: i64,
|
||||
}
|
||||
|
||||
fn default_current() -> i64 {
|
||||
1
|
||||
}
|
||||
fn default_page_size() -> i64 {
|
||||
100
|
||||
}
|
||||
|
||||
impl AbQuery {
|
||||
fn offset(&self) -> i64 {
|
||||
(self.current.max(1) - 1) * self.limit()
|
||||
}
|
||||
fn limit(&self) -> i64 {
|
||||
self.page_size.clamp(1, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/ab/peers?ab=<guid>` — paginated peer list inside an AB.
|
||||
/// Wire shape matches the Flutter `Peer` decoder; only fields documented in
|
||||
/// CONSOLE_API.md §4.4 are surfaced.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct PeerOut {
|
||||
id: String,
|
||||
alias: String,
|
||||
tags: Vec<String>,
|
||||
note: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
password: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
hash: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
username: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
hostname: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
platform: String,
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
Query(q): Query<AbQuery>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
enforce(&state, user.user_id, &q.ab, Rule::Read).await?;
|
||||
let (total, rows) = state
|
||||
.db
|
||||
.ab_list_peers(&q.ab, q.offset(), q.limit())
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let data: Vec<PeerOut> = rows
|
||||
.into_iter()
|
||||
.map(|r| PeerOut {
|
||||
id: r.id,
|
||||
alias: r.alias,
|
||||
tags: r.tags,
|
||||
note: r.note,
|
||||
password: r.password,
|
||||
hash: r.hash,
|
||||
username: r.username,
|
||||
hostname: r.hostname,
|
||||
platform: r.platform,
|
||||
})
|
||||
.collect();
|
||||
Ok((StatusCode::OK, Json(Page { total, data })))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PeerAddBody {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub alias: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub note: Option<String>,
|
||||
#[serde(default)]
|
||||
pub password: Option<String>,
|
||||
#[serde(default)]
|
||||
pub hash: Option<String>,
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub hostname: Option<String>,
|
||||
#[serde(default)]
|
||||
pub platform: Option<String>,
|
||||
}
|
||||
|
||||
/// `POST /api/ab/peer/add/{guid}` — insert one peer. **Returns HTTP 200
|
||||
/// with an empty body on success**, or `{"error":"..."}` JSON body on failure
|
||||
/// (also HTTP 200). The Flutter `_jsonDecodeActionResp` at
|
||||
/// flutter/lib/models/ab_model.dart:2002 treats *any* non-empty success body
|
||||
/// as an error to surface — including `{}` (which produces the literal string
|
||||
/// "null"), so action endpoints must reply with truly empty bodies.
|
||||
pub async fn add(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
Path(guid): Path<String>,
|
||||
Json(body): Json<PeerAddBody>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
enforce(&state, user.user_id, &guid, Rule::ReadWrite).await?;
|
||||
if body.id.is_empty() {
|
||||
return Err(ApiError::BadRequest("id required".into()));
|
||||
}
|
||||
let max = state.cfg.ab_max_peers_per_book;
|
||||
let count = state
|
||||
.db
|
||||
.ab_count_peers(&guid)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
if count >= max {
|
||||
return Err(ApiError::Forbidden("exceed_max_devices".into()));
|
||||
}
|
||||
state
|
||||
.db
|
||||
.ab_peer_insert(
|
||||
&guid,
|
||||
AbPeerInsert {
|
||||
id: &body.id,
|
||||
alias: body.alias.as_deref(),
|
||||
note: body.note.as_deref(),
|
||||
password: body.password.as_deref(),
|
||||
hash: body.hash.as_deref(),
|
||||
username: body.username.as_deref(),
|
||||
hostname: body.hostname.as_deref(),
|
||||
platform: body.platform.as_deref(),
|
||||
},
|
||||
body.tags.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
/// `PUT /api/ab/peer/update/{guid}` — partial update. Body always carries
|
||||
/// `id`, plus any subset of mutable fields. Empty success body, see `add`.
|
||||
pub async fn update(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
Path(guid): Path<String>,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
enforce(&state, user.user_id, &guid, Rule::ReadWrite).await?;
|
||||
let id = body
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ApiError::BadRequest("id required".into()))?;
|
||||
let updated = state
|
||||
.db
|
||||
.ab_peer_partial_update(&guid, id, &body)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
if !updated {
|
||||
return Err(ApiError::Forbidden("peer not found".into()));
|
||||
}
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
/// `DELETE /api/ab/peer/{guid}` — body is a JSON array of peer IDs. Empty
|
||||
/// success body, see `add`.
|
||||
pub async fn delete(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
Path(guid): Path<String>,
|
||||
Json(ids): Json<Vec<String>>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
enforce(&state, user.user_id, &guid, Rule::ReadWrite).await?;
|
||||
state
|
||||
.db
|
||||
.ab_peers_delete(&guid, &ids)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use crate::api::pagination::{Page, PageQuery};
|
||||
use crate::api::state::AppState;
|
||||
use axum::extract::{Extension, Query};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// `POST /api/ab/personal` — returns the caller's personal AB GUID, creating
|
||||
/// it if missing. When `--ab-legacy-mode=on` is configured, returns 404 to
|
||||
/// signal "this server speaks the legacy single-blob protocol" (the client
|
||||
/// then falls back to GET/POST /api/ab).
|
||||
pub async fn personal(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
if state.cfg.ab_legacy_mode {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
let guid = state
|
||||
.db
|
||||
.ab_get_or_create_personal(user.user_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(Json(json!({ "guid": guid })))
|
||||
}
|
||||
|
||||
/// `POST /api/ab/shared/profiles` — paginated list of shared address books
|
||||
/// the caller can see. Wire shape matches the Flutter `AbProfile` decoder at
|
||||
/// flutter/lib/common/hbbs/hbbs.dart:258.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AbProfileOut {
|
||||
guid: String,
|
||||
name: String,
|
||||
owner: String,
|
||||
note: String,
|
||||
rule: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
info: Option<Value>,
|
||||
}
|
||||
|
||||
pub async fn shared_profiles(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
Query(page): Query<PageQuery>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let (total, rows) = state
|
||||
.db
|
||||
.ab_list_shared_for_user(user.user_id, page.offset(), page.limit())
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let data = rows
|
||||
.into_iter()
|
||||
.map(|r| AbProfileOut {
|
||||
guid: r.guid,
|
||||
name: r.name,
|
||||
owner: r.owner,
|
||||
note: r.note,
|
||||
rule: r.rule,
|
||||
info: r
|
||||
.info_json
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok()),
|
||||
})
|
||||
.collect();
|
||||
Ok((StatusCode::OK, Json(Page { total, data })))
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::state::AppState;
|
||||
|
||||
/// Share-rule levels for a shared address book. Wire integers match the
|
||||
/// Flutter client's `ShareRule` enum at flutter/lib/common/hbbs/hbbs.dart:210.
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Rule {
|
||||
Read = 1,
|
||||
ReadWrite = 2,
|
||||
Full = 3,
|
||||
}
|
||||
|
||||
impl Rule {
|
||||
pub fn from_i64(v: i64) -> Option<Self> {
|
||||
match v {
|
||||
1 => Some(Rule::Read),
|
||||
2 => Some(Rule::ReadWrite),
|
||||
3 => Some(Rule::Full),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enforce that `caller` has at least `needed` access on `ab_guid`. Used at
|
||||
/// the top of every AB handler. Resolution lives in
|
||||
/// `Database::ab_resolve_rule` and considers (a) AB ownership and (b) the
|
||||
/// largest matching rule across direct and device-group shares.
|
||||
pub async fn enforce(
|
||||
state: &AppState,
|
||||
caller_user_id: i64,
|
||||
ab_guid: &str,
|
||||
needed: Rule,
|
||||
) -> Result<(), ApiError> {
|
||||
let resolved = state
|
||||
.db
|
||||
.ab_resolve_rule(caller_user_id, ab_guid)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let Some(have) = resolved.and_then(Rule::from_i64) else {
|
||||
// Either the AB doesn't exist or the caller has no relationship with
|
||||
// it. Surface as "not allowed" so we don't leak existence.
|
||||
return Err(ApiError::Forbidden("not allowed".into()));
|
||||
};
|
||||
if have >= needed {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApiError::Forbidden("read-only".into()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use crate::api::state::AppState;
|
||||
use axum::extract::Extension;
|
||||
use axum::Json;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// `POST /api/ab/settings` — capability/limit probe. The Flutter client
|
||||
/// (ab_model.dart:230-258) calls this once per pull cycle to learn
|
||||
/// `max_peer_one_ab`. Auth is required even though there is no body.
|
||||
pub async fn settings(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
_user: AuthedUser,
|
||||
) -> Json<Value> {
|
||||
Json(json!({ "max_peer_one_ab": state.cfg.ab_max_peers_per_book }))
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
use crate::api::ab::rules::{enforce, Rule};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use crate::api::state::AppState;
|
||||
use axum::extract::{Extension, Path};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// `POST /api/ab/tags/{guid}` — list tags. Wire shape is a bare JSON array
|
||||
/// `[{name, color}]`, NOT the `Page<T>` envelope.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TagOut {
|
||||
name: String,
|
||||
color: i64,
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
Path(guid): Path<String>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
enforce(&state, user.user_id, &guid, Rule::Read).await?;
|
||||
let rows = state
|
||||
.db
|
||||
.ab_list_tags(&guid)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let out: Vec<TagOut> = rows
|
||||
.into_iter()
|
||||
.map(|t| TagOut {
|
||||
name: t.name,
|
||||
color: t.color,
|
||||
})
|
||||
.collect();
|
||||
Ok((StatusCode::OK, Json(out)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TagAddBody {
|
||||
pub name: String,
|
||||
pub color: i64,
|
||||
}
|
||||
|
||||
pub async fn add(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
Path(guid): Path<String>,
|
||||
Json(body): Json<TagAddBody>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
enforce(&state, user.user_id, &guid, Rule::ReadWrite).await?;
|
||||
if body.name.is_empty() {
|
||||
return Err(ApiError::BadRequest("name required".into()));
|
||||
}
|
||||
state
|
||||
.db
|
||||
.ab_tag_insert(&guid, &body.name, body.color)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TagRenameBody {
|
||||
#[serde(rename = "old")]
|
||||
pub old_name: String,
|
||||
#[serde(rename = "new")]
|
||||
pub new_name: String,
|
||||
}
|
||||
|
||||
pub async fn rename(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
Path(guid): Path<String>,
|
||||
Json(body): Json<TagRenameBody>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
enforce(&state, user.user_id, &guid, Rule::ReadWrite).await?;
|
||||
state
|
||||
.db
|
||||
.ab_tag_rename(&guid, &body.old_name, &body.new_name)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TagUpdateBody {
|
||||
pub name: String,
|
||||
pub color: i64,
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
Path(guid): Path<String>,
|
||||
Json(body): Json<TagUpdateBody>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
enforce(&state, user.user_id, &guid, Rule::ReadWrite).await?;
|
||||
state
|
||||
.db
|
||||
.ab_tag_update_color(&guid, &body.name, body.color)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
Path(guid): Path<String>,
|
||||
Json(names): Json<Vec<String>>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
enforce(&state, user.user_id, &guid, Rule::ReadWrite).await?;
|
||||
state
|
||||
.db
|
||||
.ab_tags_delete(&guid, &names)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
//! `/admin/login` (POST form) and `/admin/logout` (POST). On success login
|
||||
//! sets an HttpOnly + SameSite=Strict cookie containing the freshly-minted
|
||||
//! Bearer token; the browser carries it on every subsequent request to
|
||||
//! `/admin/*` and `/api/*`. The middleware in `api::middleware` already
|
||||
//! accepts both `Authorization: Bearer …` and the cookie.
|
||||
|
||||
use crate::api::admin::i18n::{t, Lang};
|
||||
use crate::api::auth::mint_token;
|
||||
use crate::api::middleware::{sha256_token, SESSION_COOKIE};
|
||||
use crate::api::state::AppState;
|
||||
use crate::api::users::verify_password;
|
||||
use axum::extract::{Extension, Form};
|
||||
use axum::http::header::{COOKIE, SET_COOKIE};
|
||||
use axum::http::{HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginForm {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
/// 6-digit TOTP code, present on the second leg when the first leg
|
||||
/// returned `tfa_check`. The HTML input is `name="tfaCode"` (camelCase)
|
||||
/// to match the rest of the dashboard's form conventions, so we rename
|
||||
/// the wire field rather than renaming the input.
|
||||
#[serde(default, rename = "tfaCode")]
|
||||
pub tfa_code: String,
|
||||
/// Echo of the TOTP nonce the first-leg response set on the form.
|
||||
#[serde(default)]
|
||||
pub secret: String,
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
lang: Lang,
|
||||
Form(form): Form<LoginForm>,
|
||||
) -> Response {
|
||||
// First leg: password verify. Same DB call paths as `/api/login` —
|
||||
// we re-use the existing helpers so the dashboard can't accidentally
|
||||
// diverge from the API's auth contract.
|
||||
let user = match state.db.user_find_by_username(&form.username).await {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => return error_fragment(t(lang, "login.bad_credentials")),
|
||||
Err(e) => return error_fragment(&format!("internal: {}", e)),
|
||||
};
|
||||
let pw_ok = match verify_password(user.password_hash.clone(), form.password.clone()).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return error_fragment(&format!("internal: {}", e)),
|
||||
};
|
||||
if !pw_ok {
|
||||
return error_fragment(t(lang, "login.bad_credentials"));
|
||||
}
|
||||
if user.status == 0 {
|
||||
return error_fragment(t(lang, "login.account_disabled"));
|
||||
}
|
||||
if !user.is_admin {
|
||||
// Only admins can use the dashboard. Non-admin users still get
|
||||
// tokens via `/api/login` for the desktop client; they just don't
|
||||
// see the management surface.
|
||||
return error_fragment(t(lang, "login.admin_required"));
|
||||
}
|
||||
// Optional second leg: TOTP. If the user has a secret enrolled and the
|
||||
// form didn't carry a code, return a fragment that asks for one.
|
||||
let totp_secret = state
|
||||
.db
|
||||
.totp_get_secret(user.id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
if let Some(secret_b32) = totp_secret {
|
||||
if form.tfa_code.is_empty() {
|
||||
// Shape used by the JS in login.html to switch to the second
|
||||
// leg: it watches for the special marker via HX-Trigger and
|
||||
// reveals the #tfa-section.
|
||||
let frag = format!(
|
||||
r#"<span data-tfa-required="1" class="text-amber-300">{msg}</span>
|
||||
<script>
|
||||
document.getElementById('tfa-section').classList.remove('hidden');
|
||||
document.getElementById('tfaCode').focus();
|
||||
</script>"#,
|
||||
msg = t(lang, "login.totp_required"),
|
||||
);
|
||||
// We don't need a session yet — caller will resubmit with the
|
||||
// same username/password plus the code. (No nonce involved on
|
||||
// the dashboard path: the password is already in scope, so
|
||||
// tfa_check / tfa_code are folded into one form.)
|
||||
let _ = secret_b32;
|
||||
return Html(frag).into_response();
|
||||
}
|
||||
// Verify the supplied code.
|
||||
let ok = match crate::api::auth::verify_totp(&secret_b32, &form.tfa_code) {
|
||||
Ok(b) => b,
|
||||
Err(_) => return error_fragment(t(lang, "login.internal_totp")),
|
||||
};
|
||||
if !ok {
|
||||
return error_fragment(t(lang, "login.bad_totp"));
|
||||
}
|
||||
}
|
||||
|
||||
// Mint + persist a token, set the cookie.
|
||||
let token = mint_token();
|
||||
let sha = sha256_token(&token);
|
||||
if let Err(e) = state
|
||||
.db
|
||||
.token_insert(
|
||||
user.id,
|
||||
&sha,
|
||||
"",
|
||||
"",
|
||||
r#"{"source":"admin-ui"}"#,
|
||||
state.cfg.session_ttl_secs,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return error_fragment(&format!("internal: {}", e));
|
||||
}
|
||||
let cookie = format!(
|
||||
"{name}={token}; HttpOnly; Path=/; SameSite=Strict; Max-Age={ttl}",
|
||||
name = SESSION_COOKIE,
|
||||
token = token,
|
||||
ttl = state.cfg.session_ttl_secs,
|
||||
);
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Ok(v) = HeaderValue::from_str(&cookie) {
|
||||
headers.insert(SET_COOKIE, v);
|
||||
}
|
||||
// 200 with empty body; the form's hx-on::after-request redirects on
|
||||
// success.
|
||||
(StatusCode::OK, headers, "").into_response()
|
||||
}
|
||||
|
||||
pub async fn logout(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
// Best-effort: pull the token out of the cookie, drop the row.
|
||||
if let Some(tok) = cookie_token(&headers) {
|
||||
let sha = sha256_token(&tok);
|
||||
let _ = state.db.token_delete(&sha).await;
|
||||
}
|
||||
let mut out = HeaderMap::new();
|
||||
let clear = format!(
|
||||
"{name}=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0",
|
||||
name = SESSION_COOKIE
|
||||
);
|
||||
if let Ok(v) = HeaderValue::from_str(&clear) {
|
||||
out.insert(SET_COOKIE, v);
|
||||
}
|
||||
(StatusCode::OK, out, "").into_response()
|
||||
}
|
||||
|
||||
fn cookie_token(headers: &HeaderMap) -> Option<String> {
|
||||
let s = headers.get(COOKIE)?.to_str().ok()?;
|
||||
for pair in s.split(';') {
|
||||
if let Some((name, value)) = pair.trim().split_once('=') {
|
||||
if name.trim() == SESSION_COOKIE {
|
||||
let v = value.trim();
|
||||
if !v.is_empty() {
|
||||
return Some(v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn error_fragment(msg: &str) -> Response {
|
||||
let html = format!("<span>{}</span>", html_escape(msg));
|
||||
(StatusCode::UNAUTHORIZED, Html(html)).into_response()
|
||||
}
|
||||
|
||||
fn html_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
//! `/admin/me` — small HTMX fragment used by the sidebar to show "signed in
|
||||
//! as <name>". Doubles as a cheap auth-check for the dashboard shell: if
|
||||
//! the cookie isn't valid, the AuthedUser extractor 401s and the page-level
|
||||
//! HTMX response handler bounces back to the login form.
|
||||
|
||||
use crate::api::admin::i18n::{t, Lang};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use axum::response::Html;
|
||||
|
||||
pub async fn me(user: AuthedUser, lang: Lang) -> Result<Html<String>, ApiError> {
|
||||
Ok(Html(format!(
|
||||
"{label} <span class=\"text-slate-300\">{name}</span>",
|
||||
label = t(lang, "nav.signed_in_as"),
|
||||
name = html_escape(&user.name),
|
||||
)))
|
||||
}
|
||||
|
||||
fn html_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
//! Admin dashboard router. Mounted at `/admin/*` by `api::router` when
|
||||
//! the operator hasn't disabled it via `--admin-ui-dir=` (empty).
|
||||
//!
|
||||
//! Static HTML/CSS lives in `admin_ui/` next to the source tree and is
|
||||
//! embedded into the binary at build time via `include_str!` — no separate
|
||||
//! deploy artifact, no ServeDir wildcard route conflicting with the
|
||||
//! literal /admin/login etc. The ASSETS table at the bottom is the
|
||||
//! authoritative list of files we ship.
|
||||
//!
|
||||
//! Layout served at runtime:
|
||||
//! /admin/ ← index.html (the SPA shell)
|
||||
//! /admin/login.html ← login form
|
||||
//! /admin/login POST handler (form-encoded, sets session cookie)
|
||||
//! /admin/logout POST handler (clears session cookie)
|
||||
//! /admin/me GET fragment (current user, sidebar widget)
|
||||
//! /admin/pages/* GET fragments (one per page)
|
||||
|
||||
pub mod auth;
|
||||
pub mod i18n;
|
||||
pub mod me;
|
||||
pub mod oidc_login;
|
||||
pub mod pages;
|
||||
|
||||
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::api::admin::i18n::{lang_from_headers, t, Lang};
|
||||
|
||||
/// Files embedded into the binary. Paths are relative to this source file
|
||||
/// per `include_str!`. Adding a new HTML asset = one new entry here.
|
||||
const INDEX_HTML: &str = include_str!("../../../admin_ui/index.html");
|
||||
const LOGIN_HTML: &str = include_str!("../../../admin_ui/login.html");
|
||||
|
||||
/// Third-party JS dependencies vendored under `admin_ui/assets/` so the
|
||||
/// dashboard doesn't fetch from cdn.tailwindcss.com / unpkg.com at runtime.
|
||||
/// See docs/CONFIGURATION.md "Web client" for the upgrade procedure.
|
||||
const TAILWIND_JS: &[u8] = include_bytes!("../../../admin_ui/assets/tailwindcss.js");
|
||||
const HTMX_JS: &[u8] = include_bytes!("../../../admin_ui/assets/htmx.min.js");
|
||||
|
||||
pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
|
||||
if state.cfg.admin_ui_dir.is_empty() {
|
||||
// Operator opted out by setting the flag to empty.
|
||||
return None;
|
||||
}
|
||||
let r = Router::new()
|
||||
// Static HTML pages — explicit routes per file, no wildcard.
|
||||
.route("/admin", get(serve_index))
|
||||
.route("/admin/", get(serve_index))
|
||||
.route("/admin/index.html", get(serve_index))
|
||||
.route("/admin/login.html", get(serve_login))
|
||||
// Vendored third-party JS — versions pinned in source, so we can
|
||||
// cache aggressively (immutable + 1-year max-age).
|
||||
.route("/admin/assets/tailwindcss.js", get(serve_tailwind))
|
||||
.route("/admin/assets/htmx.min.js", get(serve_htmx))
|
||||
// Dynamic dashboard endpoints.
|
||||
.route("/admin/login", post(auth::login))
|
||||
.route("/admin/logout", post(auth::logout))
|
||||
.route("/admin/me", get(me::me))
|
||||
// OIDC entry points consumed by login.html (unauthenticated — they
|
||||
// *initiate* a sign-in). The matching /oidc/callback is mounted by
|
||||
// the public api router and finishes both desktop and admin flows.
|
||||
.route("/admin/oidc/providers", get(oidc_login::list_providers))
|
||||
.route("/admin/login/oidc/:provider", get(oidc_login::start_login))
|
||||
// Page fragments — one per sidebar entry.
|
||||
.route("/admin/pages/users", get(pages::users::index))
|
||||
.route(
|
||||
"/admin/pages/users/table-fragment",
|
||||
get(pages::users::table_fragment),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/users/columns",
|
||||
post(pages::users::set_columns),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/users/page-size",
|
||||
post(pages::users::set_page_size),
|
||||
)
|
||||
.route("/admin/pages/users/new", get(pages::users::new_form))
|
||||
.route("/admin/pages/users/create", post(pages::users::create))
|
||||
.route(
|
||||
"/admin/pages/users/:id/update-info",
|
||||
post(pages::users::update_info),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/users/:id/password-reset",
|
||||
post(pages::users::reset_password),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/users/:id/toggle-admin",
|
||||
post(pages::users::toggle_admin),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/users/:id/toggle-status",
|
||||
post(pages::users::toggle_status),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/users/:id/totp-enroll",
|
||||
post(pages::users::totp_enroll),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/users/:id/totp-unenroll",
|
||||
post(pages::users::totp_unenroll),
|
||||
)
|
||||
.route("/admin/pages/users/:id/delete", post(pages::users::delete))
|
||||
// Devices
|
||||
.route(
|
||||
"/admin/pages/devices/list-fragment",
|
||||
get(pages::devices::list_fragment),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/devices/columns",
|
||||
post(pages::devices::set_columns),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/devices/page-size",
|
||||
post(pages::devices::set_page_size),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/devices/:peer_id/detail",
|
||||
get(pages::devices::detail),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/devices/:peer_id/disconnect",
|
||||
post(pages::devices::force_disconnect),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/devices/:peer_id/sysinfo-refresh",
|
||||
post(pages::devices::force_sysinfo),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/devices/:peer_id/delete",
|
||||
post(pages::devices::delete),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/devices/:peer_id/toggle-managed",
|
||||
post(pages::devices::toggle_managed),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/devices/:peer_id/exec",
|
||||
get(pages::exec::index).post(pages::exec::dispatch),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/devices/:peer_id/exec/:cmd_id/poll",
|
||||
get(pages::exec::poll),
|
||||
)
|
||||
// Groups
|
||||
.route("/admin/pages/groups/create", post(pages::groups::create))
|
||||
.route("/admin/pages/groups/:id/delete", post(pages::groups::delete))
|
||||
.route(
|
||||
"/admin/pages/groups/:id/members/add",
|
||||
post(pages::groups::add_member),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/groups/:id/members/:user_id/remove",
|
||||
post(pages::groups::remove_member),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/groups/:id/peers/add",
|
||||
post(pages::groups::add_peer),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/groups/:id/peers/:peer_id/remove",
|
||||
post(pages::groups::remove_peer),
|
||||
)
|
||||
// Strategies
|
||||
.route(
|
||||
"/admin/pages/strategies/create",
|
||||
post(pages::strategies::create),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/strategies/:id/update",
|
||||
post(pages::strategies::update),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/strategies/:id/delete",
|
||||
post(pages::strategies::delete),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/strategies/:id/assignments/group",
|
||||
post(pages::strategies::assign_group),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/strategies/:id/assignments/peer",
|
||||
post(pages::strategies::assign_peer),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/strategies/:id/assignments/:assignment_id/delete",
|
||||
post(pages::strategies::unassign),
|
||||
)
|
||||
.route("/admin/pages/deploy", get(pages::deploy::index))
|
||||
.route(
|
||||
"/admin/pages/deploy/generate",
|
||||
post(pages::deploy::generate),
|
||||
)
|
||||
// Web client (M6) — full-page SPA, NOT an HTMX fragment. Mounted
|
||||
// outside /admin/pages/ because it's a standalone document the
|
||||
// operator opens in a new tab from the Devices action menu.
|
||||
.route(
|
||||
"/admin/connect/:peer_id",
|
||||
get(pages::connect::index),
|
||||
)
|
||||
.route(
|
||||
"/admin/connect/assets/bundle.js",
|
||||
get(pages::connect::bundle_js),
|
||||
)
|
||||
.route(
|
||||
"/admin/connect/assets/bundle.css",
|
||||
get(pages::connect::bundle_css),
|
||||
)
|
||||
.route("/admin/pages/devices", get(pages::devices::index))
|
||||
.route("/admin/pages/groups", get(pages::groups::index))
|
||||
.route("/admin/pages/strategies", get(pages::strategies::index))
|
||||
.route(
|
||||
"/admin/pages/address-books",
|
||||
get(pages::address_books::index),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/address-books/create",
|
||||
post(pages::address_books::create),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/address-books/:guid/delete",
|
||||
post(pages::address_books::delete),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/address-books/:guid/manage",
|
||||
get(pages::address_books::manage),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/address-books/:guid/shares/add",
|
||||
post(pages::address_books::share_add),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/address-books/:guid/shares/:user_id/remove",
|
||||
post(pages::address_books::share_remove),
|
||||
)
|
||||
// Self-service profile — cookie-only, no admin gate.
|
||||
.route("/admin/pages/profile", get(pages::profile::index))
|
||||
.route(
|
||||
"/admin/pages/profile/update-info",
|
||||
post(pages::profile::update_info),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/profile/change-password",
|
||||
post(pages::profile::change_password),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/profile/totp/start",
|
||||
post(pages::profile::totp_start),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/profile/totp/confirm",
|
||||
post(pages::profile::totp_confirm),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/profile/totp/remove",
|
||||
post(pages::profile::totp_remove),
|
||||
)
|
||||
.route("/admin/pages/audit", get(pages::audit::index));
|
||||
hbb_common::log::info!(
|
||||
"admin dashboard mounted at /admin (HTML embedded; --admin-ui-dir is informational)"
|
||||
);
|
||||
Some(r)
|
||||
}
|
||||
|
||||
async fn serve_index(headers: HeaderMap) -> Response {
|
||||
let lang = lang_from_headers(&headers);
|
||||
html_response_owned(render_index(lang))
|
||||
}
|
||||
|
||||
async fn serve_login(headers: HeaderMap) -> Response {
|
||||
let lang = lang_from_headers(&headers);
|
||||
html_response_owned(render_login(lang))
|
||||
}
|
||||
|
||||
/// Apply i18n placeholders to the embedded `index.html` template.
|
||||
/// `APP_VERSION` is intentionally distinct from the `T_*` translation
|
||||
/// tokens — it's a server-side constant, not a localizable string.
|
||||
fn render_index(lang: Lang) -> String {
|
||||
let body = INDEX_HTML
|
||||
.replace("{{LANG_CODE}}", lang.code())
|
||||
.replace("{{T_APP_TITLE}}", t(lang, "shell.app_title"))
|
||||
.replace("{{T_NAV_USERS}}", t(lang, "nav.users"))
|
||||
.replace("{{T_NAV_DEVICES}}", t(lang, "nav.devices"))
|
||||
.replace("{{T_NAV_GROUPS}}", t(lang, "nav.groups"))
|
||||
.replace("{{T_NAV_STRATEGIES}}", t(lang, "nav.strategies"))
|
||||
.replace("{{T_NAV_AB}}", t(lang, "nav.address_books"))
|
||||
.replace("{{T_NAV_AUDIT}}", t(lang, "nav.audit"))
|
||||
.replace("{{T_NAV_DEPLOY}}", t(lang, "nav.deploy"))
|
||||
.replace("{{T_NAV_PROFILE}}", t(lang, "nav.profile"))
|
||||
.replace("{{T_NAV_SIGNOUT}}", t(lang, "nav.signout"))
|
||||
.replace("{{T_LANGUAGE}}", t(lang, "common.language"))
|
||||
.replace("{{T_LOADING}}", t(lang, "common.loading"))
|
||||
.replace("{{APP_VERSION}}", crate::version::VERSION);
|
||||
apply_lang_selected(body, lang)
|
||||
}
|
||||
|
||||
/// Apply i18n placeholders to the embedded `login.html` template.
|
||||
fn render_login(lang: Lang) -> String {
|
||||
let body = LOGIN_HTML
|
||||
.replace("{{LANG_CODE}}", lang.code())
|
||||
.replace("{{T_TITLE}}", t(lang, "login.title"))
|
||||
.replace("{{T_SUBTITLE}}", t(lang, "login.subtitle"))
|
||||
.replace("{{T_USERNAME}}", t(lang, "login.username"))
|
||||
.replace("{{T_PASSWORD}}", t(lang, "login.password"))
|
||||
.replace("{{T_TOTP_LABEL}}", t(lang, "login.totp_label"))
|
||||
.replace("{{T_SIGNIN}}", t(lang, "login.signin"))
|
||||
.replace("{{T_OR}}", t(lang, "login.or"))
|
||||
.replace("{{T_LANGUAGE}}", t(lang, "common.language"))
|
||||
.replace(
|
||||
"{{T_SIGNIN_WITH_JSON}}",
|
||||
&json_string(t(lang, "login.signin_with")),
|
||||
)
|
||||
.replace("{{APP_VERSION}}", crate::version::VERSION);
|
||||
apply_lang_selected(body, lang)
|
||||
}
|
||||
|
||||
/// Inject `selected` into the matching `<option>` for the active language and
|
||||
/// blank out the others. Both templates use the same `{{LANG_SEL_XX}}` markers.
|
||||
fn apply_lang_selected(body: String, lang: Lang) -> String {
|
||||
let mut sel_en = "";
|
||||
let mut sel_de = "";
|
||||
let mut sel_fr = "";
|
||||
let mut sel_ro = "";
|
||||
let mut sel_es = "";
|
||||
match lang {
|
||||
Lang::En => sel_en = " selected",
|
||||
Lang::De => sel_de = " selected",
|
||||
Lang::Fr => sel_fr = " selected",
|
||||
Lang::Ro => sel_ro = " selected",
|
||||
Lang::Es => sel_es = " selected",
|
||||
}
|
||||
body.replace("{{LANG_SEL_EN}}", sel_en)
|
||||
.replace("{{LANG_SEL_DE}}", sel_de)
|
||||
.replace("{{LANG_SEL_FR}}", sel_fr)
|
||||
.replace("{{LANG_SEL_RO}}", sel_ro)
|
||||
.replace("{{LANG_SEL_ES}}", sel_es)
|
||||
}
|
||||
|
||||
/// JSON-encode a string so it can be embedded inside a `<script>` block as a
|
||||
/// JS string literal. We only need to escape `"`, `\`, and the control chars
|
||||
/// that show up in our translations — none of them realistically contain
|
||||
/// newlines or `</script>`, but escape them defensively anyway.
|
||||
fn json_string(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
'<' => out.push_str("\\u003c"),
|
||||
'>' => out.push_str("\\u003e"),
|
||||
'&' => out.push_str("\\u0026"),
|
||||
c if (c as u32) < 0x20 => {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(out, "\\u{:04x}", c as u32);
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
fn html_response_owned(body: String) -> Response {
|
||||
// Cache-Control: no-cache so the operator sees fresh HTML after a
|
||||
// server upgrade without having to bump asset URLs.
|
||||
let mut resp = Html(body).into_response();
|
||||
resp.headers_mut().insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("no-cache"),
|
||||
);
|
||||
resp
|
||||
}
|
||||
|
||||
async fn serve_tailwind() -> Response {
|
||||
js_response(TAILWIND_JS)
|
||||
}
|
||||
|
||||
async fn serve_htmx() -> Response {
|
||||
js_response(HTMX_JS)
|
||||
}
|
||||
|
||||
fn js_response(body: &'static [u8]) -> Response {
|
||||
let mut resp = (StatusCode::OK, body).into_response();
|
||||
let h = resp.headers_mut();
|
||||
h.insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/javascript; charset=utf-8"),
|
||||
);
|
||||
// Vendored at a pinned version — safe to cache for a year. If we
|
||||
// ever bump the version we should also bump the asset path so
|
||||
// browsers don't keep stale copies; for now the path-pinned version
|
||||
// is implicit in the binary build.
|
||||
h.insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("public, max-age=31536000, immutable"),
|
||||
);
|
||||
resp
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! OIDC login entry points for the admin dashboard.
|
||||
//!
|
||||
//! Two unauthenticated GET endpoints used by `admin_ui/login.html`:
|
||||
//!
|
||||
//! - `GET /admin/oidc/providers` returns the enabled providers as JSON so
|
||||
//! the login page can render a button per provider.
|
||||
//! - `GET /admin/login/oidc/:provider` creates an OIDC session marked as
|
||||
//! admin-flow (via the sentinel below) and 302-redirects the browser to
|
||||
//! the IdP authorization URL. After the IdP redirects to
|
||||
//! `/oidc/callback`, the existing callback handler detects the sentinel
|
||||
//! and finishes by setting `rd_admin_session` + redirecting to `/admin/`
|
||||
//! (see api/oidc/callback.rs).
|
||||
//!
|
||||
//! We keep this module separate from the desktop-client OIDC flow so the
|
||||
//! "device polls /api/oidc/auth-query" semantics stay untouched.
|
||||
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::oidc::{discovery, random_token, require_provider, OIDC_SESSION_TTL_SECS};
|
||||
use crate::api::state::AppState;
|
||||
use crate::database::OidcSessionInsert;
|
||||
use axum::extract::{Extension, Path};
|
||||
use axum::response::Redirect;
|
||||
use axum::Json;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Sentinel stuffed into `client_id_str` / `client_uuid` of an OidcSession
|
||||
/// so the callback can tell admin-UI flows apart from desktop-client flows.
|
||||
/// Real device UUIDs from the desktop client are hex-formatted GUIDs and
|
||||
/// won't collide.
|
||||
pub const ADMIN_SENTINEL: &str = "__admin_ui__";
|
||||
|
||||
pub async fn list_providers(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
) -> Json<Value> {
|
||||
let mut out: Vec<Value> = Vec::new();
|
||||
if !state.cfg.public_base_url.is_empty() {
|
||||
if let Ok(providers) = state.db.oidc_provider_list_enabled().await {
|
||||
for p in providers {
|
||||
out.push(json!({
|
||||
"name": p.name,
|
||||
"display_name": p.display_name.unwrap_or_else(|| p.name.clone()),
|
||||
"icon_url": p.icon_url,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Json(json!(out))
|
||||
}
|
||||
|
||||
pub async fn start_login(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
Path(provider_name): Path<String>,
|
||||
) -> Result<Redirect, ApiError> {
|
||||
if state.cfg.public_base_url.is_empty() {
|
||||
return Err(ApiError::Internal(
|
||||
"OIDC requires --public-base-url to be set".into(),
|
||||
));
|
||||
}
|
||||
let provider = require_provider(&state, &provider_name).await?;
|
||||
let disc = discovery::discover(&provider.issuer_url)
|
||||
.await
|
||||
.map_err(ApiError::Internal)?;
|
||||
|
||||
let code = random_token();
|
||||
let csrf_state = random_token();
|
||||
let expires_at = chrono::Utc::now().timestamp() + OIDC_SESSION_TTL_SECS;
|
||||
state
|
||||
.db
|
||||
.oidc_session_create(&OidcSessionInsert {
|
||||
code: &code,
|
||||
provider: &provider.name,
|
||||
state: &csrf_state,
|
||||
client_id_str: ADMIN_SENTINEL,
|
||||
client_uuid: ADMIN_SENTINEL,
|
||||
device_info_json: r#"{"source":"admin-ui"}"#,
|
||||
expires_at,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
|
||||
let url = format!(
|
||||
"{auth}?response_type=code&client_id={cid}&redirect_uri={ru}&scope={scope}&state={st}",
|
||||
auth = disc.authorization_endpoint,
|
||||
cid = url_encode(&provider.client_id),
|
||||
ru = url_encode(&provider.redirect_url),
|
||||
scope = url_encode(&provider.scopes),
|
||||
st = url_encode(&csrf_state),
|
||||
);
|
||||
Ok(Redirect::temporary(&url))
|
||||
}
|
||||
|
||||
fn url_encode(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for b in s.as_bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(*b as char);
|
||||
}
|
||||
_ => {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(out, "%{:02X}", b);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
//! Address books — list, create shared books, manage shares, delete.
|
||||
//! Personal books are owned by individual users and managed from the
|
||||
//! desktop client (the dashboard refuses to mutate them). Shared books
|
||||
//! are server-side artifacts: an admin creates one, then grants users
|
||||
//! `read` / `read+write` / `full` access; the desktop client picks them
|
||||
//! up via `/api/ab/shared/profiles` on the next AB sync (~30 s).
|
||||
|
||||
use super::shared::{fmt_unix, html_escape, notice_html, require_admin};
|
||||
use crate::api::admin::i18n::{t, tf1, Lang};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use crate::api::state::AppState;
|
||||
use axum::extract::{Extension, Form, Path};
|
||||
use axum::response::Html;
|
||||
use serde::Deserialize;
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub async fn index(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
Ok(Html(render_index(&state, lang, None).await?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateForm {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Form(f): Form<CreateForm>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
let name = f.name.trim();
|
||||
if name.is_empty() {
|
||||
return Ok(Html(
|
||||
render_index(&state, lang, Some(("error", t(lang, "ab.name_required")))).await?,
|
||||
));
|
||||
}
|
||||
let res = state.db.ab_create_shared(admin.user_id, name).await;
|
||||
let notice = match res {
|
||||
Ok(_) => Some(("ok", tf1(lang, "ab.created", name))),
|
||||
Err(e) => {
|
||||
// The unique index trips when the same admin creates two books
|
||||
// with the same name. Surface that cleanly instead of leaking
|
||||
// the raw SQL error.
|
||||
let msg = if e.to_string().to_lowercase().contains("unique") {
|
||||
t(lang, "ab.exists").to_string()
|
||||
} else {
|
||||
tf1(lang, "ab.create_failed", &e.to_string())
|
||||
};
|
||||
Some(("error", msg))
|
||||
}
|
||||
};
|
||||
let n = notice.as_ref().map(|(k, m)| (*k, m.as_str()));
|
||||
Ok(Html(render_index(&state, lang, n).await?))
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path(guid): Path<String>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
let ok = state
|
||||
.db
|
||||
.ab_delete(&guid)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let notice = if ok {
|
||||
("ok", t(lang, "ab.deleted"))
|
||||
} else {
|
||||
("error", t(lang, "ab.not_found"))
|
||||
};
|
||||
Ok(Html(render_index(&state, lang, Some(notice)).await?))
|
||||
}
|
||||
|
||||
pub async fn manage(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path(guid): Path<String>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
Ok(Html(render_manage(&state, lang, &guid, None).await?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ShareForm {
|
||||
pub user_id: i64,
|
||||
pub rule: i64,
|
||||
}
|
||||
|
||||
pub async fn share_add(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path(guid): Path<String>,
|
||||
Form(f): Form<ShareForm>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
if !(1..=3).contains(&f.rule) {
|
||||
return Ok(Html(
|
||||
render_manage(&state, lang, &guid, Some(("error", t(lang, "ab.invalid_rule")))).await?,
|
||||
));
|
||||
}
|
||||
state
|
||||
.db
|
||||
.ab_share_set(&guid, f.user_id, f.rule)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(Html(
|
||||
render_manage(&state, lang, &guid, Some(("ok", t(lang, "ab.share_saved")))).await?,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn share_remove(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path((guid, user_id)): Path<(String, i64)>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
let _ = state
|
||||
.db
|
||||
.ab_share_remove(&guid, user_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(Html(
|
||||
render_manage(&state, lang, &guid, Some(("ok", t(lang, "ab.share_removed")))).await?,
|
||||
))
|
||||
}
|
||||
|
||||
// ---------- rendering ----------
|
||||
|
||||
async fn render_index(
|
||||
state: &Arc<AppState>,
|
||||
lang: Lang,
|
||||
notice: Option<(&str, &str)>,
|
||||
) -> Result<String, ApiError> {
|
||||
let books = state
|
||||
.db
|
||||
.ab_list_all_with_owner()
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let notice_html = notice.map(|(k, m)| notice_html(k, m)).unwrap_or_default();
|
||||
let mut s = String::new();
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<div id="ab-region" class="space-y-6">
|
||||
<header>
|
||||
<h2 class="text-lg font-semibold">{heading}</h2>
|
||||
<p class="text-xs text-slate-500 mt-1">{tagline}</p>
|
||||
</header>
|
||||
|
||||
{notice_html}
|
||||
|
||||
<form
|
||||
class="flex items-end gap-2 bg-slate-900 border border-slate-800 rounded-lg p-3"
|
||||
hx-post="/admin/pages/address-books/create"
|
||||
hx-target="#ab-region" hx-swap="outerHTML"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="ab-name">{new_shared}</label>
|
||||
<input id="ab-name" name="name" type="text" required
|
||||
placeholder="Engineering laptops"
|
||||
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500" />
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium rounded px-4 py-2 transition">
|
||||
{create}
|
||||
</button>
|
||||
</form>
|
||||
"##,
|
||||
heading = t(lang, "ab.heading"),
|
||||
tagline = t(lang, "ab.tagline"),
|
||||
new_shared = t(lang, "ab.new_shared"),
|
||||
create = t(lang, "common.create"),
|
||||
);
|
||||
if books.is_empty() {
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<p class="text-slate-500 text-sm">{}</p></div>"##,
|
||||
t(lang, "ab.no_books"),
|
||||
);
|
||||
return Ok(s);
|
||||
}
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<div class="rounded-md border border-slate-800 bg-slate-900">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-xs uppercase text-slate-500 bg-slate-950"><tr>
|
||||
<th class="text-left font-medium px-3 py-2">{c_owner}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_kind}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_name}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_peers}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_guid}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_created}</th>
|
||||
<th class="text-right font-medium px-3 py-2 w-1">{c_actions}</th>
|
||||
</tr></thead>
|
||||
<tbody class="divide-y divide-slate-800">"##,
|
||||
c_owner = t(lang, "ab.col_owner"),
|
||||
c_kind = t(lang, "ab.col_kind"),
|
||||
c_name = t(lang, "ab.col_name"),
|
||||
c_peers = t(lang, "ab.col_peers"),
|
||||
c_guid = t(lang, "ab.col_guid"),
|
||||
c_created = t(lang, "ab.col_created"),
|
||||
c_actions = t(lang, "common.actions"),
|
||||
);
|
||||
for b in &books {
|
||||
let kind_pill = match b.kind {
|
||||
0 => format!(
|
||||
r#"<span class="text-xs px-1.5 py-0.5 rounded bg-slate-800 border border-slate-700 text-slate-300">{}</span>"#,
|
||||
t(lang, "ab.kind_personal"),
|
||||
),
|
||||
1 => format!(
|
||||
r#"<span class="text-xs px-1.5 py-0.5 rounded bg-violet-900/40 border border-violet-700/50 text-violet-300">{}</span>"#,
|
||||
t(lang, "ab.kind_shared"),
|
||||
),
|
||||
_ => String::new(),
|
||||
};
|
||||
// Both kinds get a delete action. Shared books additionally get
|
||||
// "Manage shares". Personal books carry an extra warning in the
|
||||
// confirm because the owning user's desktop client may resync
|
||||
// and recreate the book on next AB tick — deletion is "reset to
|
||||
// empty", not "permanently revoked".
|
||||
let actions = if b.kind == 1 {
|
||||
format!(
|
||||
r##"<details class="text-right relative">
|
||||
<summary class="cursor-pointer list-none text-xs text-slate-400 hover:text-slate-200 select-none">···</summary>
|
||||
<div class="absolute right-2 mt-1 z-10 w-48 bg-slate-900 border border-slate-700 rounded shadow-lg p-2 space-y-1 text-left">
|
||||
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
|
||||
hx-get="/admin/pages/address-books/{guid}/manage"
|
||||
hx-target="#ab-region" hx-swap="outerHTML">
|
||||
{manage}
|
||||
</button>
|
||||
<hr class="border-slate-700 my-1" />
|
||||
<button class="w-full text-left px-2 py-1 text-xs text-rose-300 hover:bg-rose-900/40 rounded"
|
||||
hx-post="/admin/pages/address-books/{guid}/delete"
|
||||
hx-target="#ab-region" hx-swap="outerHTML"
|
||||
hx-confirm="{confirm}">
|
||||
{delete}
|
||||
</button>
|
||||
</div>
|
||||
</details>"##,
|
||||
guid = html_escape(&b.guid),
|
||||
manage = t(lang, "ab.manage_shares"),
|
||||
confirm = html_escape(&tf1(lang, "ab.confirm_delete_shared", &b.name)),
|
||||
delete = t(lang, "ab.delete_book"),
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
r##"<details class="text-right relative">
|
||||
<summary class="cursor-pointer list-none text-xs text-slate-400 hover:text-slate-200 select-none">···</summary>
|
||||
<div class="absolute right-2 mt-1 z-10 w-56 bg-slate-900 border border-slate-700 rounded shadow-lg p-2 space-y-1 text-left">
|
||||
<button class="w-full text-left px-2 py-1 text-xs text-rose-300 hover:bg-rose-900/40 rounded"
|
||||
hx-post="/admin/pages/address-books/{guid}/delete"
|
||||
hx-target="#ab-region" hx-swap="outerHTML"
|
||||
hx-confirm="{confirm}">
|
||||
{delete}
|
||||
</button>
|
||||
</div>
|
||||
</details>"##,
|
||||
guid = html_escape(&b.guid),
|
||||
confirm = html_escape(&tf1(lang, "ab.confirm_delete_personal", &b.owner_username)),
|
||||
delete = t(lang, "ab.delete_book"),
|
||||
)
|
||||
};
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<tr>
|
||||
<td class="px-3 py-2 text-slate-200">{owner}</td>
|
||||
<td class="px-3 py-2">{kind}</td>
|
||||
<td class="px-3 py-2 text-slate-300">{name}</td>
|
||||
<td class="px-3 py-2 text-slate-400">{count}</td>
|
||||
<td class="px-3 py-2 font-mono text-xs text-slate-500">{guid}</td>
|
||||
<td class="px-3 py-2 text-slate-500 text-xs">{created}</td>
|
||||
<td class="px-3 py-2">{actions}</td>
|
||||
</tr>"##,
|
||||
owner = html_escape(&b.owner_username),
|
||||
kind = kind_pill,
|
||||
name = html_escape(&b.name),
|
||||
count = b.peer_count,
|
||||
guid = html_escape(&b.guid),
|
||||
created = html_escape(&fmt_unix(b.created_at)),
|
||||
actions = actions,
|
||||
);
|
||||
}
|
||||
s.push_str("</tbody></table></div></div>");
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
async fn render_manage(
|
||||
state: &Arc<AppState>,
|
||||
lang: Lang,
|
||||
guid: &str,
|
||||
notice: Option<(&str, &str)>,
|
||||
) -> Result<String, ApiError> {
|
||||
let owner_kind = state
|
||||
.db
|
||||
.ab_get_owner_kind(guid)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let Some((_owner_id, kind)) = owner_kind else {
|
||||
return Ok(format!(
|
||||
r##"<div id="ab-region">{notice}</div>"##,
|
||||
notice = notice_html("error", t(lang, "ab.not_found")),
|
||||
));
|
||||
};
|
||||
if kind != 1 {
|
||||
return Ok(format!(
|
||||
r##"<div id="ab-region">{notice}</div>"##,
|
||||
notice = notice_html("error", t(lang, "ab.personal_managed_at_client")),
|
||||
));
|
||||
}
|
||||
let shares = state
|
||||
.db
|
||||
.ab_list_shares(guid)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let (_total, users) = state
|
||||
.db
|
||||
.users_list_all(0, 1000)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let already_shared: std::collections::HashSet<i64> =
|
||||
shares.iter().map(|s| s.user_id).collect();
|
||||
|
||||
let notice_html = notice.map(|(k, m)| notice_html(k, m)).unwrap_or_default();
|
||||
let mut s = String::new();
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<div id="ab-region" class="space-y-6">
|
||||
<header class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">{heading}</h2>
|
||||
<p class="text-xs text-slate-500 mt-1 font-mono">{guid}</p>
|
||||
</div>
|
||||
<button
|
||||
class="text-xs text-slate-300 hover:text-slate-100 px-2 py-1 rounded border border-slate-700 hover:border-slate-500"
|
||||
hx-get="/admin/pages/address-books"
|
||||
hx-target="#ab-region" hx-swap="outerHTML">
|
||||
{back}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{notice_html}
|
||||
|
||||
<section class="rounded-md border border-slate-800 bg-slate-900 p-4 space-y-3">
|
||||
<h3 class="text-sm font-semibold text-slate-200">{add_or_update}</h3>
|
||||
<form
|
||||
class="flex flex-wrap items-end gap-2"
|
||||
hx-post="/admin/pages/address-books/{guid}/shares/add"
|
||||
hx-target="#ab-region" hx-swap="outerHTML"
|
||||
>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="share-user">{user_label}</label>
|
||||
<select id="share-user" name="user_id" required
|
||||
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500">"##,
|
||||
heading = t(lang, "ab.manage_heading"),
|
||||
back = t(lang, "ab.back"),
|
||||
add_or_update = t(lang, "ab.add_or_update"),
|
||||
user_label = t(lang, "ab.user_label"),
|
||||
guid = html_escape(guid),
|
||||
notice_html = notice_html,
|
||||
);
|
||||
if users.is_empty() {
|
||||
let _ = write!(
|
||||
s,
|
||||
r#"<option disabled>{}</option>"#,
|
||||
t(lang, "ab.no_users"),
|
||||
);
|
||||
}
|
||||
let already_text = t(lang, "ab.existing_will_update");
|
||||
for u in &users {
|
||||
let already = if already_shared.contains(&u.id) { already_text } else { "" };
|
||||
let _ = write!(
|
||||
s,
|
||||
r#"<option value="{id}">{name}{already}</option>"#,
|
||||
id = u.id,
|
||||
name = html_escape(&u.username),
|
||||
already = html_escape(already),
|
||||
);
|
||||
}
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="share-rule">{rule}</label>
|
||||
<select id="share-rule" name="rule"
|
||||
class="bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500">
|
||||
<option value="1">{r_read}</option>
|
||||
<option value="2" selected>{r_rw}</option>
|
||||
<option value="3">{r_full}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium rounded px-4 py-2 transition">
|
||||
{save}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="rounded-md border border-slate-800 bg-slate-900">
|
||||
<header class="px-4 py-2 text-xs uppercase text-slate-500 border-b border-slate-800">
|
||||
{current_shares}
|
||||
</header>
|
||||
"##,
|
||||
rule = t(lang, "ab.rule"),
|
||||
r_read = t(lang, "ab.rule_read"),
|
||||
r_rw = t(lang, "ab.rule_read_write"),
|
||||
r_full = t(lang, "ab.rule_full"),
|
||||
save = t(lang, "common.save"),
|
||||
current_shares = tf1(lang, "ab.current_shares", &shares.len().to_string()),
|
||||
);
|
||||
if shares.is_empty() {
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<p class="px-4 py-3 text-slate-500 text-sm">{}</p></section></div>"##,
|
||||
t(lang, "ab.no_shares"),
|
||||
);
|
||||
return Ok(s);
|
||||
}
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<table class="w-full text-sm">
|
||||
<thead class="text-xs uppercase text-slate-500 bg-slate-950"><tr>
|
||||
<th class="text-left font-medium px-3 py-2">{user_l}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{rule_l}</th>
|
||||
<th class="text-right font-medium px-3 py-2 w-1"></th>
|
||||
</tr></thead>
|
||||
<tbody class="divide-y divide-slate-800">"##,
|
||||
user_l = t(lang, "ab.user_label"),
|
||||
rule_l = t(lang, "ab.rule"),
|
||||
);
|
||||
for sh in &shares {
|
||||
let rule = match sh.rule {
|
||||
1 => t(lang, "ab.rule_read"),
|
||||
2 => t(lang, "ab.rule_read_write"),
|
||||
3 => t(lang, "ab.rule_full"),
|
||||
_ => "?",
|
||||
};
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<tr>
|
||||
<td class="px-3 py-2 text-slate-200">{user}</td>
|
||||
<td class="px-3 py-2 text-slate-400">{rule}</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
<button class="text-xs text-rose-300 hover:text-rose-200 px-2 py-1 rounded hover:bg-rose-900/40"
|
||||
hx-post="/admin/pages/address-books/{guid}/shares/{uid}/remove"
|
||||
hx-target="#ab-region" hx-swap="outerHTML"
|
||||
hx-confirm="{confirm}">
|
||||
{remove}
|
||||
</button>
|
||||
</td>
|
||||
</tr>"##,
|
||||
user = html_escape(&sh.username),
|
||||
rule = rule,
|
||||
guid = html_escape(guid),
|
||||
uid = sh.user_id,
|
||||
confirm = html_escape(&tf1(lang, "ab.confirm_remove", &sh.username)),
|
||||
remove = t(lang, "common.remove"),
|
||||
);
|
||||
}
|
||||
s.push_str("</tbody></table></section></div>");
|
||||
Ok(s)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Audit log browser — three tabs (conn / file / alarm), each capped at the
|
||||
//! latest 200 rows. M5c MVP. Pagination/filtering by date range can come in
|
||||
//! a follow-up if the operator outgrows this view.
|
||||
|
||||
use super::shared::{fmt_unix, html_escape, require_admin};
|
||||
use crate::api::admin::i18n::{t, tf1, Lang};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use crate::api::state::AppState;
|
||||
use axum::extract::{Extension, Query};
|
||||
use axum::response::Html;
|
||||
use serde::Deserialize;
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::Arc;
|
||||
|
||||
const PAGE_SIZE: i64 = 200;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TabQuery {
|
||||
#[serde(default)]
|
||||
pub tab: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn index(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Query(q): Query<TabQuery>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
let tab = q.tab.as_deref().unwrap_or("conn");
|
||||
let body = match tab {
|
||||
"file" => render_file(&state, lang).await?,
|
||||
"alarm" => render_alarm(&state, lang).await?,
|
||||
_ => render_conn(&state, lang).await?,
|
||||
};
|
||||
let pill = |id: &str, label: &str| {
|
||||
let active = id == tab;
|
||||
let cls = if active {
|
||||
"bg-slate-800 text-sky-300 border-sky-800"
|
||||
} else {
|
||||
"bg-slate-900 text-slate-400 border-slate-800 hover:text-slate-200"
|
||||
};
|
||||
format!(
|
||||
r##"<a href="#audit" hx-get="/admin/pages/audit?tab={id}" hx-target="#main" class="px-3 py-1 rounded border {cls}">{label}</a>"##,
|
||||
id = id,
|
||||
cls = cls,
|
||||
label = label,
|
||||
)
|
||||
};
|
||||
Ok(Html(format!(
|
||||
r##"<div class="space-y-4">
|
||||
<header class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">{heading}</h2>
|
||||
<p class="text-xs text-slate-500">{latest}</p>
|
||||
</header>
|
||||
<div class="flex gap-2 text-xs">{pill_conn}{pill_file}{pill_alarm}</div>
|
||||
{body}
|
||||
</div>"##,
|
||||
heading = t(lang, "audit.heading"),
|
||||
latest = tf1(lang, "audit.latest", &PAGE_SIZE.to_string()),
|
||||
pill_conn = pill("conn", t(lang, "audit.tab_conn")),
|
||||
pill_file = pill("file", t(lang, "audit.tab_file")),
|
||||
pill_alarm = pill("alarm", t(lang, "audit.tab_alarm")),
|
||||
body = body,
|
||||
)))
|
||||
}
|
||||
|
||||
async fn render_conn(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiError> {
|
||||
let rows = state
|
||||
.db
|
||||
.audit_conn_list(PAGE_SIZE)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
if rows.is_empty() {
|
||||
return Ok(empty_table(t(lang, "audit.no_conn")));
|
||||
}
|
||||
let mut s = String::new();
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-xs uppercase text-slate-500 bg-slate-950"><tr>
|
||||
<th class="text-left font-medium px-3 py-2">{c_when}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_peer}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_conn}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_ip}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_action}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_note}</th>
|
||||
</tr></thead>
|
||||
<tbody class="divide-y divide-slate-800">"##,
|
||||
c_when = t(lang, "audit.col_when"),
|
||||
c_peer = t(lang, "audit.col_peer"),
|
||||
c_conn = t(lang, "audit.col_conn_session"),
|
||||
c_ip = t(lang, "audit.col_ip"),
|
||||
c_action = t(lang, "audit.col_action"),
|
||||
c_note = t(lang, "audit.col_note"),
|
||||
);
|
||||
for r in &rows {
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<tr>
|
||||
<td class="px-3 py-2 text-slate-500 text-xs">{when}</td>
|
||||
<td class="px-3 py-2 font-mono text-slate-200">{peer}</td>
|
||||
<td class="px-3 py-2 text-slate-400">{conn} / {sess}</td>
|
||||
<td class="px-3 py-2 text-slate-400 font-mono text-xs">{ip}</td>
|
||||
<td class="px-3 py-2 text-slate-300">{action}</td>
|
||||
<td class="px-3 py-2 text-slate-400">{note}</td>
|
||||
</tr>"##,
|
||||
when = html_escape(&fmt_unix(r.started_at)),
|
||||
peer = html_escape(&r.peer_id),
|
||||
conn = r.conn_id,
|
||||
sess = r.session_id,
|
||||
ip = html_escape(&r.ip),
|
||||
action = html_escape(&r.action),
|
||||
note = html_escape(&r.note)
|
||||
);
|
||||
}
|
||||
s.push_str("</tbody></table></div>");
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
async fn render_file(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiError> {
|
||||
let rows = state
|
||||
.db
|
||||
.audit_file_list(PAGE_SIZE)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
if rows.is_empty() {
|
||||
return Ok(empty_table(t(lang, "audit.no_file")));
|
||||
}
|
||||
let mut s = String::new();
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-xs uppercase text-slate-500 bg-slate-950"><tr>
|
||||
<th class="text-left font-medium px-3 py-2">{c_when}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_peer}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_dir}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_path}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_remote}</th>
|
||||
</tr></thead>
|
||||
<tbody class="divide-y divide-slate-800">"##,
|
||||
c_when = t(lang, "audit.col_when"),
|
||||
c_peer = t(lang, "audit.col_peer"),
|
||||
c_dir = t(lang, "audit.col_direction"),
|
||||
c_path = t(lang, "audit.col_path"),
|
||||
c_remote = t(lang, "audit.col_remote"),
|
||||
);
|
||||
for r in &rows {
|
||||
let dir = match r.direction {
|
||||
0 => t(lang, "audit.dir_to_remote"),
|
||||
1 => t(lang, "audit.dir_from_remote"),
|
||||
_ => "?",
|
||||
};
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<tr>
|
||||
<td class="px-3 py-2 text-slate-500 text-xs">{when}</td>
|
||||
<td class="px-3 py-2 font-mono text-slate-200">{peer}</td>
|
||||
<td class="px-3 py-2 text-slate-400">{dir}</td>
|
||||
<td class="px-3 py-2 text-slate-300 font-mono text-xs">{path}</td>
|
||||
<td class="px-3 py-2 text-slate-400 font-mono text-xs">{remote}</td>
|
||||
</tr>"##,
|
||||
when = html_escape(&fmt_unix(r.at)),
|
||||
peer = html_escape(&r.peer_id),
|
||||
dir = dir,
|
||||
path = html_escape(&r.path),
|
||||
remote = html_escape(&r.remote_peer)
|
||||
);
|
||||
}
|
||||
s.push_str("</tbody></table></div>");
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
async fn render_alarm(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiError> {
|
||||
let rows = state
|
||||
.db
|
||||
.audit_alarm_list(PAGE_SIZE)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
if rows.is_empty() {
|
||||
return Ok(empty_table(t(lang, "audit.no_alarm")));
|
||||
}
|
||||
let mut s = String::new();
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-xs uppercase text-slate-500 bg-slate-950"><tr>
|
||||
<th class="text-left font-medium px-3 py-2">{c_when}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_peer}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_type}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_info}</th>
|
||||
</tr></thead>
|
||||
<tbody class="divide-y divide-slate-800">"##,
|
||||
c_when = t(lang, "audit.col_when"),
|
||||
c_peer = t(lang, "audit.col_peer"),
|
||||
c_type = t(lang, "audit.col_type"),
|
||||
c_info = t(lang, "audit.col_info"),
|
||||
);
|
||||
for r in &rows {
|
||||
let typ = match r.typ {
|
||||
0 => "IpWhitelist",
|
||||
1 => "ExceedThirtyAttempts",
|
||||
2 => "SixAttemptsWithinOneMinute",
|
||||
6 => "ExceedIPv6PrefixAttempts",
|
||||
n => return Ok(format!("(unknown alarm type {})", n)),
|
||||
};
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<tr>
|
||||
<td class="px-3 py-2 text-slate-500 text-xs">{when}</td>
|
||||
<td class="px-3 py-2 font-mono text-slate-200">{peer}</td>
|
||||
<td class="px-3 py-2 text-amber-300">{typ}</td>
|
||||
<td class="px-3 py-2 text-slate-400 font-mono text-xs break-all">{info}</td>
|
||||
</tr>"##,
|
||||
when = html_escape(&fmt_unix(r.at)),
|
||||
peer = html_escape(&r.peer_id),
|
||||
typ = typ,
|
||||
info = html_escape(&r.info_json)
|
||||
);
|
||||
}
|
||||
s.push_str("</tbody></table></div>");
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
fn empty_table(msg: &str) -> String {
|
||||
format!(
|
||||
r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-6 text-center text-sm text-slate-500">{}</div>"##,
|
||||
html_escape(msg)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//! `/admin/connect/:peer_id` — serves the embedded web client SPA.
|
||||
//!
|
||||
//! Architecture: the SPA at web_client/src/main.ts opens WebSockets directly
|
||||
//! to the existing rendezvous (hbbs:21118) and relay (hbbr:21119) endpoints
|
||||
//! and speaks the same protocol the desktop client speaks. The role of this
|
||||
//! handler is to (a) gate access via the AuthedUser cookie middleware,
|
||||
//! (b) inject per-request config (rendezvous host, relay host, server pubkey,
|
||||
//! peer id, admin name) into the SPA, and (c) serve the bundled JS/CSS via
|
||||
//! `include_bytes!` so the binary is self-contained.
|
||||
//!
|
||||
//! Same `{{CUSTOM_CONFIG}}` template substitution pattern as deploy.rs.
|
||||
|
||||
use super::shared::{html_escape, require_admin};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use axum::extract::Path;
|
||||
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use serde_json::json;
|
||||
|
||||
const CONNECT_HTML: &str = include_str!("../../../../admin_ui/connect.html");
|
||||
const BUNDLE_JS: &[u8] = include_bytes!("../../../../web_client/dist/bundle.js");
|
||||
const BUNDLE_CSS: &[u8] = include_bytes!("../../../../web_client/dist/bundle.css");
|
||||
|
||||
/// `GET /admin/connect/:peer_id` — render the SPA shell with config injected.
|
||||
pub async fn index(
|
||||
admin: AuthedUser,
|
||||
headers: HeaderMap,
|
||||
Path(peer_id): Path<String>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
|
||||
// Derive default rendezvous/relay hosts from the request Host header so
|
||||
// operators don't need to configure separately for the common case where
|
||||
// hbbs and hbbr live on the same machine the browser is currently talking
|
||||
// to. Same approach as the deploy page.
|
||||
let host = headers
|
||||
.get(header::HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(host_only)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let pubkey = read_pubkey();
|
||||
let api_server = format!(
|
||||
"{}://{}",
|
||||
if is_https(&headers) { "https" } else { "http" },
|
||||
headers
|
||||
.get(header::HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
);
|
||||
|
||||
let cfg = json!({
|
||||
"api_server": api_server,
|
||||
"rendezvous_server": host,
|
||||
"relay_server": host,
|
||||
"key": pubkey,
|
||||
"peer_id": peer_id,
|
||||
"admin_name": admin.name.clone(),
|
||||
});
|
||||
|
||||
let cfg_str = cfg.to_string();
|
||||
// The placeholder is inside <script id="custom-config" type="application/json">
|
||||
// so the JSON content is parsed verbatim by JSON.parse — no further escaping
|
||||
// beyond ensuring no literal "</script>" appears (which a JSON serializer
|
||||
// never produces) and HTML-escaping any peer_id we substitute elsewhere.
|
||||
let html = CONNECT_HTML.replace("{{CUSTOM_CONFIG}}", &cfg_str);
|
||||
|
||||
// Defensive: if a peer_id ever ends up reflected outside the JSON tag
|
||||
// (the template doesn't currently do this, but future edits might),
|
||||
// having html_escape called as part of the page-build flow is a habit
|
||||
// worth preserving.
|
||||
let _ = html_escape;
|
||||
|
||||
Ok(Html(html))
|
||||
}
|
||||
|
||||
/// `GET /admin/connect/assets/bundle.js` — serve the SPA bundle.
|
||||
pub async fn bundle_js() -> Response {
|
||||
asset_response(BUNDLE_JS, "application/javascript; charset=utf-8")
|
||||
}
|
||||
|
||||
/// `GET /admin/connect/assets/bundle.css` — serve the SPA stylesheet.
|
||||
pub async fn bundle_css() -> Response {
|
||||
asset_response(BUNDLE_CSS, "text/css; charset=utf-8")
|
||||
}
|
||||
|
||||
fn asset_response(body: &'static [u8], content_type: &'static str) -> Response {
|
||||
let mut resp = (StatusCode::OK, body).into_response();
|
||||
let headers = resp.headers_mut();
|
||||
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
|
||||
// Bundles are content-addressed by SHA in name? Not yet — until we add
|
||||
// hashed filenames, force fresh fetches so admin upgrades pick up new JS.
|
||||
headers.insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("no-cache"),
|
||||
);
|
||||
resp
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
/// Read the server's Ed25519 public key from `id_ed25519.pub` in CWD —
|
||||
/// same path `common::gen_sk` writes it to and what the deploy page reads.
|
||||
fn read_pubkey() -> String {
|
||||
std::fs::read_to_string("id_ed25519.pub")
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Strip `:port` (and IPv6 brackets) from a Host-header value. Borrowed
|
||||
/// from the deploy page; kept inline here rather than promoting to shared
|
||||
/// to avoid a cross-module dep on a one-liner.
|
||||
fn host_only(s: &str) -> &str {
|
||||
if let Some(rest) = s.strip_prefix('[') {
|
||||
if let Some(end) = rest.find(']') {
|
||||
return &rest[..end];
|
||||
}
|
||||
}
|
||||
s.rsplit_once(':').map(|(h, _)| h).unwrap_or(s)
|
||||
}
|
||||
|
||||
/// Heuristic: were we reached via HTTPS? The presence of any
|
||||
/// `X-Forwarded-Proto: https` from a reverse proxy is the standard signal.
|
||||
/// Falls back to false; the SPA only uses this to construct the displayed
|
||||
/// API URL, the actual WebSockets pick `ws://` vs `wss://` based on the
|
||||
/// page's own protocol.
|
||||
fn is_https(headers: &HeaderMap) -> bool {
|
||||
headers
|
||||
.get("x-forwarded-proto")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.eq_ignore_ascii_case("https"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
//! Deploy page — generates a `CustomServer` blob the Windows / macOS / Linux
|
||||
//! client accepts via `rustdesk --config <blob>`, plus the equivalent
|
||||
//! rename-the-installer filename. The blob format is documented at
|
||||
//! `<rustdesk>/src/custom_server.rs`: JSON → URL-safe-no-pad base64 →
|
||||
//! reverse-the-string. The client tries the unsigned JSON path before
|
||||
//! signature verification, so we don't need the Pro private key.
|
||||
|
||||
use super::shared::{html_escape, require_admin};
|
||||
use crate::api::admin::i18n::{t, tf1, Lang};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use axum::extract::Form;
|
||||
use axum::http::HeaderMap;
|
||||
use axum::response::Html;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
pub async fn index(
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
let pubkey = read_pubkey();
|
||||
// Best-effort prefill: the Host the admin's browser is currently
|
||||
// talking to is almost always the same machine running hbbs, so it's
|
||||
// the right default for the rendezvous-host field. Reverse proxies
|
||||
// forward the original Host through unless explicitly stripped, so
|
||||
// this works behind nginx/Caddy/Traefik too. Operator can edit if
|
||||
// hbbr lives on a different host.
|
||||
let host_default = headers
|
||||
.get(axum::http::header::HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(host_only)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let (api_default, relay_default) = if host_default.is_empty() {
|
||||
(String::new(), String::new())
|
||||
} else {
|
||||
(format!("https://{}", host_default), host_default.clone())
|
||||
};
|
||||
Ok(Html(render_form(
|
||||
lang,
|
||||
&pubkey,
|
||||
&host_default,
|
||||
&api_default,
|
||||
&relay_default,
|
||||
"",
|
||||
None,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Strip an optional `:port` (and IPv6 brackets) from a Host-header value.
|
||||
/// "rustdesk.example.com:21114" -> "rustdesk.example.com"
|
||||
/// "[::1]:21114" -> "::1"
|
||||
/// "10.196.83.110" -> "10.196.83.110"
|
||||
fn host_only(s: &str) -> &str {
|
||||
if let Some(rest) = s.strip_prefix('[') {
|
||||
if let Some(end) = rest.find(']') {
|
||||
return &rest[..end];
|
||||
}
|
||||
}
|
||||
s.rsplit_once(':').map(|(h, _)| h).unwrap_or(s)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeployForm {
|
||||
#[serde(default)]
|
||||
pub host: String,
|
||||
#[serde(default)]
|
||||
pub api: String,
|
||||
#[serde(default)]
|
||||
pub relay: String,
|
||||
#[serde(default)]
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
pub async fn generate(
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Form(f): Form<DeployForm>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
if f.host.trim().is_empty() {
|
||||
return Ok(Html(render_form(
|
||||
lang,
|
||||
&f.key,
|
||||
&f.host,
|
||||
&f.api,
|
||||
&f.relay,
|
||||
"",
|
||||
Some(("error", t(lang, "deploy.host_required"))),
|
||||
)));
|
||||
}
|
||||
let blob = encode_blob(&f.host, &f.key, &f.api, &f.relay);
|
||||
let result = render_result(lang, &f.host, &f.key, &f.api, &f.relay, &blob);
|
||||
Ok(Html(render_form(
|
||||
lang,
|
||||
&f.key,
|
||||
&f.host,
|
||||
&f.api,
|
||||
&f.relay,
|
||||
&result,
|
||||
None,
|
||||
)))
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
/// Best-effort read of the server's public key from `id_ed25519.pub` in CWD —
|
||||
/// the same path `common::gen_sk` writes it to. If the file is missing
|
||||
/// (operator passed `--key` explicitly, or the binary runs from a directory
|
||||
/// they can't read), the field is left blank for them to paste.
|
||||
fn read_pubkey() -> String {
|
||||
std::fs::read_to_string("id_ed25519.pub")
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Encode a `CustomServer` payload the way the client's
|
||||
/// `get_custom_server_from_config_string` expects: JSON → URL-safe-no-pad
|
||||
/// base64 → reverse the resulting string. The client reverses it back, base64
|
||||
/// decodes, then JSON parses.
|
||||
fn encode_blob(host: &str, key: &str, api: &str, relay: &str) -> String {
|
||||
let payload = json!({
|
||||
"host": host,
|
||||
"key": key,
|
||||
"api": api,
|
||||
"relay": relay,
|
||||
});
|
||||
let b64 = base64::encode_config(payload.to_string().as_bytes(), base64::URL_SAFE_NO_PAD);
|
||||
b64.chars().rev().collect()
|
||||
}
|
||||
|
||||
fn render_form(
|
||||
lang: Lang,
|
||||
key: &str,
|
||||
host: &str,
|
||||
api: &str,
|
||||
relay: &str,
|
||||
result_html: &str,
|
||||
notice: Option<(&str, &str)>,
|
||||
) -> String {
|
||||
let notice_html = match notice {
|
||||
Some((kind, msg)) => super::shared::notice_html(kind, msg),
|
||||
None => String::new(),
|
||||
};
|
||||
format!(
|
||||
r##"<div class="space-y-6">
|
||||
<header>
|
||||
<h2 class="text-lg font-semibold">{heading}</h2>
|
||||
<p class="text-xs text-slate-500 mt-1">{intro}</p>
|
||||
</header>
|
||||
|
||||
{notice_html}
|
||||
|
||||
<form
|
||||
class="space-y-3 bg-slate-900 border border-slate-800 rounded-lg p-4"
|
||||
hx-post="/admin/pages/deploy/generate"
|
||||
hx-target="#main"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="host">{host_label}</label>
|
||||
<input id="host" name="host" type="text" required value="{host}"
|
||||
placeholder="rustdesk.example.com or 203.0.113.10"
|
||||
oninput="
|
||||
const h = this.value.trim();
|
||||
const api = document.getElementById('api');
|
||||
const relay = document.getElementById('relay');
|
||||
if (api.dataset.derived !== '0') api.value = h ? 'https://' + h : '';
|
||||
if (relay.dataset.derived !== '0') relay.value = h;
|
||||
api.placeholder = h ? 'https://' + h : 'https://rustdesk.example.com';
|
||||
"
|
||||
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500" />
|
||||
<p class="text-xs text-slate-500 mt-1">{host_hint}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="api">{api_label}</label>
|
||||
<input id="api" name="api" type="text" value="{api}"
|
||||
placeholder="https://rustdesk.example.com"
|
||||
oninput="this.dataset.derived = '0';"
|
||||
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500" />
|
||||
<p class="text-xs text-slate-500 mt-1">{api_hint}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="relay">{relay_label}</label>
|
||||
<input id="relay" name="relay" type="text" value="{relay}"
|
||||
placeholder="rustdesk.example.com"
|
||||
oninput="this.dataset.derived = '0';"
|
||||
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500" />
|
||||
<p class="text-xs text-slate-500 mt-1">{relay_hint}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="key">{key_label}</label>
|
||||
<textarea id="key" name="key" rows="2"
|
||||
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-xs font-mono focus:outline-none focus:border-sky-500">{key}</textarea>
|
||||
<p class="text-xs text-slate-500 mt-1">{key_hint}</p>
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
class="bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium rounded px-4 py-2 transition">
|
||||
{generate}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{result_html}
|
||||
</div>"##,
|
||||
heading = t(lang, "deploy.heading"),
|
||||
intro = t(lang, "deploy.intro"),
|
||||
host_label = t(lang, "deploy.host_label"),
|
||||
host_hint = t(lang, "deploy.host_hint"),
|
||||
api_label = t(lang, "deploy.api_label"),
|
||||
api_hint = t(lang, "deploy.api_hint"),
|
||||
relay_label = t(lang, "deploy.relay_label"),
|
||||
relay_hint = t(lang, "deploy.relay_hint"),
|
||||
key_label = t(lang, "deploy.key_label"),
|
||||
key_hint = t(lang, "deploy.key_hint"),
|
||||
generate = t(lang, "deploy.generate"),
|
||||
host = html_escape(host),
|
||||
api = html_escape(api),
|
||||
relay = html_escape(relay),
|
||||
key = html_escape(key),
|
||||
notice_html = notice_html,
|
||||
result_html = result_html,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_result(lang: Lang, host: &str, key: &str, api: &str, relay: &str, blob: &str) -> String {
|
||||
// Build the rename-the-installer alternative. Windows filenames disallow
|
||||
// `:` and `/`, which the API URL is full of (`http://host:21114`). The
|
||||
// client falls back to `http://<host>:21114` when `api` is empty
|
||||
// (rustdesk/src/common.rs:get_api_server_), so we can omit the API field
|
||||
// from the filename whenever it matches that default. If the operator
|
||||
// supplied a non-default API URL we still build a "renamed" string for
|
||||
// reference but mark it as unusable on Windows and steer them to the
|
||||
// --config path.
|
||||
let default_api = format!("http://{}:21114", host);
|
||||
let api_is_default = api.is_empty() || api == default_api;
|
||||
let unsafe_chars = str_has_filename_unsafe_chars(api) && !api_is_default;
|
||||
|
||||
let mut renamed = format!("rustdesk-host={}", host);
|
||||
if !key.is_empty() {
|
||||
renamed.push_str(&format!(",key={}", key));
|
||||
}
|
||||
if !api_is_default && !unsafe_chars {
|
||||
renamed.push_str(&format!(",api={}", api));
|
||||
}
|
||||
if !relay.is_empty() && !str_has_filename_unsafe_chars(relay) {
|
||||
renamed.push_str(&format!(",relay={}", relay));
|
||||
}
|
||||
renamed.push_str(".exe");
|
||||
|
||||
let renamed_note = if unsafe_chars {
|
||||
r##"<p class="text-xs text-rose-300 mt-1">⚠ Your API URL contains <code>:</code> or <code>/</code>, which Windows forbids in filenames. The renamed-installer approach cannot carry it — use approach A above instead.</p>"##.to_string()
|
||||
} else if !api_is_default {
|
||||
format!(
|
||||
r##"<p class="text-xs text-amber-300 mt-1">⚠ Your API URL ({api_disp}) is not the default <code>http://<host>:21114</code>. The client will auto-derive the default from the rendezvous host on first launch, so this filename will deploy with the wrong API URL. Use approach A instead.</p>"##,
|
||||
api_disp = html_escape(api)
|
||||
)
|
||||
} else if !api.is_empty() {
|
||||
r##"<p class="text-xs text-slate-500 mt-1">API URL omitted from filename (Windows can't store <code>:</code> / <code>/</code>); the client auto-derives <code>http://<host>:21114</code> from the rendezvous host.</p>"##.to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let licensed = format!("rustdesk-licensed-{}", blob);
|
||||
let cmd_win = format!(
|
||||
r#""C:\Program Files\RustDesk\rustdesk.exe" --config {licensed}"#,
|
||||
licensed = licensed
|
||||
);
|
||||
let cmd_unix = format!("rustdesk --config {}", licensed);
|
||||
let cmd_hello = format!("hello-agent.exe --install --config {}", blob);
|
||||
|
||||
format!(
|
||||
r##"<section class="space-y-4 bg-slate-900 border border-slate-800 rounded-lg p-4">
|
||||
<header>
|
||||
<h3 class="text-sm font-semibold text-slate-200">{artifact_heading}</h3>
|
||||
<p class="text-xs text-slate-500 mt-1">{artifact_intro}</p>
|
||||
</header>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1">{a_label}</label>
|
||||
<pre class="text-xs bg-slate-950 border border-slate-800 rounded p-2 overflow-x-auto select-all whitespace-pre-wrap break-all">{cmd_win}</pre>
|
||||
<p class="text-xs text-slate-500 mt-1">{a_hint}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1">{b_label}</label>
|
||||
<pre class="text-xs bg-slate-950 border border-slate-800 rounded p-2 overflow-x-auto select-all whitespace-pre-wrap break-all">{renamed}</pre>
|
||||
<p class="text-xs text-slate-500 mt-1">{b_hint}</p>
|
||||
{renamed_note}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1">{c_label}</label>
|
||||
<pre class="text-xs bg-slate-950 border border-slate-800 rounded p-2 overflow-x-auto select-all whitespace-pre-wrap break-all">{cmd_hello}</pre>
|
||||
<p class="text-xs text-slate-500 mt-1">{c_hint}</p>
|
||||
</div>
|
||||
|
||||
<details class="text-xs text-slate-400">
|
||||
<summary class="cursor-pointer text-slate-300 select-none">{raw_blob}</summary>
|
||||
<pre class="mt-2 bg-slate-950 border border-slate-800 rounded p-2 overflow-x-auto select-all whitespace-pre-wrap break-all">{blob}</pre>
|
||||
</details>
|
||||
</section>"##,
|
||||
artifact_heading = t(lang, "deploy.artifact_heading"),
|
||||
artifact_intro = t(lang, "deploy.artifact_intro"),
|
||||
a_label = t(lang, "deploy.cmd_a_label"),
|
||||
a_hint = tf1(lang, "deploy.cmd_a_hint", &html_escape(&cmd_unix)),
|
||||
b_label = t(lang, "deploy.cmd_b_label"),
|
||||
b_hint = t(lang, "deploy.cmd_b_hint"),
|
||||
c_label = t(lang, "deploy.cmd_c_label"),
|
||||
c_hint = t(lang, "deploy.cmd_c_hint"),
|
||||
raw_blob = t(lang, "deploy.raw_blob"),
|
||||
cmd_win = html_escape(&cmd_win),
|
||||
cmd_hello = html_escape(&cmd_hello),
|
||||
renamed = html_escape(&renamed),
|
||||
renamed_note = renamed_note,
|
||||
blob = html_escape(blob),
|
||||
)
|
||||
}
|
||||
|
||||
/// Rough check for characters Windows disallows in filenames. We don't try
|
||||
/// to be exhaustive (NUL, control chars etc. won't realistically appear in a
|
||||
/// hostname or URL), just the ones a typical URL/relay value will trip on.
|
||||
fn str_has_filename_unsafe_chars(s: &str) -> bool {
|
||||
s.chars()
|
||||
.any(|c| matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|'))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,482 @@
|
||||
//! Per-device PowerShell remote-exec page.
|
||||
//!
|
||||
//! Layout:
|
||||
//! GET /admin/pages/devices/:peer_id/exec — full page
|
||||
//! POST /admin/pages/devices/:peer_id/exec — dispatch
|
||||
//! GET /admin/pages/devices/:peer_id/exec/:cmd_id/poll — single-row fragment, auto-refreshes
|
||||
//!
|
||||
//! Gates:
|
||||
//! - AuthedUser.is_admin
|
||||
//! - peer.managed = 1 (no exec on legacy/unsigned peers)
|
||||
//! - strategy.config_options."enable-remote-exec" = "Y"
|
||||
//! - no other in-flight exec for this peer
|
||||
|
||||
use crate::api::admin::i18n::{t, tf1, tf2, Lang};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use crate::api::state::AppState;
|
||||
use crate::api::strategy;
|
||||
use crate::database::ExecRow;
|
||||
use axum::extract::{Extension, Form, Path};
|
||||
use axum::response::Html;
|
||||
use serde::Deserialize;
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::Arc;
|
||||
|
||||
const HISTORY_LIMIT: i64 = 20;
|
||||
const MAX_SCRIPT_BYTES: usize = 32 * 1024;
|
||||
/// Wall-clock cap, mirrored from heartbeat.rs::EXEC_MAX_SECS so the dispatch
|
||||
/// confirm dialog can surface the same number. Kept duplicated rather than
|
||||
/// shared as a pub const because the two values legitimately differ in
|
||||
/// future (per-strategy override is a likely next step).
|
||||
const UI_MAX_SECS: u64 = 300;
|
||||
const UI_MAX_BYTES: u64 = 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DispatchForm {
|
||||
pub script: String,
|
||||
}
|
||||
|
||||
/// Main page (full content for `#main`). Renders the gate banner, the
|
||||
/// script form (only when allowed), and the recent-history table.
|
||||
pub async fn index(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path(peer_id): Path<String>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
Ok(Html(render_page(&state, lang, &peer_id, None).await?))
|
||||
}
|
||||
|
||||
/// Dispatch handler. Re-checks all gates server-side (the UI also gates
|
||||
/// the form, but the form is just HTML — never trust the client). On
|
||||
/// success: insert into exec_history with status='queued', return the
|
||||
/// page with a success notice; the next heartbeat will flip it to
|
||||
/// 'running' and the history row picks up auto-refresh.
|
||||
pub async fn dispatch(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path(peer_id): Path<String>,
|
||||
Form(form): Form<DispatchForm>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
let script = form.script.trim_end_matches(['\r', '\n']).to_string();
|
||||
|
||||
// Validation runs BEFORE the gate checks so an empty script doesn't
|
||||
// get a confusing "managed required" error. Order: shape → policy.
|
||||
if script.trim().is_empty() {
|
||||
return Ok(Html(
|
||||
render_page(&state, lang, &peer_id, Some(("error", t(lang, "exec.error_empty").to_string()))).await?,
|
||||
));
|
||||
}
|
||||
if script.len() > MAX_SCRIPT_BYTES {
|
||||
return Ok(Html(
|
||||
render_page(
|
||||
&state,
|
||||
lang,
|
||||
&peer_id,
|
||||
Some((
|
||||
"error",
|
||||
tf2(
|
||||
lang,
|
||||
"exec.error_too_large",
|
||||
&script.len().to_string(),
|
||||
&MAX_SCRIPT_BYTES.to_string(),
|
||||
),
|
||||
)),
|
||||
)
|
||||
.await?,
|
||||
));
|
||||
}
|
||||
|
||||
let gate = check_gate(&state, &peer_id).await?;
|
||||
if !gate.allowed {
|
||||
return Ok(Html(
|
||||
render_page(
|
||||
&state,
|
||||
lang,
|
||||
&peer_id,
|
||||
Some(("error", gate_reason_message(lang, &gate))),
|
||||
)
|
||||
.await?,
|
||||
));
|
||||
}
|
||||
if gate.in_flight > 0 {
|
||||
return Ok(Html(
|
||||
render_page(
|
||||
&state,
|
||||
lang,
|
||||
&peer_id,
|
||||
Some(("error", t(lang, "exec.error_in_flight").to_string())),
|
||||
)
|
||||
.await?,
|
||||
));
|
||||
}
|
||||
|
||||
let cmd_id = uuid::Uuid::new_v4().to_string();
|
||||
state
|
||||
.db
|
||||
.exec_create(&cmd_id, &peer_id, admin.user_id, &script)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
hbb_common::log::info!(
|
||||
"admin {} queued exec cmd_id={} for peer {} ({} bytes)",
|
||||
admin.name,
|
||||
cmd_id,
|
||||
peer_id,
|
||||
script.len()
|
||||
);
|
||||
|
||||
Ok(Html(
|
||||
render_page(
|
||||
&state,
|
||||
lang,
|
||||
&peer_id,
|
||||
Some(("ok", tf1(lang, "exec.queued", &cmd_id))),
|
||||
)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
/// Single-row poll fragment. Returned by an HTMX `hx-get` whose
|
||||
/// `hx-trigger` keeps firing until the row reaches a terminal state.
|
||||
pub async fn poll(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path((peer_id, cmd_id)): Path<(String, String)>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
let row = state
|
||||
.db
|
||||
.exec_get_by_cmd_id(&cmd_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let row = match row {
|
||||
Some(r) if r.peer_id == peer_id => r,
|
||||
_ => {
|
||||
return Ok(Html(format!(
|
||||
r##"<tr><td colspan="5" class="px-3 py-2 text-rose-300 text-xs">{}</td></tr>"##,
|
||||
t(lang, "exec.not_found"),
|
||||
)));
|
||||
}
|
||||
};
|
||||
Ok(Html(render_history_row(lang, &row)))
|
||||
}
|
||||
|
||||
// ───────────────────────── helpers ─────────────────────────
|
||||
|
||||
async fn check_gate(state: &Arc<AppState>, peer_id: &str) -> Result<GateCheck, ApiError> {
|
||||
let auth = state
|
||||
.db
|
||||
.peer_get_auth(peer_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let managed = matches!(auth, Some((_, true)));
|
||||
let strategy_allows = strategy::allows_remote_exec(state, peer_id).await;
|
||||
let in_flight = state
|
||||
.db
|
||||
.exec_in_flight_count(peer_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(GateCheck {
|
||||
allowed: managed && strategy_allows,
|
||||
managed,
|
||||
strategy_allows,
|
||||
in_flight,
|
||||
})
|
||||
}
|
||||
|
||||
struct GateCheck {
|
||||
allowed: bool,
|
||||
managed: bool,
|
||||
strategy_allows: bool,
|
||||
in_flight: i64,
|
||||
}
|
||||
|
||||
fn gate_reason_message(lang: Lang, g: &GateCheck) -> String {
|
||||
if !g.managed {
|
||||
return t(lang, "exec.reason_not_managed").to_string();
|
||||
}
|
||||
if !g.strategy_allows {
|
||||
return t(lang, "exec.reason_strategy").to_string();
|
||||
}
|
||||
t(lang, "exec.reason_unknown").to_string()
|
||||
}
|
||||
|
||||
async fn render_page(
|
||||
state: &Arc<AppState>,
|
||||
lang: Lang,
|
||||
peer_id: &str,
|
||||
notice: Option<(&'static str, String)>,
|
||||
) -> Result<String, ApiError> {
|
||||
let gate = check_gate(state, peer_id).await?;
|
||||
let history = state
|
||||
.db
|
||||
.exec_list_for_peer(peer_id, HISTORY_LIMIT)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
|
||||
let mut out = String::new();
|
||||
let _ = write!(
|
||||
&mut out,
|
||||
r##"<div class="space-y-4">
|
||||
<header class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">{heading} <code class="font-mono text-sky-300">{id}</code></h2>
|
||||
<button class="text-xs text-sky-300 hover:text-sky-200"
|
||||
hx-get="/admin/pages/devices/list-fragment"
|
||||
hx-target="#devices-region" hx-swap="innerHTML"
|
||||
hx-push-url="#devices">{back}</button>
|
||||
</header>"##,
|
||||
heading = t(lang, "exec.heading"),
|
||||
id = html_escape(peer_id),
|
||||
back = t(lang, "devices.back"),
|
||||
);
|
||||
|
||||
if let Some((kind, msg)) = notice {
|
||||
let _ = write!(&mut out, "{}", notice_html(kind, &msg));
|
||||
}
|
||||
|
||||
// Gate banner
|
||||
let _ = write!(&mut out, "{}", render_gate_banner(lang, &gate));
|
||||
|
||||
// Script form — only rendered when gate is open
|
||||
if gate.allowed {
|
||||
let _ = write!(
|
||||
&mut out,
|
||||
r##"<form class="space-y-2" hx-post="/admin/pages/devices/{id}/exec"
|
||||
hx-target="#devices-region" hx-swap="innerHTML"
|
||||
hx-confirm="{confirm}">
|
||||
<label class="block text-xs text-slate-400">{label}</label>
|
||||
<textarea name="script" rows="8" required
|
||||
class="w-full font-mono text-sm rounded border border-slate-700 bg-slate-950 text-slate-200 p-2"
|
||||
placeholder="Get-Service hello-agent | Select-Object Status, Name"></textarea>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs text-slate-500">{caps}</p>
|
||||
<button type="submit" class="rounded bg-sky-700 hover:bg-sky-600 text-sky-100 text-xs px-3 py-1.5">{run}</button>
|
||||
</div>
|
||||
</form>"##,
|
||||
id = html_escape(peer_id),
|
||||
label = t(lang, "exec.script_label"),
|
||||
confirm = html_escape(&tf1(lang, "exec.confirm_dispatch", peer_id)),
|
||||
caps = html_escape(&tf2(
|
||||
lang,
|
||||
"exec.caps_note",
|
||||
&UI_MAX_SECS.to_string(),
|
||||
&(UI_MAX_BYTES / 1024 / 1024).to_string(),
|
||||
)),
|
||||
run = t(lang, "exec.run"),
|
||||
);
|
||||
}
|
||||
|
||||
// History table
|
||||
let _ = write!(
|
||||
&mut out,
|
||||
r##"<section>
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-2">{hist}</h3>
|
||||
<div class="rounded-md border border-slate-800 bg-slate-900">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-xs uppercase text-slate-500 bg-slate-950">
|
||||
<tr>
|
||||
<th class="text-left font-medium px-3 py-2">{c_when}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_who}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_status}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_script}</th>
|
||||
<th class="text-left font-medium px-3 py-2">{c_output}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800">"##,
|
||||
hist = t(lang, "exec.history"),
|
||||
c_when = t(lang, "exec.col_when"),
|
||||
c_who = t(lang, "exec.col_who"),
|
||||
c_status = t(lang, "exec.col_status"),
|
||||
c_script = t(lang, "exec.col_script"),
|
||||
c_output = t(lang, "exec.col_output"),
|
||||
);
|
||||
if history.is_empty() {
|
||||
let _ = write!(
|
||||
&mut out,
|
||||
r##"<tr><td colspan="5" class="px-3 py-3 text-center text-xs text-slate-500">{}</td></tr>"##,
|
||||
t(lang, "exec.no_history"),
|
||||
);
|
||||
} else {
|
||||
for row in &history {
|
||||
out.push_str(&render_history_row(lang, row));
|
||||
}
|
||||
}
|
||||
out.push_str(r##" </tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>"##);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn render_gate_banner(lang: Lang, g: &GateCheck) -> String {
|
||||
let (kind, msg) = if g.allowed {
|
||||
("ok", t(lang, "exec.gate_open").to_string())
|
||||
} else if !g.managed {
|
||||
("error", t(lang, "exec.reason_not_managed").to_string())
|
||||
} else if !g.strategy_allows {
|
||||
("error", t(lang, "exec.reason_strategy").to_string())
|
||||
} else {
|
||||
("error", t(lang, "exec.reason_unknown").to_string())
|
||||
};
|
||||
notice_html(kind, &msg)
|
||||
}
|
||||
|
||||
fn render_history_row(lang: Lang, r: &ExecRow) -> String {
|
||||
// Auto-refresh while the row is non-terminal so the operator sees
|
||||
// running → finished without manual reload. HTMX `hx-trigger` with
|
||||
// `every 1s` fires until the SERVER emits a row missing the trigger
|
||||
// (i.e. the row reached finished/timed_out/errored).
|
||||
let row_id = format!("exec-row-{}", html_escape(&r.cmd_id));
|
||||
let polling_attrs = if matches!(r.status.as_str(), "queued" | "running") {
|
||||
format!(
|
||||
r##"hx-get="/admin/pages/devices/{peer}/exec/{cmd}/poll" hx-trigger="load delay:1s" hx-target="this" hx-swap="outerHTML""##,
|
||||
peer = html_escape(&r.peer_id),
|
||||
cmd = html_escape(&r.cmd_id),
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let status_cell = render_status_badge(lang, r);
|
||||
let when = fmt_unix(r.issued_at);
|
||||
let script_preview = preview(&r.script, 80);
|
||||
let output_block = render_output_block(lang, r);
|
||||
|
||||
format!(
|
||||
r##"<tr id="{row_id}" {polling}>
|
||||
<td class="px-3 py-2 text-xs text-slate-500 whitespace-nowrap">{when}</td>
|
||||
<td class="px-3 py-2 text-xs text-slate-400">#{user}</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap">{status}</td>
|
||||
<td class="px-3 py-2"><code class="font-mono text-xs text-slate-300">{script}</code></td>
|
||||
<td class="px-3 py-2">{output}</td>
|
||||
</tr>"##,
|
||||
row_id = row_id,
|
||||
polling = polling_attrs,
|
||||
when = html_escape(&when),
|
||||
user = r.issued_by_user_id,
|
||||
status = status_cell,
|
||||
script = html_escape(&script_preview),
|
||||
output = output_block,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_status_badge(lang: Lang, r: &ExecRow) -> String {
|
||||
let (border, bg, text_color, label) = match r.status.as_str() {
|
||||
"queued" => ("slate-700", "slate-800/40", "slate-300", t(lang, "exec.status_queued")),
|
||||
"running" => ("amber-700/50", "amber-900/30", "amber-300", t(lang, "exec.status_running")),
|
||||
"finished" => {
|
||||
if r.exit_code == Some(0) {
|
||||
("emerald-700/50", "emerald-900/30", "emerald-300", t(lang, "exec.status_finished_ok"))
|
||||
} else {
|
||||
("rose-700/50", "rose-900/30", "rose-300", t(lang, "exec.status_finished_err"))
|
||||
}
|
||||
}
|
||||
"timed_out" => ("rose-700/50", "rose-900/30", "rose-300", t(lang, "exec.status_timed_out")),
|
||||
_ => ("rose-700/50", "rose-900/30", "rose-300", t(lang, "exec.status_errored")),
|
||||
};
|
||||
let exit_suffix = match (r.status.as_str(), r.exit_code) {
|
||||
("finished", Some(c)) => format!(" (exit {c})"),
|
||||
_ => String::new(),
|
||||
};
|
||||
format!(
|
||||
r##"<span class="inline-flex items-center gap-1 rounded border border-{b} bg-{bg} px-2 py-0.5 text-xs text-{t}">{label}{exit}</span>"##,
|
||||
b = border,
|
||||
bg = bg,
|
||||
t = text_color,
|
||||
label = html_escape(label),
|
||||
exit = exit_suffix,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_output_block(lang: Lang, r: &ExecRow) -> String {
|
||||
if matches!(r.status.as_str(), "queued" | "running") {
|
||||
return format!(
|
||||
r##"<span class="text-xs text-slate-500">{}</span>"##,
|
||||
t(lang, "exec.output_pending"),
|
||||
);
|
||||
}
|
||||
let mut s = String::new();
|
||||
if !r.stdout.is_empty() {
|
||||
let _ = write!(
|
||||
&mut s,
|
||||
r##"<details class="text-xs"><summary class="cursor-pointer text-slate-400 hover:text-slate-200">stdout ({n} bytes)</summary>
|
||||
<pre class="mt-1 max-h-80 overflow-auto rounded bg-slate-950 border border-slate-800 p-2 font-mono text-slate-300 whitespace-pre-wrap">{out}</pre>
|
||||
</details>"##,
|
||||
n = r.stdout.len(),
|
||||
out = html_escape(&r.stdout),
|
||||
);
|
||||
}
|
||||
if !r.stderr.is_empty() {
|
||||
let _ = write!(
|
||||
&mut s,
|
||||
r##"<details class="text-xs mt-1"><summary class="cursor-pointer text-rose-400 hover:text-rose-300">stderr ({n} bytes)</summary>
|
||||
<pre class="mt-1 max-h-80 overflow-auto rounded bg-slate-950 border border-slate-800 p-2 font-mono text-rose-300 whitespace-pre-wrap">{out}</pre>
|
||||
</details>"##,
|
||||
n = r.stderr.len(),
|
||||
out = html_escape(&r.stderr),
|
||||
);
|
||||
}
|
||||
if r.truncated {
|
||||
let _ = write!(
|
||||
&mut s,
|
||||
r##"<p class="text-xs text-amber-300 mt-1">{}</p>"##,
|
||||
t(lang, "exec.output_truncated"),
|
||||
);
|
||||
}
|
||||
if s.is_empty() {
|
||||
s = format!(r##"<span class="text-xs text-slate-500">{}</span>"##, t(lang, "exec.output_empty"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn fmt_unix(ts: i64) -> String {
|
||||
use chrono::TimeZone;
|
||||
chrono::Utc
|
||||
.timestamp_opt(ts, 0)
|
||||
.single()
|
||||
.map(|d| d.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||
.unwrap_or_else(|| "—".to_string())
|
||||
}
|
||||
|
||||
fn preview(s: &str, max: usize) -> String {
|
||||
let first_line = s.lines().next().unwrap_or("");
|
||||
if first_line.len() <= max {
|
||||
first_line.to_string()
|
||||
} else {
|
||||
format!("{}…", &first_line[..max])
|
||||
}
|
||||
}
|
||||
|
||||
fn notice_html(kind: &str, msg: &str) -> String {
|
||||
let (border, bg, text) = match kind {
|
||||
"ok" => ("emerald-700/50", "emerald-900/30", "emerald-300"),
|
||||
_ => ("rose-700/50", "rose-900/30", "rose-300"),
|
||||
};
|
||||
format!(
|
||||
r##"<div class="rounded border border-{border} bg-{bg} p-3 text-sm text-{text}">{msg}</div>"##,
|
||||
border = border,
|
||||
bg = bg,
|
||||
text = text,
|
||||
msg = html_escape(msg),
|
||||
)
|
||||
}
|
||||
|
||||
fn html_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
fn require_admin(u: &AuthedUser) -> Result<(), ApiError> {
|
||||
if u.is_admin {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApiError::Forbidden("admin required".into()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
//! Device-groups page — list, create, delete, add/remove member.
|
||||
//! Strategies and AB shares hang off device-group membership, so this is
|
||||
//! the canonical place to manage who can see whose devices.
|
||||
|
||||
use super::shared::{html_escape, notice_html, require_admin};
|
||||
use crate::api::admin::i18n::{t, tf1, Lang};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use crate::api::state::AppState;
|
||||
use axum::extract::{Extension, Form, Path};
|
||||
use axum::response::Html;
|
||||
use serde::Deserialize;
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub async fn index(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
Ok(Html(render_full(&state, lang).await?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateForm {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Form(form): Form<CreateForm>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
if form.name.trim().is_empty() {
|
||||
return notice_then(&state, lang, "error", t(lang, "groups.name_required")).await;
|
||||
}
|
||||
state
|
||||
.db
|
||||
.device_group_create(form.name.trim())
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
notice_then(&state, lang, "ok", &tf1(lang, "groups.created", &form.name)).await
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
let ok = state
|
||||
.db
|
||||
.device_group_delete(id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
notice_then(
|
||||
&state,
|
||||
lang,
|
||||
if ok { "ok" } else { "error" },
|
||||
if ok { t(lang, "groups.deleted") } else { t(lang, "common.already_gone") },
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MemberForm {
|
||||
pub user_id: i64,
|
||||
}
|
||||
|
||||
pub async fn add_member(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path(id): Path<i64>,
|
||||
Form(form): Form<MemberForm>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
state
|
||||
.db
|
||||
.device_group_add_member(id, form.user_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(Html(render_full(&state, lang).await?))
|
||||
}
|
||||
|
||||
pub async fn remove_member(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path((id, user_id)): Path<(i64, i64)>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
state
|
||||
.db
|
||||
.device_group_remove_member(id, user_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(Html(render_full(&state, lang).await?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PeerForm {
|
||||
pub peer_id: String,
|
||||
}
|
||||
|
||||
pub async fn add_peer(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path(id): Path<i64>,
|
||||
Form(form): Form<PeerForm>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
let peer_id = form.peer_id.trim();
|
||||
if peer_id.is_empty() {
|
||||
return notice_then(&state, lang, "error", t(lang, "groups.peer_id_required")).await;
|
||||
}
|
||||
let exists = state
|
||||
.db
|
||||
.peer_exists(peer_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
if !exists {
|
||||
return notice_then(
|
||||
&state,
|
||||
lang,
|
||||
"error",
|
||||
&tf1(lang, "groups.no_device_yet", peer_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
state
|
||||
.db
|
||||
.device_group_add_peer(id, peer_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(Html(render_full(&state, lang).await?))
|
||||
}
|
||||
|
||||
pub async fn remove_peer(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path((id, peer_id)): Path<(i64, String)>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
state
|
||||
.db
|
||||
.device_group_remove_peer(id, &peer_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(Html(render_full(&state, lang).await?))
|
||||
}
|
||||
|
||||
// ---------- rendering ----------
|
||||
|
||||
/// Minimal percent-encoder for path segments. Peer IDs are usually digits,
|
||||
/// but the schema allows arbitrary text — encode anything outside the
|
||||
/// unreserved set so a literal `/` or `?` in a peer id can't break routing.
|
||||
fn url_encode(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for b in s.as_bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(*b as char);
|
||||
}
|
||||
_ => {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(out, "%{:02X}", b);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
async fn notice_then(
|
||||
state: &Arc<AppState>,
|
||||
lang: Lang,
|
||||
kind: &str,
|
||||
msg: &str,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
let mut html = notice_html(kind, msg);
|
||||
html.push_str(&render_full(state, lang).await?);
|
||||
Ok(Html(html))
|
||||
}
|
||||
|
||||
async fn render_full(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiError> {
|
||||
let groups = state
|
||||
.db
|
||||
.device_groups_list_all()
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let (_, all_users) = state
|
||||
.db
|
||||
.users_list_all(0, 1000)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
|
||||
let mut s = String::new();
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<div id="groups-region" class="space-y-6">
|
||||
<header><h2 class="text-lg font-semibold">{heading}</h2></header>
|
||||
<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-3">{create_heading}</h3>
|
||||
<form class="flex gap-2 text-sm" hx-post="/admin/pages/groups/create" hx-target="#groups-region" hx-swap="outerHTML">
|
||||
<input name="name" placeholder="{ph}" required class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
|
||||
<button class="bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 font-medium text-white">{create}</button>
|
||||
</form>
|
||||
</section>
|
||||
"##,
|
||||
heading = t(lang, "groups.heading"),
|
||||
create_heading = t(lang, "groups.create_heading"),
|
||||
ph = t(lang, "groups.group_name"),
|
||||
create = t(lang, "common.create"),
|
||||
);
|
||||
|
||||
if groups.is_empty() {
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<p class="text-slate-500 text-sm">{}</p>"##,
|
||||
t(lang, "groups.no_groups"),
|
||||
);
|
||||
}
|
||||
for g in &groups {
|
||||
let members = state
|
||||
.db
|
||||
.device_group_members(g.id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let peer_members = state
|
||||
.db
|
||||
.device_group_peer_members(g.id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4 space-y-4">
|
||||
<header class="flex items-center justify-between">
|
||||
<h3 class="font-semibold">{name}</h3>
|
||||
<button class="text-xs text-rose-400 hover:text-rose-300"
|
||||
hx-post="/admin/pages/groups/{id}/delete"
|
||||
hx-confirm="{confirm}"
|
||||
hx-target="#groups-region" hx-swap="outerHTML">{delete_group}</button>
|
||||
</header>
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-1">{users}</h4>
|
||||
<ul class="text-sm divide-y divide-slate-800">"##,
|
||||
id = g.id,
|
||||
name = html_escape(&g.name),
|
||||
confirm = html_escape(&tf1(lang, "groups.confirm_delete", &g.name)),
|
||||
delete_group = t(lang, "groups.delete_group"),
|
||||
users = t(lang, "groups.users"),
|
||||
);
|
||||
if members.is_empty() {
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<li class="py-2 text-slate-500 text-xs">{}</li>"##,
|
||||
t(lang, "groups.no_user_members"),
|
||||
);
|
||||
}
|
||||
for u in &members {
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<li class="py-2 flex items-center justify-between">
|
||||
<span class="text-slate-200">{username}</span>
|
||||
<button class="text-xs text-slate-400 hover:text-rose-300"
|
||||
hx-post="/admin/pages/groups/{gid}/members/{uid}/remove"
|
||||
hx-target="#groups-region" hx-swap="outerHTML">{remove}</button>
|
||||
</li>"##,
|
||||
username = html_escape(&u.username),
|
||||
gid = g.id,
|
||||
uid = u.id,
|
||||
remove = t(lang, "common.remove"),
|
||||
);
|
||||
}
|
||||
s.push_str("</ul>");
|
||||
// Add-member form: dropdown of users not currently in the group.
|
||||
let in_group: std::collections::HashSet<i64> =
|
||||
members.iter().map(|u| u.id).collect();
|
||||
let candidates: Vec<_> =
|
||||
all_users.iter().filter(|u| !in_group.contains(&u.id)).collect();
|
||||
if !candidates.is_empty() {
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<form class="flex gap-2 text-sm pt-2 border-t border-slate-800"
|
||||
hx-post="/admin/pages/groups/{id}/members/add"
|
||||
hx-target="#groups-region" hx-swap="outerHTML">
|
||||
<select name="user_id" class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1.5">
|
||||
"##,
|
||||
id = g.id
|
||||
);
|
||||
for u in &candidates {
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<option value="{uid}">{username}</option>"##,
|
||||
uid = u.id,
|
||||
username = html_escape(&u.username)
|
||||
);
|
||||
}
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"</select>
|
||||
<button class="bg-sky-700 hover:bg-sky-600 rounded px-3 py-1.5 text-xs">{add_user}</button>
|
||||
</form>"##,
|
||||
add_user = t(lang, "groups.add_user"),
|
||||
);
|
||||
}
|
||||
s.push_str("</div>");
|
||||
|
||||
// ---- Devices section ----
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<div>
|
||||
<h4 class="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-1">{devices}</h4>
|
||||
<ul class="text-sm divide-y divide-slate-800">"##,
|
||||
devices = t(lang, "groups.devices_section"),
|
||||
);
|
||||
if peer_members.is_empty() {
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<li class="py-2 text-slate-500 text-xs">{}</li>"##,
|
||||
t(lang, "groups.no_peer_members"),
|
||||
);
|
||||
}
|
||||
for (peer_id, owner) in &peer_members {
|
||||
let owner_label = if owner.is_empty() {
|
||||
format!(
|
||||
r##"<span class="text-slate-500">{}</span>"##,
|
||||
t(lang, "groups.unowned"),
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
r##"<span class="text-slate-500">{}</span>"##,
|
||||
html_escape(&tf1(lang, "groups.owner_label", owner)),
|
||||
)
|
||||
};
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<li class="py-2 flex items-center justify-between">
|
||||
<span class="font-mono text-slate-200">{pid}</span>
|
||||
<span class="text-xs flex items-center gap-3">
|
||||
{owner_label}
|
||||
<button class="text-slate-400 hover:text-rose-300"
|
||||
hx-post="/admin/pages/groups/{gid}/peers/{pid_url}/remove"
|
||||
hx-target="#groups-region" hx-swap="outerHTML">{remove}</button>
|
||||
</span>
|
||||
</li>"##,
|
||||
pid = html_escape(peer_id),
|
||||
pid_url = url_encode(peer_id),
|
||||
gid = g.id,
|
||||
owner_label = owner_label,
|
||||
remove = t(lang, "common.remove"),
|
||||
);
|
||||
}
|
||||
s.push_str("</ul>");
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<form class="flex gap-2 text-sm pt-2 border-t border-slate-800"
|
||||
hx-post="/admin/pages/groups/{id}/peers/add"
|
||||
hx-target="#groups-region" hx-swap="outerHTML">
|
||||
<input name="peer_id" placeholder="{ph}" required
|
||||
class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1.5 font-mono"/>
|
||||
<button class="bg-sky-700 hover:bg-sky-600 rounded px-3 py-1.5 text-xs">{add_device}</button>
|
||||
</form></div>"##,
|
||||
id = g.id,
|
||||
ph = t(lang, "groups.peer_id_placeholder"),
|
||||
add_device = t(lang, "groups.add_device"),
|
||||
);
|
||||
|
||||
s.push_str("</section>");
|
||||
}
|
||||
s.push_str("</div>");
|
||||
Ok(s)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! Per-page HTMX fragment handlers. Each page returns a chunk of HTML that
|
||||
//! the dashboard shell drops into `#main`. Filled in across M5b/M5c.
|
||||
|
||||
pub mod address_books;
|
||||
pub mod audit;
|
||||
pub mod connect;
|
||||
pub mod deploy;
|
||||
pub mod devices;
|
||||
pub mod exec;
|
||||
pub mod groups;
|
||||
pub mod profile;
|
||||
pub mod shared;
|
||||
pub mod strategies;
|
||||
pub mod users;
|
||||
|
||||
use axum::response::Html;
|
||||
|
||||
/// Tiny placeholder fragment — replaced by the real page handlers in M5b.
|
||||
pub fn placeholder(title: &str) -> Html<String> {
|
||||
Html(format!(
|
||||
r##"<div class="space-y-2">
|
||||
<h2 class="text-lg font-semibold">{title}</h2>
|
||||
<p class="text-slate-400 text-sm">This page is part of M5b — the dashboard shell, login, and per-page navigation are wired in M5a; the actual table + form for <strong>{title}</strong> lands in the next slice.</p>
|
||||
</div>"##
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
//! `/admin/pages/profile` — self-service profile page for the
|
||||
//! currently-signed-in user. Anyone with a valid dashboard cookie can
|
||||
//! reach this; no admin gate (the user-management page elsewhere is
|
||||
//! the admin-side equivalent for editing OTHER users).
|
||||
//!
|
||||
//! Flow for each section:
|
||||
//! - Profile info → POST update-info (display_name, email)
|
||||
//! - Password → POST change-password (current_pw, new_pw, confirm)
|
||||
//! - TOTP enroll → POST totp/start (generate secret + QR)
|
||||
//! → POST totp/confirm (verify 6-digit code)
|
||||
//! - TOTP remove → POST totp/remove (current_pw)
|
||||
//!
|
||||
//! TOTP enrollment is two-step: a freshly-generated secret is shown to
|
||||
//! the user as a QR code AND echoed in a hidden form field. Until the
|
||||
//! user submits a valid 6-digit code derived from that secret, nothing
|
||||
//! is written to `user_totp_secrets`. This means a half-finished enroll
|
||||
//! (user closes the tab) leaves no garbage state.
|
||||
|
||||
use crate::api::admin::i18n::{t, Lang};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use crate::api::state::AppState;
|
||||
use crate::api::users::{hash_password, verify_password};
|
||||
use axum::extract::{Extension, Form};
|
||||
use axum::response::Html;
|
||||
use serde::Deserialize;
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::Arc;
|
||||
use totp_rs::Secret;
|
||||
|
||||
// ---------- index ----------
|
||||
|
||||
pub async fn index(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
lang: Lang,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
Ok(Html(render_full_page(&state, lang, &user, None).await?))
|
||||
}
|
||||
|
||||
// ---------- update profile info ----------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct InfoForm {
|
||||
#[serde(default)]
|
||||
pub display_name: String,
|
||||
#[serde(default)]
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
pub async fn update_info(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
lang: Lang,
|
||||
Form(form): Form<InfoForm>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
state
|
||||
.db
|
||||
.user_set_display_name(user.user_id, form.display_name.trim())
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
state
|
||||
.db
|
||||
.raw_update_user_email(user.user_id, form.email.trim())
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(Html(
|
||||
render_full_page(
|
||||
&state,
|
||||
lang,
|
||||
&user,
|
||||
Some(("ok", t(lang, "profile.profile_updated"))),
|
||||
)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
// ---------- change password ----------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PasswordForm {
|
||||
pub current_password: String,
|
||||
pub new_password: String,
|
||||
pub confirm_password: String,
|
||||
}
|
||||
|
||||
pub async fn change_password(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
lang: Lang,
|
||||
Form(form): Form<PasswordForm>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
let row = state
|
||||
.db
|
||||
.user_find_by_id(user.user_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
if row.is_oidc_linked() {
|
||||
return Ok(Html(
|
||||
render_full_page(
|
||||
&state,
|
||||
lang,
|
||||
&user,
|
||||
Some(("error", t(lang, "profile.password_oidc_change"))),
|
||||
)
|
||||
.await?,
|
||||
));
|
||||
}
|
||||
if form.new_password.len() < 4 {
|
||||
return Ok(Html(
|
||||
render_full_page(
|
||||
&state,
|
||||
lang,
|
||||
&user,
|
||||
Some(("error", t(lang, "profile.password_min"))),
|
||||
)
|
||||
.await?,
|
||||
));
|
||||
}
|
||||
if form.new_password != form.confirm_password {
|
||||
return Ok(Html(
|
||||
render_full_page(
|
||||
&state,
|
||||
lang,
|
||||
&user,
|
||||
Some(("error", t(lang, "profile.password_mismatch"))),
|
||||
)
|
||||
.await?,
|
||||
));
|
||||
}
|
||||
let pw_ok = verify_password(row.password_hash.clone(), form.current_password)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if !pw_ok {
|
||||
return Ok(Html(
|
||||
render_full_page(
|
||||
&state,
|
||||
lang,
|
||||
&user,
|
||||
Some(("error", t(lang, "profile.current_incorrect"))),
|
||||
)
|
||||
.await?,
|
||||
));
|
||||
}
|
||||
let hash = hash_password(form.new_password)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
state
|
||||
.db
|
||||
.user_set_password(user.user_id, &hash)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(Html(
|
||||
render_full_page(
|
||||
&state,
|
||||
lang,
|
||||
&user,
|
||||
Some(("ok", t(lang, "profile.password_updated"))),
|
||||
)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
// ---------- TOTP: start ----------
|
||||
|
||||
/// `POST /admin/pages/profile/totp/start` — generate a fresh secret
|
||||
/// and render the QR + confirm form. Nothing is written to the DB
|
||||
/// yet; the secret rides in a hidden form field until confirm.
|
||||
pub async fn totp_start(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
lang: Lang,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
// Reject if the user already has TOTP — they should remove it first.
|
||||
let already = state
|
||||
.db
|
||||
.user_has_totp(user.user_id)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if already {
|
||||
return Ok(Html(
|
||||
render_full_page(
|
||||
&state,
|
||||
lang,
|
||||
&user,
|
||||
Some(("error", t(lang, "profile.tfa_already"))),
|
||||
)
|
||||
.await?,
|
||||
));
|
||||
}
|
||||
|
||||
let raw = sodiumoxide::randombytes::randombytes(20);
|
||||
let secret_b32 = Secret::Raw(raw).to_encoded().to_string();
|
||||
let issuer = "RustDesk";
|
||||
let label = format!("{}:{}", issuer, user.name);
|
||||
let otpauth = format!(
|
||||
"otpauth://totp/{label}?secret={secret}&issuer={issuer}&algorithm=SHA1&digits=6&period=30",
|
||||
label = url_encode(&label),
|
||||
secret = url_encode(&secret_b32),
|
||||
issuer = url_encode(issuer),
|
||||
);
|
||||
let qr_svg = render_qr_svg(&otpauth);
|
||||
|
||||
Ok(Html(render_totp_enroll_panel(
|
||||
&state,
|
||||
lang,
|
||||
&user,
|
||||
&secret_b32,
|
||||
&qr_svg,
|
||||
None,
|
||||
)
|
||||
.await?))
|
||||
}
|
||||
|
||||
// ---------- TOTP: confirm ----------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TotpConfirmForm {
|
||||
pub secret_b32: String,
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
pub async fn totp_confirm(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
lang: Lang,
|
||||
Form(form): Form<TotpConfirmForm>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
let code = form.code.trim();
|
||||
let secret = form.secret_b32.trim();
|
||||
if secret.is_empty() {
|
||||
return Ok(Html(
|
||||
render_full_page(
|
||||
&state,
|
||||
lang,
|
||||
&user,
|
||||
Some(("error", t(lang, "profile.tfa_missing_secret"))),
|
||||
)
|
||||
.await?,
|
||||
));
|
||||
}
|
||||
let valid = crate::api::auth::verify_totp(secret, code).unwrap_or(false);
|
||||
if !valid {
|
||||
// Re-render the enroll panel with the same secret so the user
|
||||
// can try again — losing the QR forces them to start over and
|
||||
// re-scan, which is annoying when the only error was a typo'd
|
||||
// code.
|
||||
let issuer = "RustDesk";
|
||||
let label = format!("{}:{}", issuer, user.name);
|
||||
let otpauth = format!(
|
||||
"otpauth://totp/{label}?secret={secret}&issuer={issuer}&algorithm=SHA1&digits=6&period=30",
|
||||
label = url_encode(&label),
|
||||
secret = url_encode(secret),
|
||||
issuer = url_encode(issuer),
|
||||
);
|
||||
let qr_svg = render_qr_svg(&otpauth);
|
||||
return Ok(Html(
|
||||
render_totp_enroll_panel(
|
||||
&state,
|
||||
lang,
|
||||
&user,
|
||||
secret,
|
||||
&qr_svg,
|
||||
Some(("error", t(lang, "profile.tfa_bad_code"))),
|
||||
)
|
||||
.await?,
|
||||
));
|
||||
}
|
||||
state
|
||||
.db
|
||||
.totp_enroll(user.user_id, secret)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(Html(
|
||||
render_full_page(
|
||||
&state,
|
||||
lang,
|
||||
&user,
|
||||
Some(("ok", t(lang, "profile.tfa_enrolled"))),
|
||||
)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
// ---------- TOTP: remove ----------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TotpRemoveForm {
|
||||
pub current_password: String,
|
||||
}
|
||||
|
||||
pub async fn totp_remove(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
lang: Lang,
|
||||
Form(form): Form<TotpRemoveForm>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
let row = state
|
||||
.db
|
||||
.user_find_by_id(user.user_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
let pw_ok = verify_password(row.password_hash.clone(), form.current_password)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if !pw_ok {
|
||||
return Ok(Html(
|
||||
render_full_page(
|
||||
&state,
|
||||
lang,
|
||||
&user,
|
||||
Some(("error", t(lang, "profile.tfa_current_pw_incorrect"))),
|
||||
)
|
||||
.await?,
|
||||
));
|
||||
}
|
||||
state
|
||||
.db
|
||||
.totp_unenroll(user.user_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(Html(
|
||||
render_full_page(
|
||||
&state,
|
||||
lang,
|
||||
&user,
|
||||
Some(("ok", t(lang, "profile.tfa_removed"))),
|
||||
)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
// ---------- rendering ----------
|
||||
|
||||
async fn render_full_page(
|
||||
state: &Arc<AppState>,
|
||||
lang: Lang,
|
||||
user: &AuthedUser,
|
||||
notice: Option<(&str, &str)>,
|
||||
) -> Result<String, ApiError> {
|
||||
render_full_page_with_totp_override(state, lang, user, notice, None).await
|
||||
}
|
||||
|
||||
/// `totp_panel_override = Some(html)` swaps in a custom TOTP block —
|
||||
/// used during enrollment confirm so the QR code panel sits where the
|
||||
/// status badge would normally be, and the password / profile sections
|
||||
/// stay visible above it.
|
||||
async fn render_full_page_with_totp_override(
|
||||
state: &Arc<AppState>,
|
||||
lang: Lang,
|
||||
user: &AuthedUser,
|
||||
notice: Option<(&str, &str)>,
|
||||
totp_panel_override: Option<String>,
|
||||
) -> Result<String, ApiError> {
|
||||
let row = state
|
||||
.db
|
||||
.user_find_by_id(user.user_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
let has_totp = state
|
||||
.db
|
||||
.user_has_totp(user.user_id)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
let notice_block = notice
|
||||
.map(|(k, m)| notice_html(k, m))
|
||||
.unwrap_or_default();
|
||||
|
||||
// OIDC-linked accounts sign in via the IdP — local password and
|
||||
// local TOTP both moot. Replace those sections with a short note.
|
||||
let oidc_linked = row.is_oidc_linked();
|
||||
let password_section = if oidc_linked {
|
||||
format!(
|
||||
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-2">{heading}</h3>
|
||||
<p class="text-sm text-slate-400">
|
||||
{msg}
|
||||
</p>
|
||||
</section>"##,
|
||||
heading = t(lang, "profile.password_oidc"),
|
||||
msg = t(lang, "profile.password_oidc_msg"),
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-3">{heading}</h3>
|
||||
<form
|
||||
class="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm"
|
||||
hx-post="/admin/pages/profile/change-password"
|
||||
hx-target="#main"
|
||||
hx-swap="innerHTML"
|
||||
hx-on::after-request="if (event.detail.successful) this.reset()"
|
||||
>
|
||||
<input name="current_password" type="password" required placeholder="{cur}" class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
|
||||
<input name="new_password" type="password" required minlength="4" placeholder="{new}" class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
|
||||
<input name="confirm_password" type="password" required minlength="4" placeholder="{conf}" class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
|
||||
<button type="submit" class="sm:col-span-3 justify-self-start bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 text-white text-sm">{btn}</button>
|
||||
</form>
|
||||
</section>"##,
|
||||
heading = t(lang, "profile.password_heading"),
|
||||
cur = t(lang, "profile.current_password"),
|
||||
new = t(lang, "profile.new_password"),
|
||||
conf = t(lang, "profile.confirm_new"),
|
||||
btn = t(lang, "profile.update_password"),
|
||||
)
|
||||
};
|
||||
|
||||
let totp_section = if let Some(panel) = totp_panel_override {
|
||||
panel
|
||||
} else if oidc_linked {
|
||||
format!(
|
||||
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-2">{heading}</h3>
|
||||
<p class="text-sm text-slate-400">
|
||||
{msg}
|
||||
</p>
|
||||
</section>"##,
|
||||
heading = t(lang, "profile.tfa"),
|
||||
msg = t(lang, "profile.tfa_oidc_msg"),
|
||||
)
|
||||
} else if has_totp {
|
||||
format!(
|
||||
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-2">{heading}</h3>
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<span class="inline-flex px-1.5 py-0.5 rounded bg-violet-900/50 border border-violet-700/50 text-violet-300 text-xs">{enrolled}</span>
|
||||
<span class="text-xs text-slate-400">{tfa_msg}</span>
|
||||
</div>
|
||||
<form
|
||||
class="flex gap-2 items-center text-sm"
|
||||
hx-post="/admin/pages/profile/totp/remove"
|
||||
hx-target="#main"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="{confirm}"
|
||||
>
|
||||
<input name="current_password" type="password" required placeholder="{cur}" class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5 flex-1 max-w-xs"/>
|
||||
<button class="bg-rose-700 hover:bg-rose-600 rounded px-3 py-1.5 text-white text-xs">{btn}</button>
|
||||
</form>
|
||||
</section>"##,
|
||||
heading = t(lang, "profile.tfa"),
|
||||
enrolled = t(lang, "users.totp_enrolled"),
|
||||
tfa_msg = t(lang, "profile.tfa_enrolled_msg"),
|
||||
confirm = t(lang, "profile.tfa_confirm_remove"),
|
||||
cur = t(lang, "profile.current_password"),
|
||||
btn = t(lang, "profile.tfa_disable"),
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-2">{heading}</h3>
|
||||
<p class="text-sm text-slate-400 mb-3">
|
||||
{intro}
|
||||
</p>
|
||||
<button
|
||||
class="bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 text-white text-sm"
|
||||
hx-post="/admin/pages/profile/totp/start"
|
||||
hx-target="#main"
|
||||
hx-swap="innerHTML"
|
||||
>{btn}</button>
|
||||
</section>"##,
|
||||
heading = t(lang, "profile.tfa"),
|
||||
intro = t(lang, "profile.tfa_intro"),
|
||||
btn = t(lang, "profile.tfa_enroll"),
|
||||
)
|
||||
};
|
||||
|
||||
Ok(format!(
|
||||
r##"<div class="space-y-6 max-w-3xl">
|
||||
<header>
|
||||
<h2 class="text-lg font-semibold">{heading}</h2>
|
||||
<p class="text-xs text-slate-500 mt-0.5">{signed_in_as} <span class="text-slate-300">{username}</span></p>
|
||||
</header>
|
||||
{notice}
|
||||
|
||||
<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-3">{info_heading}</h3>
|
||||
<form
|
||||
class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm"
|
||||
hx-post="/admin/pages/profile/update-info"
|
||||
hx-target="#main"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<label class="block">
|
||||
<span class="text-xs text-slate-400">{display_name_l}</span>
|
||||
<input name="display_name" value="{display_name}" class="mt-1 w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs text-slate-400">{email_l}</span>
|
||||
<input name="email" type="email" value="{email}" class="mt-1 w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
|
||||
</label>
|
||||
<button type="submit" class="sm:col-span-2 justify-self-start bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 text-white text-sm">{save}</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{password_section}
|
||||
|
||||
{totp_section}
|
||||
</div>"##,
|
||||
heading = t(lang, "profile.heading"),
|
||||
signed_in_as = t(lang, "profile.signed_in_as"),
|
||||
info_heading = t(lang, "profile.info_heading"),
|
||||
display_name_l = t(lang, "profile.display_name"),
|
||||
email_l = t(lang, "profile.email"),
|
||||
save = t(lang, "common.save"),
|
||||
username = html_escape(&user.name),
|
||||
display_name = html_escape(&row.display_name),
|
||||
email = html_escape(&row.email),
|
||||
notice = notice_block,
|
||||
password_section = password_section,
|
||||
totp_section = totp_section,
|
||||
))
|
||||
}
|
||||
|
||||
async fn render_totp_enroll_panel(
|
||||
state: &Arc<AppState>,
|
||||
lang: Lang,
|
||||
user: &AuthedUser,
|
||||
secret_b32: &str,
|
||||
qr_svg: &str,
|
||||
notice: Option<(&str, &str)>,
|
||||
) -> Result<String, ApiError> {
|
||||
let panel = format!(
|
||||
r##"<section class="rounded-md border border-sky-700/60 bg-sky-900/20 p-4">
|
||||
<h3 class="text-sm font-semibold text-sky-200 mb-2">{heading}</h3>
|
||||
<p class="text-xs text-sky-200/80 mb-3">
|
||||
{intro}
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 items-start">
|
||||
<div class="bg-white p-2 rounded inline-block">{qr}</div>
|
||||
<div class="flex-1 space-y-2 text-sm">
|
||||
<div>
|
||||
<span class="text-xs text-slate-400">{secret_label}</span>
|
||||
<code class="block mt-1 break-all text-emerald-200 bg-slate-950 px-2 py-1.5 rounded text-xs">{secret}</code>
|
||||
</div>
|
||||
<form
|
||||
class="flex gap-2 items-stretch"
|
||||
hx-post="/admin/pages/profile/totp/confirm"
|
||||
hx-target="#main"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<input type="hidden" name="secret_b32" value="{secret}"/>
|
||||
<input
|
||||
name="code"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]{{6}}"
|
||||
required
|
||||
maxlength="6"
|
||||
autocomplete="one-time-code"
|
||||
placeholder="123456"
|
||||
class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5 w-32 font-mono tracking-widest"
|
||||
/>
|
||||
<button type="submit" class="bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 text-white text-sm">{confirm}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>"##,
|
||||
heading = t(lang, "profile.tfa_confirm_heading"),
|
||||
intro = t(lang, "profile.tfa_confirm_intro"),
|
||||
secret_label = t(lang, "profile.tfa_secret_manual"),
|
||||
confirm = t(lang, "common.confirm"),
|
||||
qr = qr_svg,
|
||||
secret = html_escape(secret_b32),
|
||||
);
|
||||
render_full_page_with_totp_override(state, lang, user, notice, Some(panel)).await
|
||||
}
|
||||
|
||||
fn render_qr_svg(payload: &str) -> String {
|
||||
// qrcode 0.14: build the QR, then render with the SVG renderer.
|
||||
// .min_dimensions caps how big the SVG-pixel grid is; the actual
|
||||
// CSS size is handled by inline width/height, but the underlying
|
||||
// module size needs to be reasonable so it stays crisp on retina.
|
||||
use qrcode::render::svg;
|
||||
use qrcode::QrCode;
|
||||
match QrCode::new(payload.as_bytes()) {
|
||||
Ok(code) => code
|
||||
.render::<svg::Color<'_>>()
|
||||
.min_dimensions(180, 180)
|
||||
.dark_color(svg::Color("#000"))
|
||||
.light_color(svg::Color("#fff"))
|
||||
.build(),
|
||||
Err(_) => "<div class=\"text-rose-300 text-xs\">QR encode failed</div>".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn notice_html(kind: &str, msg: &str) -> String {
|
||||
let (border, bg, text) = match kind {
|
||||
"ok" => ("emerald-700/50", "emerald-900/30", "emerald-300"),
|
||||
_ => ("rose-700/50", "rose-900/30", "rose-300"),
|
||||
};
|
||||
format!(
|
||||
r##"<div class="rounded border border-{border} bg-{bg} p-3 mb-4 text-sm text-{text}">{msg}</div>"##,
|
||||
border = border,
|
||||
bg = bg,
|
||||
text = text,
|
||||
msg = html_escape(msg),
|
||||
)
|
||||
}
|
||||
|
||||
fn html_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
fn url_encode(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for b in s.as_bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(*b as char);
|
||||
}
|
||||
_ => {
|
||||
let _ = write!(out, "%{:02X}", b);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Tiny rendering helpers shared by every admin page. Splitting these out
|
||||
//! keeps each page module under ~200 LOC.
|
||||
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
|
||||
pub fn require_admin(u: &AuthedUser) -> Result<(), ApiError> {
|
||||
if u.is_admin {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApiError::Forbidden("admin required".into()))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn html_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
pub fn notice_html(kind: &str, msg: &str) -> String {
|
||||
let (border, bg, text) = match kind {
|
||||
"ok" => ("emerald-700/50", "emerald-900/30", "emerald-300"),
|
||||
_ => ("rose-700/50", "rose-900/30", "rose-300"),
|
||||
};
|
||||
format!(
|
||||
r##"<div class="rounded border border-{border} bg-{bg} p-3 mb-4 text-sm text-{text}">{msg}</div>"##,
|
||||
border = border,
|
||||
bg = bg,
|
||||
text = text,
|
||||
msg = html_escape(msg),
|
||||
)
|
||||
}
|
||||
|
||||
/// Format a unix timestamp as a short ISO-ish string for table cells.
|
||||
pub fn fmt_unix(ts: i64) -> String {
|
||||
if ts <= 0 {
|
||||
return "—".into();
|
||||
}
|
||||
use chrono::{TimeZone, Utc};
|
||||
Utc.timestamp_opt(ts, 0)
|
||||
.single()
|
||||
.map(|t| t.format("%Y-%m-%d %H:%M:%SZ").to_string())
|
||||
.unwrap_or_else(|| ts.to_string())
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
//! Strategies page — list / create / edit-config / delete, plus the
|
||||
//! assignment matrix that decides which clients receive each strategy.
|
||||
//! Assignments are scoped to device groups or individual peers; user-level
|
||||
//! assignments are still SQL-driven (rare in practice).
|
||||
|
||||
use super::shared::{html_escape, notice_html, require_admin};
|
||||
use crate::api::admin::i18n::{t, tf1, tf2, Lang};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use crate::api::state::AppState;
|
||||
use axum::extract::{Extension, Form, Path};
|
||||
use axum::response::Html;
|
||||
use serde::Deserialize;
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub async fn index(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
Ok(Html(render_full(&state, lang).await?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateForm {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub config_options_json: String,
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Form(form): Form<CreateForm>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
if form.name.trim().is_empty() {
|
||||
return notice_then(&state, lang, "error", t(lang, "groups.name_required")).await;
|
||||
}
|
||||
let cfg = if form.config_options_json.trim().is_empty() {
|
||||
"{}".to_string()
|
||||
} else {
|
||||
// Validate it's a JSON object — empty object is fine, anything else
|
||||
// gets rejected with a friendly message.
|
||||
match serde_json::from_str::<serde_json::Value>(&form.config_options_json) {
|
||||
Ok(v) if v.is_object() => form.config_options_json.clone(),
|
||||
Ok(_) => {
|
||||
return notice_then(&state, lang, "error", t(lang, "strategies.json_obj_required")).await
|
||||
}
|
||||
Err(e) => {
|
||||
return notice_then(
|
||||
&state,
|
||||
lang,
|
||||
"error",
|
||||
&tf1(lang, "strategies.invalid_json", &e.to_string()),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
};
|
||||
state
|
||||
.db
|
||||
.strategy_create(form.name.trim(), &cfg, "{}")
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
notice_then(
|
||||
&state,
|
||||
lang,
|
||||
"ok",
|
||||
&tf1(lang, "strategies.created", &form.name),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateForm {
|
||||
pub config_options_json: String,
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path(id): Path<i64>,
|
||||
Form(form): Form<UpdateForm>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
let cfg = match serde_json::from_str::<serde_json::Value>(&form.config_options_json) {
|
||||
Ok(v) if v.is_object() => form.config_options_json.clone(),
|
||||
_ => {
|
||||
return notice_then(&state, lang, "error", t(lang, "strategies.json_obj_required")).await
|
||||
}
|
||||
};
|
||||
state
|
||||
.db
|
||||
.strategy_update_config(id, &cfg)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
notice_then(&state, lang, "ok", t(lang, "strategies.updated")).await
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
let ok = state
|
||||
.db
|
||||
.strategy_delete(id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
notice_then(
|
||||
&state,
|
||||
lang,
|
||||
if ok { "ok" } else { "error" },
|
||||
if ok { t(lang, "strategies.deleted") } else { t(lang, "common.already_gone") },
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AssignGroupForm {
|
||||
pub device_group_id: i64,
|
||||
}
|
||||
|
||||
pub async fn assign_group(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path(id): Path<i64>,
|
||||
Form(form): Form<AssignGroupForm>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
state
|
||||
.db
|
||||
.strategy_assign_group(id, form.device_group_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
notice_then(&state, lang, "ok", t(lang, "strategies.group_assigned")).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AssignPeerForm {
|
||||
pub peer_id: String,
|
||||
}
|
||||
|
||||
pub async fn assign_peer(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path(id): Path<i64>,
|
||||
Form(form): Form<AssignPeerForm>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
let peer_id = form.peer_id.trim();
|
||||
if peer_id.is_empty() {
|
||||
return notice_then(&state, lang, "error", t(lang, "groups.peer_id_required")).await;
|
||||
}
|
||||
let exists = state
|
||||
.db
|
||||
.peer_exists(peer_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
if !exists {
|
||||
return notice_then(
|
||||
&state,
|
||||
lang,
|
||||
"error",
|
||||
&tf1(lang, "groups.no_device_yet", peer_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
state
|
||||
.db
|
||||
.strategy_assign_peer_replace(id, peer_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
notice_then(
|
||||
&state,
|
||||
lang,
|
||||
"ok",
|
||||
&tf1(lang, "strategies.peer_assigned", peer_id),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn unassign(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
lang: Lang,
|
||||
Path((id, assignment_id)): Path<(i64, i64)>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
let ok = state
|
||||
.db
|
||||
.strategy_assignment_delete(id, assignment_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
notice_then(
|
||||
&state,
|
||||
lang,
|
||||
if ok { "ok" } else { "error" },
|
||||
if ok { t(lang, "strategies.unassigned") } else { t(lang, "common.already_gone") },
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// ---------- rendering ----------
|
||||
|
||||
async fn notice_then(
|
||||
state: &Arc<AppState>,
|
||||
lang: Lang,
|
||||
kind: &str,
|
||||
msg: &str,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
let mut html = notice_html(kind, msg);
|
||||
html.push_str(&render_full(state, lang).await?);
|
||||
Ok(Html(html))
|
||||
}
|
||||
|
||||
async fn render_full(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiError> {
|
||||
let strategies = state
|
||||
.db
|
||||
.strategies_list_all()
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let all_groups = state
|
||||
.db
|
||||
.device_groups_list_all()
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let mut s = String::new();
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<div id="strategies-region" class="space-y-6">
|
||||
<header>
|
||||
<h2 class="text-lg font-semibold">{heading}</h2>
|
||||
<p class="text-xs text-slate-500 mt-1">{tagline}</p>
|
||||
</header>
|
||||
<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-3">{create_heading}</h3>
|
||||
<form class="space-y-2 text-sm" hx-post="/admin/pages/strategies/create" hx-target="#strategies-region" hx-swap="outerHTML">
|
||||
<input name="name" placeholder="{ph}" required class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
|
||||
<textarea name="config_options_json" rows="3" placeholder='{{"enable-udp": "N", "whitelist": ""}}'
|
||||
class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5 font-mono text-xs"></textarea>
|
||||
<button class="bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 font-medium text-white">{create}</button>
|
||||
</form>
|
||||
</section>
|
||||
"##,
|
||||
heading = t(lang, "strategies.heading"),
|
||||
tagline = t(lang, "strategies.tagline"),
|
||||
create_heading = t(lang, "strategies.create_heading"),
|
||||
ph = t(lang, "strategies.name_unique"),
|
||||
create = t(lang, "common.create"),
|
||||
);
|
||||
if strategies.is_empty() {
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<p class="text-slate-500 text-sm">{}</p>"##,
|
||||
t(lang, "strategies.no_strategies"),
|
||||
);
|
||||
}
|
||||
for str_ in &strategies {
|
||||
let assignments = state
|
||||
.db
|
||||
.strategy_assignments_for(str_.id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4 space-y-3">
|
||||
<header class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="font-semibold">{name}</h3>
|
||||
<p class="text-xs text-slate-500">{meta}</p>
|
||||
</div>
|
||||
<button class="text-xs text-rose-400 hover:text-rose-300"
|
||||
hx-post="/admin/pages/strategies/{id}/delete"
|
||||
hx-confirm="{confirm}"
|
||||
hx-target="#strategies-region" hx-swap="outerHTML">{delete}</button>
|
||||
</header>
|
||||
<form class="space-y-2 text-sm"
|
||||
hx-post="/admin/pages/strategies/{id}/update"
|
||||
hx-target="#strategies-region" hx-swap="outerHTML">
|
||||
<label class="block text-xs text-slate-400">{cfg_label}</label>
|
||||
<textarea name="config_options_json" rows="4"
|
||||
class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5 font-mono text-xs">{cfg}</textarea>
|
||||
<button class="bg-sky-700 hover:bg-sky-600 rounded px-3 py-1.5 text-xs">{save}</button>
|
||||
</form>"##,
|
||||
id = str_.id,
|
||||
name = html_escape(&str_.name),
|
||||
meta = tf2(lang, "strategies.id_modified", &str_.id.to_string(), &str_.modified_at.to_string()),
|
||||
cfg = html_escape(&str_.config_options_json),
|
||||
confirm = html_escape(&tf1(lang, "strategies.confirm_delete", &str_.name)),
|
||||
delete = t(lang, "common.delete"),
|
||||
cfg_label = t(lang, "strategies.config_label"),
|
||||
save = t(lang, "common.save"),
|
||||
);
|
||||
|
||||
// ---- Assignments section ----
|
||||
render_assignments(&mut s, lang, str_.id, &assignments, &all_groups);
|
||||
|
||||
s.push_str("</section>");
|
||||
}
|
||||
s.push_str("</div>");
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
fn render_assignments(
|
||||
s: &mut String,
|
||||
lang: Lang,
|
||||
strategy_id: i64,
|
||||
assignments: &[crate::database::StrategyAssignmentRow],
|
||||
all_groups: &[crate::database::DeviceGroupRow],
|
||||
) {
|
||||
let group_assigned: std::collections::HashSet<i64> = assignments
|
||||
.iter()
|
||||
.filter_map(|a| a.device_group_id)
|
||||
.collect();
|
||||
let group_rows: Vec<&crate::database::StrategyAssignmentRow> = assignments
|
||||
.iter()
|
||||
.filter(|a| a.device_group_id.is_some())
|
||||
.collect();
|
||||
let peer_rows: Vec<&crate::database::StrategyAssignmentRow> = assignments
|
||||
.iter()
|
||||
.filter(|a| a.peer_id.is_some())
|
||||
.collect();
|
||||
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<div class="pt-3 border-t border-slate-800 space-y-3">
|
||||
<h4 class="text-xs font-semibold text-slate-400 uppercase tracking-wide">{filter_heading}</h4>
|
||||
<p class="text-[11px] text-slate-500">{filter_hint}</p>"##,
|
||||
filter_heading = t(lang, "strategies.filter_heading"),
|
||||
filter_hint = t(lang, "strategies.filter_hint"),
|
||||
);
|
||||
|
||||
// ---- Device groups ----
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<div>
|
||||
<div class="text-[11px] font-semibold text-slate-400 mb-1">{groups_label}</div>
|
||||
<ul class="text-sm divide-y divide-slate-800">"##,
|
||||
groups_label = t(lang, "strategies.groups_label"),
|
||||
);
|
||||
if group_rows.is_empty() {
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<li class="py-2 text-slate-500 text-xs">{}</li>"##,
|
||||
t(lang, "strategies.no_group_assignments"),
|
||||
);
|
||||
}
|
||||
for a in &group_rows {
|
||||
let name = a
|
||||
.device_group_name
|
||||
.as_deref()
|
||||
.unwrap_or("(deleted group)");
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<li class="py-2 flex items-center justify-between">
|
||||
<span class="text-slate-200">{name}</span>
|
||||
<button class="text-xs text-slate-400 hover:text-rose-300"
|
||||
hx-post="/admin/pages/strategies/{sid}/assignments/{aid}/delete"
|
||||
hx-target="#strategies-region" hx-swap="outerHTML">{remove}</button>
|
||||
</li>"##,
|
||||
name = html_escape(name),
|
||||
sid = strategy_id,
|
||||
aid = a.id,
|
||||
remove = t(lang, "common.remove"),
|
||||
);
|
||||
}
|
||||
s.push_str("</ul>");
|
||||
let candidates: Vec<&crate::database::DeviceGroupRow> = all_groups
|
||||
.iter()
|
||||
.filter(|g| !group_assigned.contains(&g.id))
|
||||
.collect();
|
||||
if !candidates.is_empty() {
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<form class="flex gap-2 text-sm pt-2"
|
||||
hx-post="/admin/pages/strategies/{sid}/assignments/group"
|
||||
hx-target="#strategies-region" hx-swap="outerHTML">
|
||||
<select name="device_group_id" class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1.5">
|
||||
"##,
|
||||
sid = strategy_id,
|
||||
);
|
||||
for g in &candidates {
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<option value="{gid}">{name}</option>"##,
|
||||
gid = g.id,
|
||||
name = html_escape(&g.name),
|
||||
);
|
||||
}
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"</select>
|
||||
<button class="bg-sky-700 hover:bg-sky-600 rounded px-3 py-1.5 text-xs">{assign}</button>
|
||||
</form>"##,
|
||||
assign = t(lang, "strategies.assign_group"),
|
||||
);
|
||||
} else if !all_groups.is_empty() {
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<p class="text-[11px] text-slate-500 pt-1">{}</p>"##,
|
||||
t(lang, "strategies.all_groups_assigned"),
|
||||
);
|
||||
} else {
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<p class="text-[11px] text-slate-500 pt-1">{}</p>"##,
|
||||
t(lang, "strategies.no_groups_exist"),
|
||||
);
|
||||
}
|
||||
s.push_str("</div>");
|
||||
|
||||
// ---- Peers ----
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<div>
|
||||
<div class="text-[11px] font-semibold text-slate-400 mb-1">{peers_label}</div>
|
||||
<ul class="text-sm divide-y divide-slate-800">"##,
|
||||
peers_label = t(lang, "strategies.peers_label"),
|
||||
);
|
||||
if peer_rows.is_empty() {
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<li class="py-2 text-slate-500 text-xs">{}</li>"##,
|
||||
t(lang, "strategies.no_peer_assignments"),
|
||||
);
|
||||
}
|
||||
for a in &peer_rows {
|
||||
let pid = a.peer_id.as_deref().unwrap_or("");
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<li class="py-2 flex items-center justify-between">
|
||||
<span class="font-mono text-slate-200">{pid}</span>
|
||||
<button class="text-xs text-slate-400 hover:text-rose-300"
|
||||
hx-post="/admin/pages/strategies/{sid}/assignments/{aid}/delete"
|
||||
hx-target="#strategies-region" hx-swap="outerHTML">{remove}</button>
|
||||
</li>"##,
|
||||
pid = html_escape(pid),
|
||||
sid = strategy_id,
|
||||
aid = a.id,
|
||||
remove = t(lang, "common.remove"),
|
||||
);
|
||||
}
|
||||
s.push_str("</ul>");
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<form class="flex gap-2 text-sm pt-2"
|
||||
hx-post="/admin/pages/strategies/{sid}/assignments/peer"
|
||||
hx-target="#strategies-region" hx-swap="outerHTML">
|
||||
<input name="peer_id" placeholder="{ph}" required
|
||||
class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1.5 font-mono"/>
|
||||
<button class="bg-sky-700 hover:bg-sky-600 rounded px-3 py-1.5 text-xs">{assign}</button>
|
||||
</form>"##,
|
||||
sid = strategy_id,
|
||||
ph = t(lang, "groups.peer_id_placeholder"),
|
||||
assign = t(lang, "strategies.assign_peer"),
|
||||
);
|
||||
s.push_str("</div></div>");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
//! `POST /api/agent/exec-result` — agent posts back the result of a
|
||||
//! PowerShell command queued via the heartbeat reply.
|
||||
//!
|
||||
//! Auth: same per-peer signed-API gate as the other agent endpoints
|
||||
//! ([`crate::api::device_auth`]). Because remote exec is only ever
|
||||
//! dispatched against `peer.managed = 1` peers, *this* endpoint
|
||||
//! additionally refuses unsigned posts even when the peer happens to be
|
||||
//! `managed=0` — there's no legacy compatibility story for exec, so we
|
||||
//! fail closed.
|
||||
|
||||
use crate::api::device_auth::{self, AuthOutcome};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::state::AppState;
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::Extension;
|
||||
use axum::http::HeaderMap;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ExecResultBody {
|
||||
pub id: String,
|
||||
pub uuid: String,
|
||||
pub cmd_id: String,
|
||||
pub exit_code: i64,
|
||||
#[serde(default)]
|
||||
pub stdout: String,
|
||||
#[serde(default)]
|
||||
pub stderr: String,
|
||||
#[serde(default)]
|
||||
pub timed_out: bool,
|
||||
#[serde(default)]
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
pub async fn exec_result(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<String, ApiError> {
|
||||
let outcome =
|
||||
device_auth::verify(&state, "POST", "/api/agent/exec-result", &headers, &body).await?;
|
||||
let payload: ExecResultBody = serde_json::from_slice(&body)
|
||||
.map_err(|_| ApiError::BadRequest("invalid json".into()))?;
|
||||
if payload.id.is_empty() || payload.uuid.is_empty() || payload.cmd_id.is_empty() {
|
||||
return Err(ApiError::BadRequest(
|
||||
"id, uuid, and cmd_id required".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Bind identity to body. Unsigned posts are flat-out rejected here
|
||||
// even when the peer is currently managed=0 — exec is a signed-only
|
||||
// feature, no legacy path.
|
||||
let id = match outcome {
|
||||
AuthOutcome::Verified { id: signed_id } => {
|
||||
if payload.id != signed_id {
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
signed_id
|
||||
}
|
||||
AuthOutcome::LegacyUnsigned => return Err(ApiError::Unauthorized),
|
||||
};
|
||||
|
||||
let updated = state
|
||||
.db
|
||||
.exec_finish(
|
||||
&payload.cmd_id,
|
||||
&id,
|
||||
payload.exit_code,
|
||||
&payload.stdout,
|
||||
&payload.stderr,
|
||||
payload.timed_out,
|
||||
payload.truncated,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
if !updated {
|
||||
// Either the cmd_id doesn't exist, belongs to another peer, or
|
||||
// is already in a terminal state. The agent doesn't need to
|
||||
// distinguish — log on our side and return OK so it doesn't
|
||||
// retry forever.
|
||||
hbb_common::log::warn!(
|
||||
"exec-result: no-op update for cmd_id={} peer={} (already finalized or wrong peer)",
|
||||
payload.cmd_id,
|
||||
id
|
||||
);
|
||||
}
|
||||
Ok("OK".to_string())
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! `POST /api/audit/alarm` — security alarm (IP whitelist hit, brute-force
|
||||
//! thresholds). Wire shape from CONSOLE_API.md §7.3:
|
||||
//! `{ id, uuid, typ: int, info: stringified-JSON }`.
|
||||
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::state::AppState;
|
||||
use axum::extract::Extension;
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AlarmAuditBody {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub uuid: String,
|
||||
#[serde(default)]
|
||||
pub typ: i64,
|
||||
#[serde(default)]
|
||||
pub info: String,
|
||||
}
|
||||
|
||||
pub async fn alarm(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
Json(body): Json<AlarmAuditBody>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
if body.id.is_empty() {
|
||||
return Err(ApiError::BadRequest("id required".into()));
|
||||
}
|
||||
state
|
||||
.db
|
||||
.audit_alarm_insert(&body.id, body.typ, &body.info)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! `POST /api/audit/conn` — fire-and-forget connection log entry. The client
|
||||
//! ([src/server/connection.rs:1248-1279](file:///Users/sn0/Desktop/rustdesk/src/server/connection.rs#L1248))
|
||||
//! emits this on every accepted session, no Authorization header. We answer
|
||||
//! with `{"guid":"..."}` so the client can pass that guid back later in
|
||||
//! `PUT /api/audit` (CONSOLE_API.md §7.1).
|
||||
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::state::AppState;
|
||||
use axum::extract::Extension;
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ConnAuditBody {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub uuid: String,
|
||||
#[serde(default)]
|
||||
pub conn_id: i64,
|
||||
#[serde(default)]
|
||||
pub session_id: i64,
|
||||
#[serde(default)]
|
||||
pub ip: String,
|
||||
#[serde(default)]
|
||||
pub action: String,
|
||||
}
|
||||
|
||||
pub async fn conn(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
Json(body): Json<ConnAuditBody>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
if body.id.is_empty() {
|
||||
return Err(ApiError::BadRequest("id required".into()));
|
||||
}
|
||||
let action = if body.action.is_empty() {
|
||||
"new"
|
||||
} else {
|
||||
body.action.as_str()
|
||||
};
|
||||
let guid = state
|
||||
.db
|
||||
.audit_conn_insert(&body.id, body.conn_id, body.session_id, &body.ip, action)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(Json(json!({ "guid": guid })))
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! `POST /api/audit/file` — file transfer log entry (CONSOLE_API.md §7.2).
|
||||
//! `info` arrives as a stringified JSON object; we store it verbatim.
|
||||
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::state::AppState;
|
||||
use axum::extract::Extension;
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct FileAuditBody {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub uuid: String,
|
||||
#[serde(default)]
|
||||
pub peer_id: String,
|
||||
#[serde(default, rename = "type")]
|
||||
pub direction: i64,
|
||||
#[serde(default)]
|
||||
pub path: String,
|
||||
#[serde(default)]
|
||||
pub is_file: bool,
|
||||
#[serde(default)]
|
||||
pub info: String,
|
||||
}
|
||||
|
||||
pub async fn file(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
Json(body): Json<FileAuditBody>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
if body.id.is_empty() {
|
||||
return Err(ApiError::BadRequest("id required".into()));
|
||||
}
|
||||
state
|
||||
.db
|
||||
.audit_file_insert(
|
||||
&body.id,
|
||||
&body.peer_id,
|
||||
body.direction,
|
||||
&body.path,
|
||||
body.is_file,
|
||||
&body.info,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod alarm;
|
||||
pub mod conn;
|
||||
pub mod file;
|
||||
pub mod note;
|
||||
@@ -0,0 +1,39 @@
|
||||
//! `PUT /api/audit` — operator end-of-session note. Sent from the Flutter
|
||||
//! `_showConnEndAuditDialogCloseCanceled` flow at
|
||||
//! [flutter/lib/common/widgets/dialog.dart:1656](file:///Users/sn0/Desktop/rustdesk/flutter/lib/common/widgets/dialog.dart#L1656).
|
||||
//! Bearer-authenticated.
|
||||
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use crate::api::state::AppState;
|
||||
use axum::extract::Extension;
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct NoteBody {
|
||||
pub guid: String,
|
||||
#[serde(default)]
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
pub async fn note(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
_user: AuthedUser,
|
||||
Json(body): Json<NoteBody>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
if body.guid.is_empty() {
|
||||
return Err(ApiError::BadRequest("guid required".into()));
|
||||
}
|
||||
let updated = state
|
||||
.db
|
||||
.audit_conn_update_note(&body.guid, &body.note)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
if !updated {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
+376
@@ -0,0 +1,376 @@
|
||||
use crate::api::email;
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::{sha256_token, AuthedUser};
|
||||
use crate::api::state::AppState;
|
||||
use crate::api::users::{verify_password, UserPayload};
|
||||
use crate::database::UserRow;
|
||||
use axum::extract::Extension;
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
use totp_rs::{Algorithm, Secret, TOTP};
|
||||
|
||||
const EMAIL_CODE_TTL_SECS: i64 = 600;
|
||||
|
||||
/// `LoginRequest` mirrors the Flutter client at
|
||||
/// flutter/lib/common/hbbs/hbbs.dart:133. M1 only consults `username`,
|
||||
/// `password`, and `type`; the other fields are tolerated for forward-compat.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub password: Option<String>,
|
||||
#[serde(default)]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub uuid: Option<String>,
|
||||
#[serde(default, rename = "type")]
|
||||
pub kind: Option<String>,
|
||||
#[serde(default, rename = "deviceInfo")]
|
||||
pub device_info: Option<Value>,
|
||||
// Tolerated, ignored in M1:
|
||||
#[serde(default)]
|
||||
pub auto_login: Option<bool>,
|
||||
#[serde(default, rename = "verificationCode")]
|
||||
pub verification_code: Option<String>,
|
||||
#[serde(default, rename = "tfaCode")]
|
||||
pub tfa_code: Option<String>,
|
||||
#[serde(default)]
|
||||
pub secret: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct IdUuidBody {
|
||||
#[serde(default)]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub uuid: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn login_options_head() -> StatusCode {
|
||||
StatusCode::OK
|
||||
}
|
||||
|
||||
pub async fn login_options(Extension(state): Extension<Arc<AppState>>) -> Json<Vec<String>> {
|
||||
// Static base set from config (account / email_code), plus a dynamic
|
||||
// `oidc/<name>` entry per enabled provider in the DB. Recomputed per
|
||||
// request so adding a provider via SQL takes effect without a restart.
|
||||
let mut out = state.cfg.login_options.clone();
|
||||
if !state.cfg.public_base_url.is_empty() {
|
||||
if let Ok(providers) = state.db.oidc_provider_list_enabled().await {
|
||||
for p in providers {
|
||||
out.push(format!("oidc/{}", p.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
Json(out)
|
||||
}
|
||||
|
||||
const TFA_CHALLENGE_TTL_SECS: i64 = 300;
|
||||
|
||||
pub async fn login(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
// The desktop client reuses the email-code dialog for the TOTP second
|
||||
// leg: it POSTs `type: "email_code"` with `tfaCode` set (and the email
|
||||
// `verificationCode` field also set, but we ignore that when tfaCode is
|
||||
// present). Detect that shape up-front and route to the TOTP verifier;
|
||||
// otherwise dispatch on the declared `type`.
|
||||
let has_tfa = req.tfa_code.as_deref().is_some_and(|s| !s.is_empty())
|
||||
&& req.secret.as_deref().is_some_and(|s| !s.is_empty());
|
||||
if has_tfa {
|
||||
return login_tfa_code(state, req).await;
|
||||
}
|
||||
let kind = req.kind.as_deref().unwrap_or("account");
|
||||
match kind {
|
||||
"account" | "" => login_account(state, req).await,
|
||||
"tfa_code" => login_tfa_code(state, req).await,
|
||||
"email_code" => login_email_code(state, req).await,
|
||||
other => Err(ApiError::BadRequest(format!(
|
||||
"unsupported login type: {}",
|
||||
other
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Two-leg passwordless login by email. Leg 1 (no `verificationCode`) mints a
|
||||
/// fresh 6-digit code and emails it to the user (or logs to stdout when SMTP
|
||||
/// is unconfigured). Leg 2 (with `verificationCode`) verifies the code,
|
||||
/// consumes it, and issues an access token.
|
||||
async fn login_email_code(
|
||||
state: Arc<AppState>,
|
||||
req: LoginRequest,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
// The Flutter client passes the email/username in the `username` field;
|
||||
// accept it either as a literal email or as a username we can map to one.
|
||||
let identifier = req
|
||||
.username
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| ApiError::BadRequest("username (email) required".into()))?;
|
||||
let user = resolve_user_by_identifier(&state, identifier).await?;
|
||||
let email = if !user.email.is_empty() {
|
||||
user.email.clone()
|
||||
} else if user.username.contains('@') {
|
||||
// Operator bootstraps users with email-as-username — accept that.
|
||||
user.username.clone()
|
||||
} else {
|
||||
return Err(ApiError::BadRequest(
|
||||
"user has no email address on file".into(),
|
||||
));
|
||||
};
|
||||
|
||||
if let Some(code) = req
|
||||
.verification_code
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
// Leg 2: verify.
|
||||
let supplied_hash = sodiumoxide::crypto::hash::sha256::hash(code.as_bytes())
|
||||
.as_ref()
|
||||
.to_vec();
|
||||
let ok = state
|
||||
.db
|
||||
.email_code_verify(&email, &supplied_hash)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
if !ok {
|
||||
return Err(ApiError::BadCredentials);
|
||||
}
|
||||
if user.status == 0 {
|
||||
return Err(ApiError::AccountDisabled);
|
||||
}
|
||||
if user.status == -1 {
|
||||
return Err(ApiError::Unverified);
|
||||
}
|
||||
return issue_session(&state, &req, &user).await;
|
||||
}
|
||||
|
||||
// Leg 1: mint + send a fresh code.
|
||||
let (code, code_hash) = email::mint_code();
|
||||
state
|
||||
.db
|
||||
.email_code_create(&email, &code_hash, EMAIL_CODE_TTL_SECS)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
if let Err(e) = email::send_login_code(state.cfg.email.as_ref(), &email, &code).await {
|
||||
return Err(ApiError::Internal(e));
|
||||
}
|
||||
Ok(Json(json!({ "type": "email_check" })))
|
||||
}
|
||||
|
||||
async fn resolve_user_by_identifier(
|
||||
state: &AppState,
|
||||
identifier: &str,
|
||||
) -> Result<UserRow, ApiError> {
|
||||
if identifier.contains('@') {
|
||||
if let Some(u) = state
|
||||
.db
|
||||
.user_find_by_email(identifier)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?
|
||||
{
|
||||
return Ok(u);
|
||||
}
|
||||
}
|
||||
state
|
||||
.db
|
||||
.user_find_by_username(identifier)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?
|
||||
.ok_or(ApiError::BadCredentials)
|
||||
}
|
||||
|
||||
async fn login_account(
|
||||
state: Arc<AppState>,
|
||||
req: LoginRequest,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let username = req
|
||||
.username
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| ApiError::BadRequest("username required".into()))?;
|
||||
let password = req
|
||||
.password
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| ApiError::BadRequest("password required".into()))?;
|
||||
|
||||
let user = state
|
||||
.db
|
||||
.user_find_by_username(username)
|
||||
.await?
|
||||
.ok_or(ApiError::BadCredentials)?;
|
||||
|
||||
let ok = verify_password(user.password_hash.clone(), password.to_string()).await?;
|
||||
if !ok {
|
||||
return Err(ApiError::BadCredentials);
|
||||
}
|
||||
if user.status == 0 {
|
||||
return Err(ApiError::AccountDisabled);
|
||||
}
|
||||
if user.status == -1 {
|
||||
return Err(ApiError::Unverified);
|
||||
}
|
||||
|
||||
// 2FA gate: if the user has TOTP enrolled, mint a short-lived nonce and
|
||||
// tell the client we want the TOTP code in a follow-up POST. The client
|
||||
// echoes the nonce back as `secret`.
|
||||
//
|
||||
// Wire shape matches the Flutter client's expectations
|
||||
// (flutter/lib/common/widgets/login.dart:485): the outer `type` is the
|
||||
// generic `email_check` envelope (the dialog the client opens for any
|
||||
// second-leg challenge), and `tfa_type` distinguishes TOTP (`tfa_check`)
|
||||
// from email (`email_check`). Returning `type:"tfa_check"` directly
|
||||
// would miss the switch's only branch and surface as the unhelpful
|
||||
// "bad response from server" toast.
|
||||
if state.db.totp_get_secret(user.id).await?.is_some() {
|
||||
let nonce = state
|
||||
.db
|
||||
.tfa_challenge_create(user.id, TFA_CHALLENGE_TTL_SECS)
|
||||
.await?;
|
||||
return Ok(Json(json!({
|
||||
"type": "email_check",
|
||||
"tfa_type": "tfa_check",
|
||||
"secret": nonce,
|
||||
})));
|
||||
}
|
||||
|
||||
issue_session(&state, &req, &user).await
|
||||
}
|
||||
|
||||
async fn login_tfa_code(
|
||||
state: Arc<AppState>,
|
||||
req: LoginRequest,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let nonce = req
|
||||
.secret
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| ApiError::BadRequest("secret required".into()))?;
|
||||
let code = req
|
||||
.tfa_code
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| ApiError::BadRequest("tfaCode required".into()))?;
|
||||
|
||||
let user_id = state
|
||||
.db
|
||||
.tfa_challenge_lookup(nonce)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::BadRequest("invalid or expired challenge".into()))?;
|
||||
let secret_b32 = state
|
||||
.db
|
||||
.totp_get_secret(user_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::BadRequest("TOTP not enrolled".into()))?;
|
||||
|
||||
if !verify_totp(&secret_b32, code)? {
|
||||
// Leave the challenge row alive — operators may want short retries.
|
||||
return Err(ApiError::BadCredentials);
|
||||
}
|
||||
state.db.tfa_challenge_consume(nonce).await?;
|
||||
|
||||
let user = state
|
||||
.db
|
||||
.user_find_by_id(user_id)
|
||||
.await?
|
||||
.ok_or(ApiError::Unauthorized)?;
|
||||
issue_session(&state, &req, &user).await
|
||||
}
|
||||
|
||||
/// Build and persist a fresh access token, claim the calling device, and
|
||||
/// return the standard logged-in response shape. Shared by the password,
|
||||
/// post-TOTP, post-email-code, and (later) post-OIDC paths.
|
||||
async fn issue_session(
|
||||
state: &AppState,
|
||||
req: &LoginRequest,
|
||||
user: &UserRow,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let token = mint_token();
|
||||
let sha = sha256_token(&token);
|
||||
let device_info_json = req
|
||||
.device_info
|
||||
.as_ref()
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_default();
|
||||
state
|
||||
.db
|
||||
.token_insert(
|
||||
user.id,
|
||||
&sha,
|
||||
req.id.as_deref().unwrap_or_default(),
|
||||
req.uuid.as_deref().unwrap_or_default(),
|
||||
&device_info_json,
|
||||
state.cfg.session_ttl_secs,
|
||||
)
|
||||
.await?;
|
||||
// Bind the calling device to this user so /api/peers shows it correctly.
|
||||
state
|
||||
.db
|
||||
.device_claim(
|
||||
user.id,
|
||||
req.id.as_deref().unwrap_or_default(),
|
||||
req.uuid.as_deref().unwrap_or_default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Json(json!({
|
||||
"access_token": token,
|
||||
"type": "access_token",
|
||||
"user": UserPayload::from(user),
|
||||
})))
|
||||
}
|
||||
|
||||
pub(crate) fn verify_totp(secret_b32: &str, code: &str) -> Result<bool, ApiError> {
|
||||
let secret = Secret::Encoded(secret_b32.to_string())
|
||||
.to_bytes()
|
||||
.map_err(|e| ApiError::Internal(format!("bad TOTP secret: {:?}", e)))?;
|
||||
let totp = TOTP::new(Algorithm::SHA1, 6, 1, 30, secret)
|
||||
.map_err(|e| ApiError::Internal(format!("TOTP init: {}", e)))?;
|
||||
totp.check_current(code)
|
||||
.map_err(|e| ApiError::Internal(format!("TOTP check: {}", e)))
|
||||
}
|
||||
|
||||
pub async fn current_user(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
// Body is required by the client but its content is purely advisory.
|
||||
Json(_body): Json<IdUuidBody>,
|
||||
) -> Result<Json<UserPayload>, ApiError> {
|
||||
let row = state
|
||||
.db
|
||||
.user_find_by_id(user.user_id)
|
||||
.await?
|
||||
.ok_or(ApiError::Unauthorized)?;
|
||||
Ok(Json(UserPayload::from(&row)))
|
||||
}
|
||||
|
||||
pub async fn logout(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(_body): Json<IdUuidBody>,
|
||||
) -> StatusCode {
|
||||
// Best-effort: parse the bearer ourselves so a missing/invalid token still
|
||||
// returns 200 (matches the client's fire-and-forget logout flow).
|
||||
if let Some(auth) = headers.get(axum::http::header::AUTHORIZATION) {
|
||||
if let Ok(s) = auth.to_str() {
|
||||
if let Some(tok) = s.strip_prefix("Bearer ").map(str::trim) {
|
||||
if !tok.is_empty() {
|
||||
let sha = sha256_token(tok);
|
||||
let _ = state.db.token_delete(&sha).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
StatusCode::OK
|
||||
}
|
||||
|
||||
pub(crate) fn mint_token() -> String {
|
||||
let bytes = sodiumoxide::randombytes::randombytes(32);
|
||||
base64::encode_config(bytes, base64::URL_SAFE_NO_PAD)
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
//! Ed25519-signature gate for the agent-facing HTTP API
|
||||
//! (`/api/heartbeat`, `/api/sysinfo`).
|
||||
//!
|
||||
//! Trust root: the device's Ed25519 public key is already written into
|
||||
//! `peer.pk` during the rendezvous `RegisterPk` handshake (TCP/protobuf,
|
||||
//! port 21116). That handshake proves possession of the matching private key
|
||||
//! to the rendezvous server — so any later HTTP request signed by the same
|
||||
//! key is provably from the same device.
|
||||
//!
|
||||
//! Cutover: per-peer. `peer.managed = 0` (default) keeps stock-client
|
||||
//! behaviour — no signature required. `managed = 1` requires a valid sig on
|
||||
//! every request. The flag flips from 0→1 on the first valid signature we
|
||||
//! observe (TOFU) or via the admin endpoint. It never flips back from a
|
||||
//! request — only an admin can downgrade.
|
||||
//!
|
||||
//! Wire format (both headers required on signed requests):
|
||||
//! X-RD-Device-Id: <id>
|
||||
//! X-RD-Signature: v1.<unix_ts>.<base64(ed25519_sig)>
|
||||
//! where the signed message is:
|
||||
//! "rd-api-v1\n" || METHOD || "\n" || PATH || "\n" || TS || "\n" || sha256(body)
|
||||
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::state::AppState;
|
||||
use axum::http::HeaderMap;
|
||||
use lazy_static::lazy_static;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::Arc;
|
||||
|
||||
const SIG_VERSION: &str = "v1";
|
||||
const HEADER_ID: &str = "x-rd-device-id";
|
||||
const HEADER_SIG: &str = "x-rd-signature";
|
||||
const SKEW_TOLERANCE_SECS: i64 = 300;
|
||||
const REPLAY_WINDOW_SECS: i64 = 600;
|
||||
const REPLAY_CACHE_MAX: usize = 16_384;
|
||||
|
||||
/// Outcome of running the gate. The handler uses this to decide which `id`
|
||||
/// to trust as the device identity:
|
||||
/// - `Verified` → caller is cryptographically that device.
|
||||
/// - `LegacyUnsigned` → managed=0 peer that sent no sig headers; the
|
||||
/// handler may proceed but the body `id` is trusted only weakly
|
||||
/// (same risk as today). The handler still calls `get_peer` to confirm
|
||||
/// the id is known.
|
||||
pub enum AuthOutcome {
|
||||
Verified { id: String },
|
||||
LegacyUnsigned,
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
/// Replay cache. Key: "<id>|<ts>|<sig_first32>". Value: expiry unix ts.
|
||||
/// Small enough that the sweep-on-insert cost is negligible.
|
||||
static ref REPLAY: Mutex<HashMap<String, i64>> = Mutex::new(HashMap::new());
|
||||
}
|
||||
|
||||
pub async fn verify(
|
||||
state: &Arc<AppState>,
|
||||
method: &str,
|
||||
path: &str,
|
||||
headers: &HeaderMap,
|
||||
body: &[u8],
|
||||
) -> Result<AuthOutcome, ApiError> {
|
||||
let sig_hdr = headers.get(HEADER_SIG).and_then(|v| v.to_str().ok());
|
||||
let id_hdr = headers.get(HEADER_ID).and_then(|v| v.to_str().ok());
|
||||
|
||||
// No signature headers at all → legacy path. Even then we still need to
|
||||
// check that the peer (if it claims an id in the body) isn't marked
|
||||
// `managed=1`. The handler doesn't know the body id yet, so we defer
|
||||
// the managed-check to a second call (`enforce_managed_for_id`) after
|
||||
// the handler has parsed the body. Returning LegacyUnsigned here just
|
||||
// means "no sig present, you must call enforce_managed_for_id next".
|
||||
let (sig_hdr, id_hdr) = match (sig_hdr, id_hdr) {
|
||||
(Some(s), Some(i)) if !s.is_empty() && !i.is_empty() => (s, i),
|
||||
(None, None) => return Ok(AuthOutcome::LegacyUnsigned),
|
||||
// Partial headers: someone tried to sign but messed up the request.
|
||||
// Don't fall through to legacy — treat as an outright failure so we
|
||||
// don't silently downgrade a misconfigured agent.
|
||||
_ => {
|
||||
hbb_common::log::warn!(
|
||||
"signed API {}: partial headers (id={:?}, sig_present={})",
|
||||
path,
|
||||
id_hdr,
|
||||
sig_hdr.map(|s| !s.is_empty()).unwrap_or(false),
|
||||
);
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
};
|
||||
|
||||
// Parse "v1.<ts>.<b64>".
|
||||
let mut parts = sig_hdr.splitn(3, '.');
|
||||
let ver = parts.next().unwrap_or("");
|
||||
let ts_s = parts.next().unwrap_or("");
|
||||
let sig_b64 = parts.next().unwrap_or("");
|
||||
if ver != SIG_VERSION || ts_s.is_empty() || sig_b64.is_empty() {
|
||||
hbb_common::log::warn!(
|
||||
"signed API {} from {}: malformed signature header (ver={:?})",
|
||||
path, id_hdr, ver,
|
||||
);
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
let ts: i64 = match ts_s.parse() {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
hbb_common::log::warn!(
|
||||
"signed API {} from {}: bad timestamp {:?}",
|
||||
path, id_hdr, ts_s,
|
||||
);
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
};
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
if (now - ts).abs() > SKEW_TOLERANCE_SECS {
|
||||
hbb_common::log::warn!(
|
||||
"signed API {} from {}: clock skew {}s exceeds {}s tolerance",
|
||||
path, id_hdr, (now - ts).abs(), SKEW_TOLERANCE_SECS,
|
||||
);
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
let sig_bytes = match base64::decode(sig_b64) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
hbb_common::log::warn!(
|
||||
"signed API {} from {}: base64 decode failed: {}",
|
||||
path, id_hdr, e,
|
||||
);
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
};
|
||||
|
||||
// Replay check before the expensive crypto. The (id, ts, sig-prefix)
|
||||
// tuple is unique per request from a non-broken agent.
|
||||
let replay_key = {
|
||||
let prefix: String = sig_b64.chars().take(32).collect();
|
||||
format!("{}|{}|{}", id_hdr, ts, prefix)
|
||||
};
|
||||
{
|
||||
let mut cache = REPLAY.lock().unwrap();
|
||||
cache.retain(|_, exp| *exp > now);
|
||||
if cache.contains_key(&replay_key) {
|
||||
hbb_common::log::warn!(
|
||||
"signed API {} from {}: replay rejected",
|
||||
path, id_hdr,
|
||||
);
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
if cache.len() < REPLAY_CACHE_MAX {
|
||||
cache.insert(replay_key, now + REPLAY_WINDOW_SECS);
|
||||
}
|
||||
// If the cache is full we accept (no DoS via cache exhaustion). The
|
||||
// 5-min skew window already bounds replay risk.
|
||||
}
|
||||
|
||||
// Look up the peer's pk and managed flag in one query.
|
||||
let row = state
|
||||
.db
|
||||
.peer_get_auth(id_hdr)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let (pk_bytes, managed) = match row {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
// Early-boot race: the agent generates its keypair and starts
|
||||
// signing API requests before its `--server` child has done
|
||||
// the rendezvous RegisterPk handshake that creates the peer
|
||||
// row. Returning Unauthorized here would leave brand-new
|
||||
// agents stuck — the retry loop is designed around the
|
||||
// ID_NOT_FOUND response from the handler, not a hard auth
|
||||
// failure. Fall through to legacy so the handler can answer
|
||||
// ID_NOT_FOUND; the next retry after RegisterPk completes
|
||||
// will validate normally and TOFU-promote.
|
||||
hbb_common::log::debug!(
|
||||
"signed API request for unregistered peer {} — pre-rendezvous race, \
|
||||
deferring to legacy path",
|
||||
id_hdr,
|
||||
);
|
||||
return Ok(AuthOutcome::LegacyUnsigned);
|
||||
}
|
||||
};
|
||||
if pk_bytes.is_empty() {
|
||||
// Peer row exists (rendezvous touched it) but no PK yet — same
|
||||
// race as above, mid-handshake. Defer to legacy; the handler's
|
||||
// `enforce_managed_for_id` still protects this peer if it was
|
||||
// somehow flagged managed=1 with no pk.
|
||||
hbb_common::log::debug!(
|
||||
"signed API request for peer {} with empty pk — deferring to legacy path",
|
||||
id_hdr,
|
||||
);
|
||||
return Ok(AuthOutcome::LegacyUnsigned);
|
||||
}
|
||||
|
||||
// Build the canonical signed message:
|
||||
// "rd-api-v1\n" || METHOD || "\n" || PATH || "\n" || TS || "\n" || sha256(body)
|
||||
let body_sha = sodiumoxide::crypto::hash::sha256::hash(body);
|
||||
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 pk = match sodiumoxide::crypto::sign::PublicKey::from_slice(&pk_bytes) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
hbb_common::log::warn!(
|
||||
"signed API {} from {}: stored pk ({}B) is not a valid Ed25519 public key",
|
||||
path, id_hdr, pk_bytes.len(),
|
||||
);
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
};
|
||||
let sig = match sodiumoxide::crypto::sign::Signature::from_bytes(&sig_bytes) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
hbb_common::log::warn!(
|
||||
"signed API {} from {}: signature length {} is not the Ed25519 size",
|
||||
path, id_hdr, sig_bytes.len(),
|
||||
);
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
};
|
||||
if !sodiumoxide::crypto::sign::verify_detached(&sig, &msg, &pk) {
|
||||
// The agent's keypair doesn't match the pk stored in `peer`.
|
||||
// Usually this means a config was wiped/regenerated on the agent
|
||||
// side without the server's row being cleared — the next
|
||||
// successful RegisterPk handshake will fix it.
|
||||
hbb_common::log::warn!(
|
||||
"signed API {} from {}: signature does NOT verify against stored pk \
|
||||
(agent's keypair differs from the one rendezvous registered) \
|
||||
— managed={}",
|
||||
path, id_hdr, managed,
|
||||
);
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
|
||||
// TOFU promote: first valid sig flips managed=0 → 1. After this, the
|
||||
// same device can no longer fall back to the legacy unsigned path.
|
||||
if !managed {
|
||||
if let Err(e) = state.db.peer_set_managed(id_hdr, true).await {
|
||||
hbb_common::log::warn!("peer_set_managed({}) failed: {}", id_hdr, e);
|
||||
// Don't fail the request — the sig was valid, the promote is
|
||||
// best-effort. Next request will retry the promote.
|
||||
} else {
|
||||
hbb_common::log::info!("peer {} TOFU-promoted to managed=1", id_hdr);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(AuthOutcome::Verified {
|
||||
id: id_hdr.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Called by handlers AFTER they've parsed the body and extracted the
|
||||
/// device id. Only meaningful when `verify` returned `LegacyUnsigned`.
|
||||
/// Enforces: if the peer is currently managed=1, an unsigned request for
|
||||
/// that id must be rejected.
|
||||
pub async fn enforce_managed_for_id(
|
||||
state: &Arc<AppState>,
|
||||
id: &str,
|
||||
) -> Result<(), ApiError> {
|
||||
if id.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let row = state
|
||||
.db
|
||||
.peer_get_auth(id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
match row {
|
||||
Some((_, true)) => {
|
||||
hbb_common::log::warn!(
|
||||
"rejecting unsigned API request for managed peer {}",
|
||||
id,
|
||||
);
|
||||
Err(ApiError::Unauthorized)
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
//! `POST /api/devices/cli` — used by `rustdesk --assign --token <T> ...`
|
||||
//! to enroll a freshly installed device into a tenant slot.
|
||||
//!
|
||||
//! Per CONSOLE_API.md §11: bearer-authenticated; the response body is plain
|
||||
//! text (empty = success, non-empty = informational message). The client
|
||||
//! prints "Done!" when the body is empty.
|
||||
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use crate::api::state::AppState;
|
||||
use crate::database::AbPeerInsert;
|
||||
use axum::extract::Extension;
|
||||
use axum::http::header;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AssignBody {
|
||||
pub id: String,
|
||||
pub uuid: String,
|
||||
#[serde(default)]
|
||||
pub user_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub strategy_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub address_book_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub address_book_tag: Option<String>,
|
||||
#[serde(default)]
|
||||
pub address_book_alias: Option<String>,
|
||||
#[serde(default)]
|
||||
pub address_book_password: Option<String>,
|
||||
#[serde(default)]
|
||||
pub address_book_note: Option<String>,
|
||||
#[serde(default)]
|
||||
pub device_group_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub note: Option<String>,
|
||||
#[serde(default)]
|
||||
pub device_username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub device_name: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn assign(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
caller: AuthedUser,
|
||||
Json(body): Json<AssignBody>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
if body.id.is_empty() || body.uuid.is_empty() {
|
||||
return Err(ApiError::BadRequest("id and uuid required".into()));
|
||||
}
|
||||
let mut warnings: Vec<String> = vec![];
|
||||
|
||||
// Resolve owner. If --user_name was supplied, that's the owner; otherwise
|
||||
// the caller becomes the owner (matches `rustdesk --assign` flows where
|
||||
// the operator's account is the destination).
|
||||
let owner = if let Some(name) = body.user_name.as_deref().filter(|s| !s.is_empty()) {
|
||||
if !caller.is_admin {
|
||||
return Err(ApiError::Forbidden(
|
||||
"admin required to assign to another user".into(),
|
||||
));
|
||||
}
|
||||
match state
|
||||
.db
|
||||
.user_find_by_username(name)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?
|
||||
{
|
||||
Some(u) => u,
|
||||
None => {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"no such user: {}",
|
||||
name
|
||||
)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
state
|
||||
.db
|
||||
.user_find_by_id(caller.user_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?
|
||||
.ok_or(ApiError::Unauthorized)?
|
||||
};
|
||||
|
||||
// Bind the device to the owner (mirrors what /api/login's device_claim
|
||||
// does, but here it's an admin operation rather than user-initiated).
|
||||
state.db.device_claim(owner.id, &body.id, &body.uuid).await;
|
||||
|
||||
// Address-book entry. We always target the *owner's* personal AB.
|
||||
if let Some(ab_name) = body.address_book_name.as_deref().filter(|s| !s.is_empty()) {
|
||||
let _ = ab_name; // M2's get_or_create_personal ignores the name; OSS has one personal AB per user.
|
||||
let ab_guid = state
|
||||
.db
|
||||
.ab_get_or_create_personal(owner.id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let tags: Option<Vec<String>> = body
|
||||
.address_book_tag
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|t| t.split(',').map(|s| s.trim().to_string()).collect());
|
||||
if let Err(e) = state
|
||||
.db
|
||||
.ab_peer_insert(
|
||||
&ab_guid,
|
||||
AbPeerInsert {
|
||||
id: &body.id,
|
||||
alias: body.address_book_alias.as_deref(),
|
||||
note: body.address_book_note.as_deref(),
|
||||
password: body.address_book_password.as_deref(),
|
||||
hash: None,
|
||||
username: body.device_username.as_deref(),
|
||||
hostname: body.device_name.as_deref(),
|
||||
platform: None,
|
||||
},
|
||||
tags.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
// Likely a UNIQUE conflict if the peer is already in the AB;
|
||||
// surface as a warning rather than failing the whole call.
|
||||
warnings.push(format!("address-book entry not added: {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy assignment by name. We attach to the device directly (peer-scoped),
|
||||
// which is the most-specific tier in our resolver.
|
||||
if let Some(name) = body.strategy_name.as_deref().filter(|s| !s.is_empty()) {
|
||||
match resolve_strategy_id(&state, name).await? {
|
||||
Some(strategy_id) => {
|
||||
if let Err(e) = state
|
||||
.db
|
||||
.strategy_assign_peer(strategy_id, &body.id)
|
||||
.await
|
||||
{
|
||||
warnings.push(format!("strategy assignment failed: {}", e));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
warnings.push(format!("strategy {:?} does not exist", name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Device-group membership: ensure the group exists, ensure the owner is a
|
||||
// member. We treat the group name as the natural key per the M2 schema.
|
||||
if let Some(group_name) = body.device_group_name.as_deref().filter(|s| !s.is_empty()) {
|
||||
if let Err(e) = state
|
||||
.db
|
||||
.device_group_ensure_member(group_name, owner.id)
|
||||
.await
|
||||
{
|
||||
warnings.push(format!("device-group assignment failed: {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
// Fields we accept but don't currently persist as discrete columns. These
|
||||
// travel with the next sysinfo upload anyway (note, device_username,
|
||||
// device_name end up in `device_sysinfo.payload` JSON).
|
||||
if body.note.as_deref().map(|s| !s.is_empty()).unwrap_or(false) {
|
||||
warnings.push(
|
||||
"--note is currently surfaced via sysinfo only, not persisted as a discrete field"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let body_text = if warnings.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
warnings.join("\n")
|
||||
};
|
||||
Ok((
|
||||
[(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
|
||||
body_text,
|
||||
))
|
||||
}
|
||||
|
||||
async fn resolve_strategy_id(
|
||||
state: &AppState,
|
||||
name: &str,
|
||||
) -> Result<Option<i64>, ApiError> {
|
||||
state
|
||||
.db
|
||||
.strategy_find_by_name(name)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
/// Wrap the `Value` JSON the request _could_ have under `Json<Value>` if a
|
||||
/// future variation needs it. Currently unused; kept for symmetry with other
|
||||
/// modules that work with raw JSON in/out.
|
||||
#[allow(dead_code)]
|
||||
fn ignore_value(_v: Value) {}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! SMTP transport for email-code login. Two modes:
|
||||
//!
|
||||
//! - **Production:** `--smtp-host` (and friends) configured → real SMTP via
|
||||
//! `lettre` with optional STARTTLS + auth.
|
||||
//! - **Dev:** `--smtp-host` empty → the code is logged to stdout instead.
|
||||
//! This makes the round-trip testable without standing up a mail server.
|
||||
|
||||
use crate::api::state::EmailConfig;
|
||||
use hbb_common::log;
|
||||
use lettre::message::header::ContentType;
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::transport::smtp::AsyncSmtpTransport;
|
||||
use lettre::{AsyncTransport, Message, Tokio1Executor};
|
||||
|
||||
pub async fn send_login_code(
|
||||
cfg: Option<&EmailConfig>,
|
||||
to: &str,
|
||||
code: &str,
|
||||
) -> Result<(), String> {
|
||||
if to.is_empty() {
|
||||
return Err("recipient address is empty".into());
|
||||
}
|
||||
let Some(cfg) = cfg else {
|
||||
// Dev mode: surface the code so the operator can complete the flow.
|
||||
log::info!("[email-code] login code for <{}>: {}", to, code);
|
||||
return Ok(());
|
||||
};
|
||||
let body = format!(
|
||||
"Your login code is: {}\n\nIt expires in 10 minutes.\nIf you didn't request this, ignore this email.\n",
|
||||
code
|
||||
);
|
||||
let message = Message::builder()
|
||||
.from(
|
||||
cfg.from
|
||||
.parse()
|
||||
.map_err(|e| format!("invalid From address {:?}: {}", cfg.from, e))?,
|
||||
)
|
||||
.to(to.parse().map_err(|e| format!("invalid To address {:?}: {}", to, e))?)
|
||||
.subject("Your RustDesk login code")
|
||||
.header(ContentType::TEXT_PLAIN)
|
||||
.body(body)
|
||||
.map_err(|e| format!("compose: {}", e))?;
|
||||
|
||||
let mut builder = if cfg.starttls {
|
||||
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&cfg.host)
|
||||
.map_err(|e| format!("STARTTLS init for {}: {}", cfg.host, e))?
|
||||
} else {
|
||||
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&cfg.host)
|
||||
}
|
||||
.port(cfg.port);
|
||||
if let (Some(user), Some(pass)) = (cfg.username.as_deref(), cfg.password.as_deref()) {
|
||||
builder = builder.credentials(Credentials::new(user.to_string(), pass.to_string()));
|
||||
}
|
||||
let transport = builder.build();
|
||||
transport
|
||||
.send(message)
|
||||
.await
|
||||
.map_err(|e| format!("smtp send to {}: {}", cfg.host, e))?;
|
||||
log::info!("[email-code] code mailed to <{}>", to);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate a 6-digit numeric code with cryptographic entropy. Returns the
|
||||
/// code as a string and its sha256 for storage.
|
||||
pub fn mint_code() -> (String, Vec<u8>) {
|
||||
// Sample 4 random bytes, fold into 0..1_000_000, format as 6-digit
|
||||
// zero-padded decimal. 24 bits of entropy is plenty for a 10-minute
|
||||
// 5-attempt-limit code.
|
||||
let bytes = sodiumoxide::randombytes::randombytes(4);
|
||||
let mut n: u32 = 0;
|
||||
for b in &bytes {
|
||||
n = (n << 8) | (*b as u32);
|
||||
}
|
||||
let n = n % 1_000_000;
|
||||
let code = format!("{:06}", n);
|
||||
let hash = sodiumoxide::crypto::hash::sha256::hash(code.as_bytes())
|
||||
.as_ref()
|
||||
.to_vec();
|
||||
(code, hash)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde_json::json;
|
||||
|
||||
/// Single error type for the management API. Always serializes to
|
||||
/// `{"error":"..."}` per the protocol spec; the HTTP status is chosen so the
|
||||
/// client behaves correctly:
|
||||
///
|
||||
/// - 401 Unauthorized clears the local access_token (intentional fallback in
|
||||
/// the Flutter client — see CONSOLE_API.md §3.6).
|
||||
/// - 200 OK + JSON `error` for business failures (bad creds, validation).
|
||||
/// Most non-auth handlers should return BadRequest or Conflict instead so
|
||||
/// the operator can distinguish them in logs.
|
||||
#[derive(Debug)]
|
||||
pub enum ApiError {
|
||||
Unauthorized,
|
||||
BadCredentials,
|
||||
AccountDisabled,
|
||||
Unverified,
|
||||
Forbidden(String),
|
||||
NotFound,
|
||||
BadRequest(String),
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, msg) = match self {
|
||||
ApiError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".to_string()),
|
||||
ApiError::BadCredentials => (StatusCode::UNAUTHORIZED, "bad credentials".to_string()),
|
||||
ApiError::AccountDisabled => (StatusCode::FORBIDDEN, "account disabled".to_string()),
|
||||
ApiError::Unverified => (StatusCode::FORBIDDEN, "unverified".to_string()),
|
||||
// Returning HTTP 200 + {"error": ...} for share-rule rejections.
|
||||
// Flutter's _jsonDecodeActionResp at ab_model.dart:2002 surfaces
|
||||
// the JSON `error` field as a toast and stays signed-in; using
|
||||
// 403 here would trigger the global 401/403 logout path and yank
|
||||
// the user's session.
|
||||
ApiError::Forbidden(m) => (StatusCode::OK, m),
|
||||
ApiError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()),
|
||||
ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
|
||||
ApiError::Internal(m) => {
|
||||
hbb_common::log::error!("api internal error: {}", m);
|
||||
(StatusCode::OK, "internal error".to_string())
|
||||
}
|
||||
};
|
||||
(status, Json(json!({ "error": msg }))).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<hbb_common::anyhow::Error> for ApiError {
|
||||
fn from(e: hbb_common::anyhow::Error) -> Self {
|
||||
ApiError::Internal(e.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! `GET /api/device-group/accessible` — paginated list of device groups the
|
||||
//! caller is a member of (admin sees all). The Flutter client at
|
||||
//! flutter/lib/models/group_model.dart:103 silently tolerates errors here, so
|
||||
//! we keep the behavior tight: empty list when no groups exist, never panic.
|
||||
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use crate::api::pagination::{Page, PageQuery};
|
||||
use crate::api::state::AppState;
|
||||
use axum::extract::{Extension, Query};
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DeviceGroupOut {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
pub async fn accessible(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
Query(q): Query<PageQuery>,
|
||||
) -> Result<Json<Page<DeviceGroupOut>>, ApiError> {
|
||||
let (total, rows) = state
|
||||
.db
|
||||
.groups_list_for_user(user.user_id, user.is_admin, q.offset(), q.limit())
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(Json(Page {
|
||||
total,
|
||||
data: rows
|
||||
.into_iter()
|
||||
.map(|g| DeviceGroupOut { name: g.name })
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! `POST /api/heartbeat` — the agent management loop. The client sends every
|
||||
//! ~15 s (3 s when active connections exist). The reply may carry, in any
|
||||
//! combination:
|
||||
//! - `sysinfo: true` — force the client to re-upload sysinfo immediately,
|
||||
//! - `disconnect: [conn_id, ...]` — tell the client to drop those sessions,
|
||||
//! - `modified_at` + `strategy` — push a config-options merge,
|
||||
//! - `exec: [{cmd_id, script, max_secs, max_bytes}, ...]` — PowerShell
|
||||
//! commands queued from the admin UI. The agent runs each and POSTs
|
||||
//! results to `/api/agent/exec-result`. See docs/AGENT-API-AUTH.md.
|
||||
//!
|
||||
//! Auth: signed agents (peer.managed=1) must carry `X-RD-Device-Id` +
|
||||
//! `X-RD-Signature` headers — see `device_auth::verify`. Stock clients
|
||||
//! (peer.managed=0) keep posting unsigned bodies; the first valid sig we
|
||||
//! see flips the peer to managed=1 (TOFU).
|
||||
|
||||
use crate::api::device_auth::{self, AuthOutcome};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::state::AppState;
|
||||
use crate::api::strategy;
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::Extension;
|
||||
use axum::http::HeaderMap;
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct HeartbeatBody {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub uuid: String,
|
||||
#[serde(default)]
|
||||
pub ver: i64,
|
||||
#[serde(default)]
|
||||
pub conns: Option<Vec<i64>>,
|
||||
#[serde(default)]
|
||||
pub modified_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct HeartbeatResp {
|
||||
/// Present-and-truthy → client re-uploads sysinfo immediately.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sysinfo: Option<bool>,
|
||||
/// Conn IDs the client should drop. Always present (possibly empty).
|
||||
pub disconnect: Vec<i64>,
|
||||
/// Strategy version. Echoed back by the client; when it changes, the
|
||||
/// client re-merges `strategy.config_options` into local config.
|
||||
pub modified_at: i64,
|
||||
pub strategy: Value,
|
||||
/// PowerShell commands queued for this peer. Omitted from the JSON
|
||||
/// reply when empty so vanilla rustdesk clients (which don't parse
|
||||
/// this field) see a payload that's byte-for-byte identical to what
|
||||
/// they received before this feature shipped.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub exec: Vec<ExecRequest>,
|
||||
}
|
||||
|
||||
/// What the agent receives per queued PowerShell command. Caps live on the
|
||||
/// server so the operator can tune fleet-wide without redeploying agents.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ExecRequest {
|
||||
pub cmd_id: String,
|
||||
pub script: String,
|
||||
pub max_secs: u64,
|
||||
pub max_bytes: u64,
|
||||
}
|
||||
|
||||
/// Wall-clock ceiling on a single PowerShell exec. Server-side cap; the
|
||||
/// agent kills the process when the deadline elapses and reports
|
||||
/// `timed_out=true` to `/api/agent/exec-result`.
|
||||
const EXEC_MAX_SECS: u64 = 300;
|
||||
/// Combined stdout+stderr byte ceiling. Past this the agent stops
|
||||
/// appending and sets `truncated=true`. 1 MiB matches the cap surfaced
|
||||
/// in the admin UI confirm dialog.
|
||||
const EXEC_MAX_BYTES: u64 = 1024 * 1024;
|
||||
|
||||
pub async fn heartbeat(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
raw: Bytes,
|
||||
) -> Result<Json<HeartbeatResp>, ApiError> {
|
||||
let outcome = device_auth::verify(&state, "POST", "/api/heartbeat", &headers, &raw).await?;
|
||||
let body: HeartbeatBody = serde_json::from_slice(&raw)
|
||||
.map_err(|_| ApiError::BadRequest("invalid json".into()))?;
|
||||
if body.id.is_empty() || body.uuid.is_empty() {
|
||||
return Err(ApiError::BadRequest("id and uuid required".into()));
|
||||
}
|
||||
match outcome {
|
||||
AuthOutcome::Verified { id: signed_id } => {
|
||||
if body.id != signed_id {
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
}
|
||||
AuthOutcome::LegacyUnsigned => {
|
||||
device_auth::enforce_managed_for_id(&state, &body.id).await?;
|
||||
}
|
||||
}
|
||||
let conns_json = serde_json::to_string(&body.conns.unwrap_or_default())
|
||||
.unwrap_or_else(|_| "[]".into());
|
||||
|
||||
let needs_sysinfo = state
|
||||
.db
|
||||
.sysinfo_heartbeat(
|
||||
&body.id,
|
||||
&body.uuid,
|
||||
body.ver,
|
||||
&conns_json,
|
||||
&state.cfg.sysinfo_ver,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// One-shot operator commands queued for this peer (force-disconnect,
|
||||
// force-sysinfo). Read-and-delete in one transaction.
|
||||
let mut disconnect: Vec<i64> = vec![];
|
||||
let mut force_sysinfo = needs_sysinfo;
|
||||
for cmd in state
|
||||
.db
|
||||
.heartbeat_pop_commands(&body.id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?
|
||||
{
|
||||
match cmd.kind.as_str() {
|
||||
"disconnect" => {
|
||||
if let Some(payload) = cmd.payload {
|
||||
if let Ok(arr) = serde_json::from_str::<Vec<i64>>(&payload) {
|
||||
disconnect.extend(arr);
|
||||
}
|
||||
}
|
||||
}
|
||||
"sysinfo" => force_sysinfo = true,
|
||||
other => hbb_common::log::warn!("unknown heartbeat_command kind {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy resolution (peer > device-group > user, highest priority wins).
|
||||
let (modified_at, strategy) = strategy::resolve_for(&state, &body.id).await;
|
||||
|
||||
// Pop any queued PowerShell exec commands for this peer and flip them
|
||||
// to 'running' in one transaction. The handler doesn't re-check the
|
||||
// strategy gate here — that was done at dispatch time. By the time a
|
||||
// row reaches 'queued' status, the operator has already passed all
|
||||
// checks; if the strategy changed since dispatch, the rows in flight
|
||||
// still ride out their lifecycle (an admin can flip the per-row state
|
||||
// via the dashboard if they need to abort, but that's a separate UI
|
||||
// surface — see future work in AGENT-API-AUTH.md).
|
||||
let exec = state
|
||||
.db
|
||||
.exec_pop_queued_for_peer(&body.id)
|
||||
.await
|
||||
.map(|rows| {
|
||||
rows.into_iter()
|
||||
.map(|r| ExecRequest {
|
||||
cmd_id: r.cmd_id,
|
||||
script: r.script,
|
||||
max_secs: EXEC_MAX_SECS,
|
||||
max_bytes: EXEC_MAX_BYTES,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_else(|e| {
|
||||
hbb_common::log::warn!("exec_pop_queued_for_peer({}) failed: {}", body.id, e);
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
Ok(Json(HeartbeatResp {
|
||||
sysinfo: if force_sysinfo { Some(true) } else { None },
|
||||
disconnect,
|
||||
modified_at,
|
||||
strategy,
|
||||
exec,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//! TCP-over-rendezvous HTTP fallback. The client wraps any `/api/*` request
|
||||
//! in an `HttpProxyRequest` protobuf and ships it over the rendezvous TCP
|
||||
//! connection (already encrypted via secure_tcp) when
|
||||
//! `OPTION_USE_RAW_TCP_FOR_API=Y`. We dispatch the wrapped request through
|
||||
//! the **same** axum `Router` the HTTPS listener uses, so every existing
|
||||
//! handler — auth, AB, audit, OIDC, … — is reachable through this path
|
||||
//! with zero per-route plumbing.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::Router;
|
||||
use hbb_common::log;
|
||||
use hbb_common::rendezvous_proto::{HeaderEntry, HttpProxyRequest, HttpProxyResponse};
|
||||
use http::header::{HeaderMap, HeaderName, HeaderValue};
|
||||
use http::{Method, Request};
|
||||
use once_cell::sync::Lazy;
|
||||
use std::convert::TryFrom;
|
||||
use std::sync::Mutex;
|
||||
use tower::ServiceExt;
|
||||
|
||||
/// Shared router. Populated by [`api::serve`] before the HTTPS listener
|
||||
/// starts, so that the rendezvous TCP path can reach the same handlers.
|
||||
/// `Mutex` because `Router` isn't `Sync` even though it is `Send + Clone`;
|
||||
/// we never hold the lock across an await — we clone out, drop the guard,
|
||||
/// and call `oneshot` on the clone.
|
||||
static ROUTER: Lazy<Mutex<Option<Router>>> = Lazy::new(|| Mutex::new(None));
|
||||
|
||||
pub fn install_router(r: Router) {
|
||||
*ROUTER.lock().unwrap() = Some(r);
|
||||
}
|
||||
|
||||
pub async fn dispatch(req: HttpProxyRequest) -> HttpProxyResponse {
|
||||
let router = match ROUTER.lock().unwrap().as_ref() {
|
||||
Some(r) => r.clone(),
|
||||
None => return error_response(503, "router not initialized"),
|
||||
};
|
||||
|
||||
let http_req = match build_request(&req) {
|
||||
Ok(r) => r,
|
||||
Err(msg) => return error_response(400, &msg),
|
||||
};
|
||||
|
||||
let response = match router.oneshot(http_req).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
log::warn!("http_proxy: router error: {}", e);
|
||||
return error_response(500, &format!("router: {}", e));
|
||||
}
|
||||
};
|
||||
|
||||
let status = response.status().as_u16() as i32;
|
||||
let headers = serialize_headers(response.headers());
|
||||
let body = match collect_body(response.into_body()).await {
|
||||
Ok(b) => b,
|
||||
Err(msg) => return error_response(500, &msg),
|
||||
};
|
||||
|
||||
HttpProxyResponse {
|
||||
status,
|
||||
headers,
|
||||
body: body.into(),
|
||||
error: String::new(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn build_request(req: &HttpProxyRequest) -> Result<Request<Body>, String> {
|
||||
let method = if req.method.is_empty() {
|
||||
Method::GET
|
||||
} else {
|
||||
Method::try_from(req.method.as_bytes())
|
||||
.map_err(|e| format!("invalid method {:?}: {}", req.method, e))?
|
||||
};
|
||||
let uri = if req.path.is_empty() {
|
||||
"/".to_string()
|
||||
} else if req.path.starts_with('/') {
|
||||
req.path.clone()
|
||||
} else {
|
||||
format!("/{}", req.path)
|
||||
};
|
||||
let body_bytes: Vec<u8> = req.body.to_vec();
|
||||
let mut builder = Request::builder().method(method).uri(uri);
|
||||
let headers_map = builder
|
||||
.headers_mut()
|
||||
.ok_or_else(|| "request builder produced no headers map".to_string())?;
|
||||
let mut saw_content_type = false;
|
||||
for h in &req.headers {
|
||||
if h.name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let lower = h.name.to_ascii_lowercase();
|
||||
// Drop hop-by-hop / framing headers we'll set ourselves to match
|
||||
// the actual body length axum sees.
|
||||
if matches!(
|
||||
lower.as_str(),
|
||||
"host" | "content-length" | "connection" | "transfer-encoding"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if lower == "content-type" {
|
||||
saw_content_type = true;
|
||||
}
|
||||
let name = HeaderName::try_from(h.name.as_bytes())
|
||||
.map_err(|e| format!("bad header name {:?}: {}", h.name, e))?;
|
||||
let value = HeaderValue::try_from(h.value.as_bytes())
|
||||
.map_err(|e| format!("bad header value for {:?}: {}", h.name, e))?;
|
||||
headers_map.append(name, value);
|
||||
}
|
||||
// Default to JSON if the client forgot — every /api/* handler expects
|
||||
// JSON unless the route reads `body` as raw bytes (only /api/record),
|
||||
// which doesn't care about content-type.
|
||||
if !saw_content_type {
|
||||
headers_map.insert(
|
||||
HeaderName::from_static("content-type"),
|
||||
HeaderValue::from_static("application/json"),
|
||||
);
|
||||
}
|
||||
builder
|
||||
.body(Body::from(body_bytes))
|
||||
.map_err(|e| format!("build request: {}", e))
|
||||
}
|
||||
|
||||
fn serialize_headers(map: &HeaderMap) -> Vec<HeaderEntry> {
|
||||
map.iter()
|
||||
.map(|(k, v)| HeaderEntry {
|
||||
name: k.as_str().to_string(),
|
||||
value: v.to_str().unwrap_or("").to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Collect any `http_body::Body` whose chunks are buffer-like into a `Vec<u8>`.
|
||||
/// Works against both the request `Body` we build and axum's
|
||||
/// `UnsyncBoxBody<Bytes, axum::Error>` response body.
|
||||
async fn collect_body<B>(mut body: B) -> Result<Vec<u8>, String>
|
||||
where
|
||||
B: http_body::Body + Unpin,
|
||||
B::Data: hbb_common::bytes::Buf,
|
||||
B::Error: std::fmt::Display,
|
||||
{
|
||||
use hbb_common::bytes::Buf;
|
||||
let mut buf = Vec::new();
|
||||
while let Some(chunk) = body.data().await {
|
||||
let mut chunk = chunk.map_err(|e| format!("body read: {}", e))?;
|
||||
while chunk.has_remaining() {
|
||||
let s = chunk.chunk();
|
||||
buf.extend_from_slice(s);
|
||||
let n = s.len();
|
||||
chunk.advance(n);
|
||||
}
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
fn error_response(status: i32, msg: &str) -> HttpProxyResponse {
|
||||
HttpProxyResponse {
|
||||
status,
|
||||
body: msg.as_bytes().to_vec().into(),
|
||||
error: msg.to_string(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! `POST /api/agent/login-event` — agent-side reporting of user logon /
|
||||
//! logoff events observed on the controlled machine. Surfaces a per-device
|
||||
//! login history on the admin Devices detail page.
|
||||
//!
|
||||
//! Auth: same per-peer signed-API gate as `/api/sysinfo` /
|
||||
//! `/api/heartbeat` / `/api/unattended-password` — see
|
||||
//! [`crate::api::device_auth`]. Stock RustDesk doesn't post here at all,
|
||||
//! so in practice every caller is a managed agent; we still keep the
|
||||
//! `LegacyUnsigned → enforce_managed_for_id` path for symmetry with the
|
||||
//! other agent endpoints.
|
||||
//!
|
||||
//! Body shape (events batched so an agent that was offline can catch up
|
||||
//! on reconnect):
|
||||
//!
|
||||
//! ```json
|
||||
//! {
|
||||
//! "id": "<peer id>",
|
||||
//! "uuid": "<peer uuid>",
|
||||
//! "events": [
|
||||
//! {
|
||||
//! "at": 1717920000,
|
||||
//! "kind": "logon", // or "logoff"
|
||||
//! "username": "alice",
|
||||
//! "domain": "CORP",
|
||||
//! "session_id": 2,
|
||||
//! "session_kind": "rdp" // or "console"
|
||||
//! }
|
||||
//! ]
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! Response: `"OK"` on success, `"ID_NOT_FOUND"` for an unregistered peer
|
||||
//! (same shape as `/api/unattended-password` so the agent can use a single
|
||||
//! retry helper for both).
|
||||
|
||||
use crate::api::device_auth::{self, AuthOutcome};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::state::AppState;
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::Extension;
|
||||
use axum::http::HeaderMap;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginEventIn {
|
||||
pub at: i64,
|
||||
pub kind: String,
|
||||
#[serde(default)]
|
||||
pub username: String,
|
||||
#[serde(default)]
|
||||
pub domain: String,
|
||||
#[serde(default)]
|
||||
pub session_id: i64,
|
||||
#[serde(default)]
|
||||
pub session_kind: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginEventBody {
|
||||
pub id: String,
|
||||
pub uuid: String,
|
||||
pub events: Vec<LoginEventIn>,
|
||||
}
|
||||
|
||||
/// Cap per-request to bound DB cost from a misbehaving / catching-up agent.
|
||||
const MAX_EVENTS_PER_POST: usize = 256;
|
||||
|
||||
pub async fn login_event(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<String, ApiError> {
|
||||
let outcome =
|
||||
device_auth::verify(&state, "POST", "/api/agent/login-event", &headers, &body).await?;
|
||||
|
||||
let payload: LoginEventBody = serde_json::from_slice(&body)
|
||||
.map_err(|_| ApiError::BadRequest("invalid json".into()))?;
|
||||
|
||||
if payload.id.is_empty() || payload.uuid.is_empty() {
|
||||
return Err(ApiError::BadRequest("id and uuid are required".into()));
|
||||
}
|
||||
if payload.events.is_empty() {
|
||||
return Ok("OK".to_string());
|
||||
}
|
||||
if payload.events.len() > MAX_EVENTS_PER_POST {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"too many events in one POST (max {MAX_EVENTS_PER_POST})"
|
||||
)));
|
||||
}
|
||||
|
||||
// Bind the trusted identity to the body. Same rule as the other agent
|
||||
// endpoints: signed → header id must equal body id; unsigned → peer
|
||||
// must not be `managed=1`.
|
||||
let id = match outcome {
|
||||
AuthOutcome::Verified { id: signed_id } => {
|
||||
if payload.id != signed_id {
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
signed_id
|
||||
}
|
||||
AuthOutcome::LegacyUnsigned => {
|
||||
device_auth::enforce_managed_for_id(&state, &payload.id).await?;
|
||||
payload.id.clone()
|
||||
}
|
||||
};
|
||||
|
||||
let peer = state
|
||||
.db
|
||||
.get_peer(&id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
if peer.is_none() {
|
||||
// Same shape as /api/unattended-password — agent treats this as
|
||||
// "retry later, rendezvous hasn't registered me yet".
|
||||
return Ok("ID_NOT_FOUND".to_string());
|
||||
}
|
||||
|
||||
let mut accepted = 0usize;
|
||||
for ev in &payload.events {
|
||||
let kind = ev.kind.trim();
|
||||
if kind != "logon" && kind != "logoff" {
|
||||
// Unknown kinds are ignored rather than 400ing the whole batch;
|
||||
// a future agent build that adds e.g. "lock" should be able to
|
||||
// post a mixed batch against an older server without losing
|
||||
// the known-kind rows.
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = state
|
||||
.db
|
||||
.login_event_insert(
|
||||
&id,
|
||||
&payload.uuid,
|
||||
ev.at,
|
||||
kind,
|
||||
ev.username.trim(),
|
||||
ev.domain.trim(),
|
||||
ev.session_id,
|
||||
ev.session_kind.trim(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
// Don't fail the whole batch on a single insert error — the
|
||||
// agent's retry loop will resend the events that didn't land,
|
||||
// and we'd rather record what we can than reject everything.
|
||||
hbb_common::log::warn!(
|
||||
"login_event_insert for peer {} failed: {}",
|
||||
id,
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
accepted += 1;
|
||||
}
|
||||
|
||||
hbb_common::log::debug!(
|
||||
"login-event: peer={} accepted={}/{}",
|
||||
id,
|
||||
accepted,
|
||||
payload.events.len()
|
||||
);
|
||||
Ok("OK".to_string())
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! `POST /api/agent/metrics` — continuous performance time-series the
|
||||
//! agent samples at ~1/min. The admin Devices detail page renders this
|
||||
//! as a CPU / memory sparkline plus a "current snapshot" card.
|
||||
//!
|
||||
//! Auth: same per-peer signed-API gate as the other agent endpoints —
|
||||
//! see [`crate::api::device_auth`]. Body shape (batched so an agent
|
||||
//! that's catching up after a transport outage can land everything in
|
||||
//! one POST):
|
||||
//!
|
||||
//! ```json
|
||||
//! {
|
||||
//! "id": "<peer id>",
|
||||
//! "uuid": "<peer uuid>",
|
||||
//! "samples": [
|
||||
//! {
|
||||
//! "at": 1717920000,
|
||||
//! "cpu_pct": 42.5,
|
||||
//! "mem_used_mb": 7820,
|
||||
//! "mem_total_mb": 16384,
|
||||
//! "proc_count": 341,
|
||||
//! "uptime_secs": 173000,
|
||||
//! "top_cpu_name": "chrome.exe",
|
||||
//! "top_cpu_pct": 18.3,
|
||||
//! "top_mem_name": "chrome.exe",
|
||||
//! "top_mem_mb": 1240
|
||||
//! }
|
||||
//! ]
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use crate::api::device_auth::{self, AuthOutcome};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::state::AppState;
|
||||
use crate::database::MetricsSampleRow;
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::Extension;
|
||||
use axum::http::HeaderMap;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MetricsSampleIn {
|
||||
pub at: i64,
|
||||
#[serde(default)]
|
||||
pub cpu_pct: f64,
|
||||
#[serde(default)]
|
||||
pub mem_used_mb: i64,
|
||||
#[serde(default)]
|
||||
pub mem_total_mb: i64,
|
||||
#[serde(default)]
|
||||
pub proc_count: i64,
|
||||
#[serde(default)]
|
||||
pub uptime_secs: i64,
|
||||
#[serde(default)]
|
||||
pub top_cpu_name: String,
|
||||
#[serde(default)]
|
||||
pub top_cpu_pct: f64,
|
||||
#[serde(default)]
|
||||
pub top_mem_name: String,
|
||||
#[serde(default)]
|
||||
pub top_mem_mb: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MetricsBody {
|
||||
pub id: String,
|
||||
pub uuid: String,
|
||||
pub samples: Vec<MetricsSampleIn>,
|
||||
}
|
||||
|
||||
/// Cap per request. At 60s sampling cadence + the agent's 30-minute
|
||||
/// retry-and-drain budget, even a long outage should fit well under this.
|
||||
const MAX_SAMPLES_PER_POST: usize = 512;
|
||||
|
||||
/// Defensive bound on string fields the agent puts in `top_*_name` — a
|
||||
/// runaway process name doesn't get to balloon the DB row.
|
||||
const MAX_PROC_NAME_LEN: usize = 128;
|
||||
|
||||
pub async fn metrics(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<String, ApiError> {
|
||||
let outcome =
|
||||
device_auth::verify(&state, "POST", "/api/agent/metrics", &headers, &body).await?;
|
||||
|
||||
let payload: MetricsBody = serde_json::from_slice(&body)
|
||||
.map_err(|_| ApiError::BadRequest("invalid json".into()))?;
|
||||
|
||||
if payload.id.is_empty() || payload.uuid.is_empty() {
|
||||
return Err(ApiError::BadRequest("id and uuid are required".into()));
|
||||
}
|
||||
if payload.samples.is_empty() {
|
||||
return Ok("OK".to_string());
|
||||
}
|
||||
if payload.samples.len() > MAX_SAMPLES_PER_POST {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"too many samples in one POST (max {MAX_SAMPLES_PER_POST})"
|
||||
)));
|
||||
}
|
||||
|
||||
let id = match outcome {
|
||||
AuthOutcome::Verified { id: signed_id } => {
|
||||
if payload.id != signed_id {
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
signed_id
|
||||
}
|
||||
AuthOutcome::LegacyUnsigned => {
|
||||
device_auth::enforce_managed_for_id(&state, &payload.id).await?;
|
||||
payload.id.clone()
|
||||
}
|
||||
};
|
||||
|
||||
let peer = state
|
||||
.db
|
||||
.get_peer(&id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
if peer.is_none() {
|
||||
return Ok("ID_NOT_FOUND".to_string());
|
||||
}
|
||||
|
||||
let mut accepted = 0usize;
|
||||
for s in &payload.samples {
|
||||
// Sanity-clamp the floats and string lengths. The agent should
|
||||
// produce well-formed values, but the public-API shape means
|
||||
// garbage-in shouldn't propagate to garbage-on-screen.
|
||||
let cpu_pct = clamp_pct(s.cpu_pct);
|
||||
let top_cpu_pct = clamp_pct(s.top_cpu_pct);
|
||||
let row = MetricsSampleRow {
|
||||
at: s.at,
|
||||
cpu_pct,
|
||||
mem_used_mb: s.mem_used_mb.max(0),
|
||||
mem_total_mb: s.mem_total_mb.max(0),
|
||||
proc_count: s.proc_count.max(0),
|
||||
uptime_secs: s.uptime_secs.max(0),
|
||||
top_cpu_name: truncate(&s.top_cpu_name, MAX_PROC_NAME_LEN),
|
||||
top_cpu_pct,
|
||||
top_mem_name: truncate(&s.top_mem_name, MAX_PROC_NAME_LEN),
|
||||
top_mem_mb: s.top_mem_mb.max(0),
|
||||
};
|
||||
if let Err(e) = state
|
||||
.db
|
||||
.metrics_sample_insert(&id, &payload.uuid, &row)
|
||||
.await
|
||||
{
|
||||
hbb_common::log::warn!(
|
||||
"metrics_sample_insert for peer {} failed: {}",
|
||||
id,
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
accepted += 1;
|
||||
}
|
||||
|
||||
hbb_common::log::debug!(
|
||||
"metrics: peer={} accepted={}/{}",
|
||||
id,
|
||||
accepted,
|
||||
payload.samples.len()
|
||||
);
|
||||
Ok("OK".to_string())
|
||||
}
|
||||
|
||||
fn clamp_pct(v: f64) -> f64 {
|
||||
if v.is_nan() {
|
||||
0.0
|
||||
} else {
|
||||
v.clamp(0.0, 100.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Char-aware truncate (so we don't slice mid-multibyte). The cap is
|
||||
/// generous so process names that include arguments or Unicode survive.
|
||||
fn truncate(s: &str, max_chars: usize) -> String {
|
||||
if s.chars().count() <= max_chars {
|
||||
s.to_string()
|
||||
} else {
|
||||
s.chars().take(max_chars).collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::state::AppState;
|
||||
use async_trait::async_trait;
|
||||
use axum::extract::{FromRequest, RequestParts};
|
||||
use axum::http::header;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Cookie name used by the admin dashboard. Browser-set, HttpOnly, SameSite=Strict.
|
||||
pub const SESSION_COOKIE: &str = "rd_admin_session";
|
||||
|
||||
pub struct AuthedUser {
|
||||
pub user_id: i64,
|
||||
pub name: String,
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
pub fn sha256_token(token: &str) -> Vec<u8> {
|
||||
sodiumoxide::crypto::hash::sha256::hash(token.as_bytes())
|
||||
.as_ref()
|
||||
.to_vec()
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<B: Send> FromRequest<B> for AuthedUser {
|
||||
type Rejection = ApiError;
|
||||
|
||||
async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
|
||||
let state: axum::extract::Extension<Arc<AppState>> =
|
||||
axum::extract::Extension::from_request(req)
|
||||
.await
|
||||
.map_err(|_| ApiError::Internal("missing state".into()))?;
|
||||
let token = extract_token(req).ok_or(ApiError::Unauthorized)?;
|
||||
let sha = sha256_token(&token);
|
||||
|
||||
let (user_id, _exp) = state
|
||||
.db
|
||||
.token_lookup(&sha)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?
|
||||
.ok_or(ApiError::Unauthorized)?;
|
||||
|
||||
// Slide the expiry forward on every authenticated request.
|
||||
if let Err(e) = state.db.token_touch(&sha, state.cfg.session_ttl_secs).await {
|
||||
hbb_common::log::warn!("token_touch failed: {}", e);
|
||||
}
|
||||
|
||||
let user = state
|
||||
.db
|
||||
.user_find_by_id(user_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?
|
||||
.ok_or(ApiError::Unauthorized)?;
|
||||
|
||||
Ok(Self {
|
||||
user_id: user.id,
|
||||
name: user.username,
|
||||
is_admin: user.is_admin,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a token from either the `Authorization: Bearer …` header (preferred,
|
||||
/// for the desktop client and curl) or the `rd_admin_session` cookie (for the
|
||||
/// browser-driven admin dashboard). Returns `None` if neither is present.
|
||||
fn extract_token<B>(req: &RequestParts<B>) -> Option<String> {
|
||||
// Bearer header wins so a curl smoke test always behaves predictably,
|
||||
// even when run from the same browser session.
|
||||
if let Some(auth) = req.headers().get(header::AUTHORIZATION) {
|
||||
if let Ok(s) = auth.to_str() {
|
||||
if let Some(tok) = s.strip_prefix("Bearer ").map(str::trim) {
|
||||
if !tok.is_empty() {
|
||||
return Some(tok.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Cookie header is a single line: `name=value; name2=value2; …`. Walk
|
||||
// the comma-or-semicolon-separated pairs without pulling in a cookie crate.
|
||||
if let Some(cookie_hdr) = req.headers().get(header::COOKIE) {
|
||||
if let Ok(s) = cookie_hdr.to_str() {
|
||||
for pair in s.split(';') {
|
||||
let pair = pair.trim();
|
||||
if let Some((name, value)) = pair.split_once('=') {
|
||||
if name.trim() == SESSION_COOKIE {
|
||||
let v = value.trim();
|
||||
if !v.is_empty() {
|
||||
return Some(v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
//! HTTP management API mounted in-process alongside hbbs's rendezvous
|
||||
//! listeners. The router is wired in via `src/rendezvous_server.rs`'s outer
|
||||
//! `tokio::select!`. M1 covers auth + heartbeat + sysinfo; later milestones
|
||||
//! add address book, audit, OIDC, etc.
|
||||
|
||||
pub mod ab;
|
||||
pub mod admin;
|
||||
pub mod agent_exec;
|
||||
pub mod audit;
|
||||
pub mod auth;
|
||||
pub mod device_auth;
|
||||
pub mod devices_cli;
|
||||
pub mod email;
|
||||
pub mod error;
|
||||
pub mod groups;
|
||||
pub mod heartbeat;
|
||||
pub mod http_proxy;
|
||||
pub mod login_event;
|
||||
pub mod metrics;
|
||||
pub mod perf_events;
|
||||
pub mod middleware;
|
||||
pub mod oidc;
|
||||
pub mod pagination;
|
||||
pub mod peers;
|
||||
pub mod plugin_sign;
|
||||
pub mod record;
|
||||
pub mod state;
|
||||
pub mod strategy;
|
||||
pub mod sysinfo;
|
||||
pub mod twofa;
|
||||
pub mod unattended;
|
||||
pub mod users;
|
||||
|
||||
pub use state::AppState;
|
||||
|
||||
use axum::extract::Extension;
|
||||
use axum::routing::{delete, get, post, put};
|
||||
use axum::Router;
|
||||
use hbb_common::{log, ResultType};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub fn router(state: Arc<AppState>) -> Router {
|
||||
let app = Router::new()
|
||||
// M1: auth + heartbeat + sysinfo
|
||||
.route(
|
||||
"/api/login-options",
|
||||
get(auth::login_options).head(auth::login_options_head),
|
||||
)
|
||||
.route("/api/login", post(auth::login))
|
||||
.route("/api/currentUser", post(auth::current_user))
|
||||
.route("/api/logout", post(auth::logout))
|
||||
.route("/api/heartbeat", post(heartbeat::heartbeat))
|
||||
.route("/api/sysinfo_ver", post(sysinfo::sysinfo_ver))
|
||||
.route("/api/sysinfo", post(sysinfo::sysinfo))
|
||||
.route("/api/agent/exec-result", post(agent_exec::exec_result))
|
||||
.route("/api/agent/login-event", post(login_event::login_event))
|
||||
.route("/api/agent/metrics", post(metrics::metrics))
|
||||
.route("/api/agent/perf-events", post(perf_events::perf_events))
|
||||
.route(
|
||||
"/api/unattended-password",
|
||||
post(unattended::unattended_password),
|
||||
)
|
||||
// M2: address book — modern (shared + personal)
|
||||
.route("/api/ab/settings", post(ab::settings::settings))
|
||||
.route("/api/ab/personal", post(ab::profiles::personal))
|
||||
.route(
|
||||
"/api/ab/shared/profiles",
|
||||
post(ab::profiles::shared_profiles),
|
||||
)
|
||||
.route("/api/ab/peers", post(ab::peers::list))
|
||||
.route("/api/ab/tags/:guid", post(ab::tags::list))
|
||||
.route("/api/ab/peer/add/:guid", post(ab::peers::add))
|
||||
.route("/api/ab/peer/update/:guid", put(ab::peers::update))
|
||||
.route("/api/ab/peer/:guid", delete(ab::peers::delete))
|
||||
.route("/api/ab/tag/add/:guid", post(ab::tags::add))
|
||||
.route("/api/ab/tag/rename/:guid", put(ab::tags::rename))
|
||||
.route("/api/ab/tag/update/:guid", put(ab::tags::update))
|
||||
.route("/api/ab/tag/:guid", delete(ab::tags::delete))
|
||||
// M2: address book — legacy single-blob fallback
|
||||
.route(
|
||||
"/api/ab",
|
||||
get(ab::legacy::get).post(ab::legacy::put),
|
||||
)
|
||||
// M2: group / users / peers panel
|
||||
.route(
|
||||
"/api/device-group/accessible",
|
||||
get(groups::accessible),
|
||||
)
|
||||
.route("/api/users", get(users::list))
|
||||
.route("/api/peers", get(peers::list))
|
||||
.route("/api/peers/:id/managed", put(peers::set_managed))
|
||||
// M3: audit
|
||||
.route("/api/audit/conn", post(audit::conn::conn))
|
||||
.route("/api/audit/file", post(audit::file::file))
|
||||
.route("/api/audit/alarm", post(audit::alarm::alarm))
|
||||
.route("/api/audit", put(audit::note::note))
|
||||
// M3: session recording upload
|
||||
.route("/api/record", post(record::record))
|
||||
// M4: TOTP enrollment (admin-only)
|
||||
.route("/api/2fa/enroll", post(twofa::enroll))
|
||||
.route("/api/2fa/unenroll", post(twofa::unenroll))
|
||||
// M4: rustdesk --assign target
|
||||
.route("/api/devices/cli", post(devices_cli::assign))
|
||||
// M4: plugin signing (no auth — protocol-level)
|
||||
.route("/lic/web/api/plugin-sign", post(plugin_sign::plugin_sign))
|
||||
// M4: OIDC device-flow login
|
||||
.route("/api/oidc/auth", post(oidc::auth::auth))
|
||||
.route("/api/oidc/auth-query", get(oidc::poll::auth_query))
|
||||
.route("/oidc/callback", get(oidc::callback::callback));
|
||||
// M5: admin dashboard (HTMX + embedded HTML). Merged BEFORE the
|
||||
// Extension layer so the merged router carries the shared state.
|
||||
let app = match admin::build(state.clone()) {
|
||||
Some(admin_router) => app.merge(admin_router),
|
||||
None => app,
|
||||
};
|
||||
app.layer(Extension(state))
|
||||
}
|
||||
|
||||
pub async fn serve(addr: SocketAddr, state: Arc<AppState>) -> ResultType<()> {
|
||||
log::info!("HTTP API listening on {}", addr);
|
||||
let app = router(state);
|
||||
// Share the same router with the rendezvous-TCP HttpProxyRequest path so
|
||||
// both transports route through the exact same handlers.
|
||||
http_proxy::install_router(app.clone());
|
||||
axum::Server::bind(&addr)
|
||||
.serve(app.into_make_service())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! `POST /api/oidc/auth` — start the device-flow login.
|
||||
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::oidc::{discovery, random_token, require_provider, OIDC_SESSION_TTL_SECS};
|
||||
use crate::api::state::AppState;
|
||||
use crate::database::OidcSessionInsert;
|
||||
use axum::extract::Extension;
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthBody {
|
||||
/// Provider short-name from `oidc_providers.name`. The Flutter client
|
||||
/// sends this from the `op` field of the OIDC dialog.
|
||||
#[serde(default)]
|
||||
pub op: String,
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub uuid: String,
|
||||
#[serde(default, rename = "deviceInfo")]
|
||||
pub device_info: Option<Value>,
|
||||
}
|
||||
|
||||
pub async fn auth(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
Json(body): Json<AuthBody>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
if state.cfg.public_base_url.is_empty() {
|
||||
return Err(ApiError::Internal(
|
||||
"OIDC requires --public-base-url to be set".into(),
|
||||
));
|
||||
}
|
||||
if body.op.is_empty() {
|
||||
return Err(ApiError::BadRequest("op (provider name) required".into()));
|
||||
}
|
||||
let provider = require_provider(&state, &body.op).await?;
|
||||
let disc = discovery::discover(&provider.issuer_url)
|
||||
.await
|
||||
.map_err(ApiError::Internal)?;
|
||||
|
||||
let code = random_token();
|
||||
let csrf_state = random_token();
|
||||
let device_info_json = body
|
||||
.device_info
|
||||
.as_ref()
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_else(|| "{}".to_string());
|
||||
|
||||
let expires_at = chrono::Utc::now().timestamp() + OIDC_SESSION_TTL_SECS;
|
||||
state
|
||||
.db
|
||||
.oidc_session_create(&OidcSessionInsert {
|
||||
code: &code,
|
||||
provider: &provider.name,
|
||||
state: &csrf_state,
|
||||
client_id_str: &body.id,
|
||||
client_uuid: &body.uuid,
|
||||
device_info_json: &device_info_json,
|
||||
expires_at,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
|
||||
// Build the IdP authorization URL.
|
||||
let url = format!(
|
||||
"{auth}?response_type=code&client_id={cid}&redirect_uri={ru}&scope={scope}&state={state}",
|
||||
auth = disc.authorization_endpoint,
|
||||
cid = url_encode(&provider.client_id),
|
||||
ru = url_encode(&provider.redirect_url),
|
||||
scope = url_encode(&provider.scopes),
|
||||
state = url_encode(&csrf_state),
|
||||
);
|
||||
|
||||
Ok(Json(json!({
|
||||
"code": code,
|
||||
"url": url,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Inline percent-encoder for the auth URL query string. See
|
||||
/// `api::twofa::url_encode` for the same routine.
|
||||
fn url_encode(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for b in s.as_bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(*b as char);
|
||||
}
|
||||
_ => {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(out, "%{:02X}", b);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
//! `GET /oidc/callback?code=&state=` — browser-facing redirect target.
|
||||
//!
|
||||
//! After the user signs in at the IdP, the IdP redirects their browser
|
||||
//! here. We exchange the IdP code for tokens, fetch userinfo, find/create
|
||||
//! a local user, mint our access token, and mark the session `success`.
|
||||
//! The browser sees a small "you can close this window" page; the desktop
|
||||
//! client picks up the token via `/api/oidc/auth-query`.
|
||||
|
||||
use crate::api::admin::oidc_login::ADMIN_SENTINEL;
|
||||
use crate::api::auth::mint_token;
|
||||
use crate::api::middleware::{sha256_token, SESSION_COOKIE};
|
||||
use crate::api::oidc::{discovery, require_provider};
|
||||
use crate::api::state::AppState;
|
||||
use axum::extract::{Extension, Query};
|
||||
use axum::http::header::{LOCATION, SET_COOKIE};
|
||||
use axum::http::{HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CallbackQuery {
|
||||
#[serde(default)]
|
||||
pub code: String,
|
||||
#[serde(default)]
|
||||
pub state: String,
|
||||
/// Some IdPs forward an error here on failed auth (e.g. user clicked
|
||||
/// "deny"). We surface it as the session error and as a friendly page.
|
||||
#[serde(default)]
|
||||
pub error: Option<String>,
|
||||
#[serde(default)]
|
||||
pub error_description: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn callback(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
Query(q): Query<CallbackQuery>,
|
||||
) -> Response {
|
||||
match handle(state.clone(), q).await {
|
||||
Ok(ok) if ok.is_admin_flow => {
|
||||
if !ok.user_is_admin {
|
||||
return Html(html_page(
|
||||
"Sign-in failed",
|
||||
"This account does not have admin access. Ask an existing admin to grant it on the Users page, then try again.",
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
// Set the dashboard session cookie and redirect to /admin/.
|
||||
// Same cookie shape /admin/login uses on success.
|
||||
let cookie = format!(
|
||||
"{name}={token}; HttpOnly; Path=/; SameSite=Strict; Max-Age={ttl}",
|
||||
name = SESSION_COOKIE,
|
||||
token = ok.token,
|
||||
ttl = state.cfg.session_ttl_secs,
|
||||
);
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Ok(v) = HeaderValue::from_str(&cookie) {
|
||||
headers.insert(SET_COOKIE, v);
|
||||
}
|
||||
headers.insert(LOCATION, HeaderValue::from_static("/admin/"));
|
||||
(StatusCode::SEE_OTHER, headers).into_response()
|
||||
}
|
||||
Ok(_) => Html(html_page(
|
||||
"Sign-in complete",
|
||||
"You can close this window and return to RustDesk.",
|
||||
))
|
||||
.into_response(),
|
||||
Err(msg) => Html(html_page("Sign-in failed", &html_escape(&msg))).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
struct HandleOk {
|
||||
/// Bearer token freshly minted for the local user. For the admin flow
|
||||
/// we set it as `rd_admin_session`; for the desktop flow it's already
|
||||
/// stashed on the OidcSession row for `/api/oidc/auth-query` polling.
|
||||
token: String,
|
||||
user_is_admin: bool,
|
||||
is_admin_flow: bool,
|
||||
}
|
||||
|
||||
async fn handle(state: Arc<AppState>, q: CallbackQuery) -> Result<HandleOk, String> {
|
||||
if q.state.is_empty() {
|
||||
return Err("missing state parameter".into());
|
||||
}
|
||||
let session = state
|
||||
.db
|
||||
.oidc_session_get_by_state(&q.state)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| "unknown or expired oidc session (state)".to_string())?;
|
||||
|
||||
if let Some(err) = q.error.as_deref().filter(|s| !s.is_empty()) {
|
||||
let detail = q
|
||||
.error_description
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(err);
|
||||
let _ = state
|
||||
.db
|
||||
.oidc_session_fail(&session.code, &format!("idp: {}", detail))
|
||||
.await;
|
||||
return Err(format!("identity provider returned an error: {}", detail));
|
||||
}
|
||||
if q.code.is_empty() {
|
||||
return Err("missing authorization code".into());
|
||||
}
|
||||
|
||||
let provider = require_provider(&state, &session.provider)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))?;
|
||||
let disc = discovery::discover(&provider.issuer_url).await?;
|
||||
|
||||
// Token exchange.
|
||||
let token_body = match discovery::http_post_form(
|
||||
&disc.token_endpoint,
|
||||
&[
|
||||
("grant_type", "authorization_code"),
|
||||
("code", &q.code),
|
||||
("redirect_uri", &provider.redirect_url),
|
||||
("client_id", &provider.client_id),
|
||||
("client_secret", &provider.client_secret),
|
||||
],
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
let _ = state
|
||||
.db
|
||||
.oidc_session_fail(&session.code, &format!("token exchange: {}", e))
|
||||
.await;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let token_resp: Value =
|
||||
serde_json::from_str(&token_body).map_err(|e| format!("parse token resp: {}", e))?;
|
||||
let access_token = token_resp
|
||||
.get("access_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "token response missing access_token".to_string())?;
|
||||
|
||||
// Fetch userinfo. We trust the userinfo endpoint as the authority on
|
||||
// the user's identity (sub + optional email + name).
|
||||
let userinfo_url = disc
|
||||
.userinfo_endpoint
|
||||
.as_deref()
|
||||
.ok_or_else(|| "provider has no userinfo_endpoint".to_string())?;
|
||||
let userinfo_body = discovery::http_get_with_bearer(userinfo_url, access_token).await?;
|
||||
let userinfo: Value = serde_json::from_str(&userinfo_body)
|
||||
.map_err(|e| format!("parse userinfo: {}", e))?;
|
||||
let sub = userinfo
|
||||
.get("sub")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "userinfo missing sub".to_string())?;
|
||||
let email = userinfo.get("email").and_then(|v| v.as_str());
|
||||
let display_name = userinfo
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| userinfo.get("preferred_username").and_then(|v| v.as_str()));
|
||||
|
||||
// Optional role-based admin sync. When the provider is configured with
|
||||
// `admin_role`, we look up the userinfo claim at `roles_claim` (default
|
||||
// "roles") and set is_admin accordingly. Two shapes are supported:
|
||||
// - object: presence of the role name as a key
|
||||
// (Zitadel default: `"urn:zitadel:iam:org:project:roles":
|
||||
// {"admin": {"<orgid>": "<orgname>"}}`)
|
||||
// - array of strings: presence of the role name as an element
|
||||
// (e.g. a custom claim mapping `"roles": ["admin", "user"]`)
|
||||
let desired_admin = provider.admin_role.as_deref().map(|role| {
|
||||
let claim_name = provider.roles_claim.as_deref().unwrap_or("roles");
|
||||
eval_admin_role(&userinfo, claim_name, role)
|
||||
});
|
||||
|
||||
let user = state
|
||||
.db
|
||||
.user_upsert_oidc(sub, email, display_name, desired_admin)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if user.status == 0 {
|
||||
return Err("user is disabled".into());
|
||||
}
|
||||
|
||||
// Mint our own access token, store hashed, mark session complete.
|
||||
let token = mint_token();
|
||||
let sha = sha256_token(&token);
|
||||
let is_admin_flow = session.client_uuid == ADMIN_SENTINEL;
|
||||
// For admin-UI OIDC the "device id/uuid" fields carry the sentinel —
|
||||
// don't pollute the tokens.peer_* columns with it.
|
||||
let (token_peer_id, token_peer_uuid): (&str, &str) = if is_admin_flow {
|
||||
("", "")
|
||||
} else {
|
||||
(
|
||||
session.client_id_str.as_str(),
|
||||
session.client_uuid.as_str(),
|
||||
)
|
||||
};
|
||||
state
|
||||
.db
|
||||
.token_insert(
|
||||
user.id,
|
||||
&sha,
|
||||
token_peer_id,
|
||||
token_peer_uuid,
|
||||
&session.device_info_json,
|
||||
state.cfg.session_ttl_secs,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
// Best-effort device claim — same path as `/api/login`. Skipped for
|
||||
// admin-UI flow because the "device" is the operator's browser, not a
|
||||
// real RustDesk peer; calling device_claim with the sentinel would
|
||||
// insert a phantom row in device_sysinfo.
|
||||
if !is_admin_flow {
|
||||
state
|
||||
.db
|
||||
.device_claim(user.id, &session.client_id_str, &session.client_uuid)
|
||||
.await;
|
||||
}
|
||||
|
||||
state
|
||||
.db
|
||||
.oidc_session_complete(&session.code, &token, user.id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(HandleOk {
|
||||
token,
|
||||
user_is_admin: user.is_admin,
|
||||
is_admin_flow,
|
||||
})
|
||||
}
|
||||
|
||||
fn html_page(title: &str, body: &str) -> String {
|
||||
format!(
|
||||
r#"<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>{title}</title>
|
||||
<style>
|
||||
body {{ font-family: -apple-system, system-ui, sans-serif;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; height: 100vh; margin: 0;
|
||||
background: #0e0f12; color: #e6e6e6; }}
|
||||
.card {{ background: #1c1e22; padding: 48px 56px;
|
||||
border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,.3); max-width: 480px; }}
|
||||
h1 {{ margin: 0 0 16px; font-size: 22px; }}
|
||||
p {{ margin: 0; line-height: 1.5; color: #b8b8b8; }}
|
||||
</style>
|
||||
</head><body><div class="card">
|
||||
<h1>{title}</h1>
|
||||
<p>{body}</p>
|
||||
</div></body></html>"#,
|
||||
title = title,
|
||||
body = body
|
||||
)
|
||||
}
|
||||
|
||||
fn html_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
/// Returns true iff the userinfo's `claim_name` field carries `role` —
|
||||
/// either as an object key (Zitadel) or as an element of a string array
|
||||
/// (generic). Anything else (missing claim, wrong type, role not present)
|
||||
/// is treated as "not admin" so a misconfigured claim demotes rather than
|
||||
/// silently grants.
|
||||
fn eval_admin_role(userinfo: &Value, claim_name: &str, role: &str) -> bool {
|
||||
let Some(node) = userinfo.get(claim_name) else {
|
||||
return false;
|
||||
};
|
||||
if let Some(obj) = node.as_object() {
|
||||
return obj.contains_key(role);
|
||||
}
|
||||
if let Some(arr) = node.as_array() {
|
||||
return arr
|
||||
.iter()
|
||||
.any(|v| v.as_str().map(|s| s == role).unwrap_or(false));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn admin_role_zitadel_object_shape() {
|
||||
let u = json!({
|
||||
"sub": "1",
|
||||
"urn:zitadel:iam:org:project:roles": {
|
||||
"admin": {"123": "myorg"},
|
||||
"user": {"123": "myorg"},
|
||||
},
|
||||
});
|
||||
assert!(eval_admin_role(&u, "urn:zitadel:iam:org:project:roles", "admin"));
|
||||
assert!(!eval_admin_role(&u, "urn:zitadel:iam:org:project:roles", "owner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_role_generic_array_shape() {
|
||||
let u = json!({"sub": "1", "roles": ["admin", "user"]});
|
||||
assert!(eval_admin_role(&u, "roles", "admin"));
|
||||
assert!(!eval_admin_role(&u, "roles", "owner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_role_missing_claim_is_not_admin() {
|
||||
let u = json!({"sub": "1"});
|
||||
assert!(!eval_admin_role(&u, "roles", "admin"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! `<issuer>/.well-known/openid-configuration` discovery + in-memory cache.
|
||||
//!
|
||||
//! Most OIDC providers serve a JSON document at this URL describing the
|
||||
//! authorization, token, and userinfo endpoints. Doing discovery once per
|
||||
//! provider and caching the result keeps the per-login overhead to two
|
||||
//! HTTP calls (token exchange + userinfo).
|
||||
|
||||
use hbb_common::log;
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct OidcDiscovery {
|
||||
pub authorization_endpoint: String,
|
||||
pub token_endpoint: String,
|
||||
#[serde(default)]
|
||||
pub userinfo_endpoint: Option<String>,
|
||||
#[serde(default)]
|
||||
pub issuer: Option<String>,
|
||||
}
|
||||
|
||||
static CACHE: Lazy<Mutex<HashMap<String, OidcDiscovery>>> =
|
||||
Lazy::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
/// Fetch (or return cached) discovery document for `issuer_url`. Strips a
|
||||
/// trailing `/` so the cache key is stable across operator typos.
|
||||
pub async fn discover(issuer_url: &str) -> Result<OidcDiscovery, String> {
|
||||
let issuer = issuer_url.trim_end_matches('/').to_string();
|
||||
if let Some(d) = CACHE.lock().unwrap().get(&issuer).cloned() {
|
||||
return Ok(d);
|
||||
}
|
||||
let url = format!("{}/.well-known/openid-configuration", issuer);
|
||||
log::info!("oidc: discovering {}", url);
|
||||
let body = http_get(&url).await?;
|
||||
let parsed: OidcDiscovery = serde_json::from_str(&body)
|
||||
.map_err(|e| format!("discovery parse {}: {}", url, e))?;
|
||||
CACHE.lock().unwrap().insert(issuer, parsed.clone());
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
/// Blocking HTTP GET wrapped in `spawn_blocking`. We use the existing
|
||||
/// `reqwest::blocking::Client` rather than adding an async client, because
|
||||
/// (a) discovery happens at most once per provider and (b) the rustdesk
|
||||
/// reqwest fork is configured for blocking-only use throughout the server.
|
||||
pub async fn http_get(url: &str) -> Result<String, String> {
|
||||
let url = url.to_owned();
|
||||
hbb_common::tokio::task::spawn_blocking(move || {
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|e| format!("http client build: {}", e))?;
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.map_err(|e| format!("http get {}: {}", url, e))?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().map_err(|e| format!("read body: {}", e))?;
|
||||
if !status.is_success() {
|
||||
return Err(format!("http {} -> {}: {}", url, status, body));
|
||||
}
|
||||
Ok(body)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking: {}", e))?
|
||||
}
|
||||
|
||||
pub async fn http_post_form(
|
||||
url: &str,
|
||||
form: &[(&str, &str)],
|
||||
) -> Result<String, String> {
|
||||
let url = url.to_owned();
|
||||
let owned: Vec<(String, String)> = form
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect();
|
||||
hbb_common::tokio::task::spawn_blocking(move || {
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|e| format!("http client build: {}", e))?;
|
||||
let pairs: Vec<(&str, &str)> = owned
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.as_str()))
|
||||
.collect();
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.form(&pairs)
|
||||
.send()
|
||||
.map_err(|e| format!("http post {}: {}", url, e))?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().map_err(|e| format!("read body: {}", e))?;
|
||||
if !status.is_success() {
|
||||
return Err(format!("http {} -> {}: {}", url, status, body));
|
||||
}
|
||||
Ok(body)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking: {}", e))?
|
||||
}
|
||||
|
||||
pub async fn http_get_with_bearer(
|
||||
url: &str,
|
||||
bearer: &str,
|
||||
) -> Result<String, String> {
|
||||
let url = url.to_owned();
|
||||
let bearer = bearer.to_owned();
|
||||
hbb_common::tokio::task::spawn_blocking(move || {
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|e| format!("http client build: {}", e))?;
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", bearer))
|
||||
.send()
|
||||
.map_err(|e| format!("http get {}: {}", url, e))?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().map_err(|e| format!("read body: {}", e))?;
|
||||
if !status.is_success() {
|
||||
return Err(format!("http {} -> {}: {}", url, status, body));
|
||||
}
|
||||
Ok(body)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking: {}", e))?
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! OIDC device-flow login.
|
||||
//!
|
||||
//! Wire flow (matching CONSOLE_API.md §3.5):
|
||||
//!
|
||||
//! 1. `POST /api/oidc/auth { op: <provider>, id, uuid, deviceInfo }` →
|
||||
//! `{ code: <opaque-poll-handle>, url: <browser auth URL> }`. The client
|
||||
//! opens `url` in the user's browser.
|
||||
//! 2. The IdP redirects the browser back to our `/oidc/callback?code=...&state=...`.
|
||||
//! That handler exchanges the IdP code for a token, fetches userinfo,
|
||||
//! upserts a local user, mints our own access token, and marks the
|
||||
//! session `success`.
|
||||
//! 3. The client polls `GET /api/oidc/auth-query?code=&id=&uuid=` until it
|
||||
//! sees a wrapped `AuthBody` envelope.
|
||||
//!
|
||||
//! Auth on the IdP side is handled by the provider's standard OAuth2
|
||||
//! authorization-code flow. We keep the hbbs side minimal: discovery via
|
||||
//! `<issuer>/.well-known/openid-configuration`, no JWT verification (we
|
||||
//! trust the userinfo endpoint, authenticated via the access token).
|
||||
|
||||
pub mod auth;
|
||||
pub mod callback;
|
||||
pub mod discovery;
|
||||
pub mod poll;
|
||||
pub mod providers;
|
||||
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::state::AppState;
|
||||
use crate::database::OidcProviderRow;
|
||||
|
||||
pub(crate) const OIDC_SESSION_TTL_SECS: i64 = 600; // 10 minutes — the user has to sign in fast
|
||||
|
||||
/// Convenience: resolve a provider name to its row, or an ApiError if it
|
||||
/// doesn't exist or is disabled.
|
||||
pub(crate) async fn require_provider(
|
||||
state: &AppState,
|
||||
name: &str,
|
||||
) -> Result<OidcProviderRow, ApiError> {
|
||||
state
|
||||
.db
|
||||
.oidc_provider_get(name)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?
|
||||
.ok_or_else(|| ApiError::BadRequest(format!("unknown OIDC provider: {}", name)))
|
||||
}
|
||||
|
||||
/// 24 random bytes, base64url-encoded → ~32 characters. Used for both the
|
||||
/// poll-handle (`code`) and the CSRF state.
|
||||
pub(crate) fn random_token() -> String {
|
||||
base64::encode_config(
|
||||
sodiumoxide::randombytes::randombytes(24),
|
||||
base64::URL_SAFE_NO_PAD,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! `GET /api/oidc/auth-query?code=&id=&uuid=` — desktop-client poll loop.
|
||||
//!
|
||||
//! Wire shape: return the inner payload as the HTTP body directly. Do NOT
|
||||
//! wrap in another `{ "body": ... }` envelope — the desktop client's
|
||||
//! transport (`http_request_sync` in src/common.rs) already wraps every
|
||||
//! response in `{ status_code, headers, body }` and feeds the inner `body`
|
||||
//! string to `HbbHttpResponse::parse`. An extra envelope makes the parser
|
||||
//! see `{"body": "..."}`, fail to deserialize as `AuthBody`, and silently
|
||||
//! retry until the 180 s client timeout. Spent half a day on this — keep
|
||||
//! the bare shape.
|
||||
//!
|
||||
//! Inner payloads:
|
||||
//! - while pending: `{"error":"No authed oidc is found"}` — client keeps polling.
|
||||
//! - on success: `{access_token, type:"access_token", user}` — client stops.
|
||||
//! - on error: `{"error":"<message>"}` — client surfaces and stops polling.
|
||||
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::state::AppState;
|
||||
use crate::api::users::UserPayload;
|
||||
use axum::extract::{Extension, Query};
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PollQuery {
|
||||
pub code: String,
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub uuid: String,
|
||||
}
|
||||
|
||||
pub async fn auth_query(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
Query(q): Query<PollQuery>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let session = state
|
||||
.db
|
||||
.oidc_session_get_by_code(&q.code)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?
|
||||
.ok_or_else(|| ApiError::BadRequest("unknown oidc session".into()))?;
|
||||
hbb_common::log::info!(
|
||||
"oidc poll: code={} status={} user_id={:?} elapsed_to_expiry={}",
|
||||
q.code,
|
||||
session.status,
|
||||
session.user_id,
|
||||
session.expires_at - now,
|
||||
);
|
||||
if session.expires_at <= now && session.status == "pending" {
|
||||
// The client treats this as an ordinary "still pending" tick and
|
||||
// gives up on its own timeout (180 s).
|
||||
return Ok(Json(json!({"error": "No authed oidc is found"})));
|
||||
}
|
||||
match session.status.as_str() {
|
||||
"pending" => Ok(Json(json!({"error": "No authed oidc is found"}))),
|
||||
"error" => {
|
||||
let msg = session
|
||||
.error
|
||||
.clone()
|
||||
.unwrap_or_else(|| "OIDC sign-in failed".to_string());
|
||||
Ok(Json(json!({ "error": msg })))
|
||||
}
|
||||
"success" => {
|
||||
let access_token = session
|
||||
.access_token
|
||||
.clone()
|
||||
.ok_or_else(|| ApiError::Internal("success session missing token".into()))?;
|
||||
let user_id = session
|
||||
.user_id
|
||||
.ok_or_else(|| ApiError::Internal("success session missing user_id".into()))?;
|
||||
let user = state
|
||||
.db
|
||||
.user_find_by_id(user_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?
|
||||
.ok_or_else(|| ApiError::Internal("user vanished mid-flow".into()))?;
|
||||
Ok(Json(json!({
|
||||
"access_token": access_token,
|
||||
"type": "access_token",
|
||||
"user": UserPayload::from(&user),
|
||||
})))
|
||||
}
|
||||
other => Err(ApiError::Internal(format!(
|
||||
"unknown oidc status {:?}",
|
||||
other
|
||||
))),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Operator-supplied provider config. Reads a TOML file shaped like:
|
||||
//!
|
||||
//! ```toml
|
||||
//! [[providers]]
|
||||
//! name = "google"
|
||||
//! display_name = "Google"
|
||||
//! issuer_url = "https://accounts.google.com"
|
||||
//! client_id = "<google client id>"
|
||||
//! client_secret = "<google client secret>"
|
||||
//! scopes = "openid email profile"
|
||||
//! ```
|
||||
//!
|
||||
//! Each entry is upserted into the `oidc_providers` table at startup.
|
||||
//! `redirect_url` is computed from `--public-base-url` + `/oidc/callback`.
|
||||
//!
|
||||
//! TOML parsing uses the existing `rust-ini` crate? — no, we'd need a TOML
|
||||
//! parser. We already have `toml` transitively via several deps; pull it in
|
||||
//! directly for clarity.
|
||||
|
||||
use crate::database::{Database, OidcProviderRow};
|
||||
use hbb_common::log;
|
||||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ProvidersFile {
|
||||
#[serde(default)]
|
||||
providers: Vec<ProviderEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ProviderEntry {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
display_name: Option<String>,
|
||||
#[serde(default)]
|
||||
icon_url: Option<String>,
|
||||
issuer_url: String,
|
||||
client_id: String,
|
||||
client_secret: String,
|
||||
#[serde(default = "default_scopes")]
|
||||
scopes: String,
|
||||
/// Optional override; defaults to `<public-base-url>/oidc/callback`.
|
||||
#[serde(default)]
|
||||
redirect_url: Option<String>,
|
||||
#[serde(default = "default_true")]
|
||||
enabled: bool,
|
||||
/// Role-based admin sync. Set both to drive `is_admin` from the IdP:
|
||||
/// admin_role = "admin"
|
||||
/// roles_claim = "urn:zitadel:iam:org:project:roles" # Zitadel
|
||||
/// Or for a generic IdP that emits `roles: ["admin","user"]`:
|
||||
/// admin_role = "admin"
|
||||
/// # roles_claim defaults to "roles"
|
||||
#[serde(default)]
|
||||
admin_role: Option<String>,
|
||||
#[serde(default)]
|
||||
roles_claim: Option<String>,
|
||||
}
|
||||
|
||||
fn default_scopes() -> String {
|
||||
"openid email profile".to_string()
|
||||
}
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub async fn load_from_file(
|
||||
db: &Database,
|
||||
path: &Path,
|
||||
public_base_url: &str,
|
||||
) -> Result<usize, String> {
|
||||
let bytes = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("read {}: {}", path.display(), e))?;
|
||||
let parsed: ProvidersFile =
|
||||
toml::from_str(&bytes).map_err(|e| format!("parse {}: {}", path.display(), e))?;
|
||||
let mut count = 0;
|
||||
for p in parsed.providers {
|
||||
let redirect_url = p
|
||||
.redirect_url
|
||||
.clone()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
let base = public_base_url.trim_end_matches('/');
|
||||
format!("{}/oidc/callback", base)
|
||||
});
|
||||
let row = OidcProviderRow {
|
||||
name: p.name.clone(),
|
||||
display_name: p.display_name,
|
||||
icon_url: p.icon_url,
|
||||
issuer_url: p.issuer_url,
|
||||
client_id: p.client_id,
|
||||
client_secret: p.client_secret,
|
||||
scopes: p.scopes,
|
||||
redirect_url,
|
||||
enabled: p.enabled,
|
||||
admin_role: p.admin_role.filter(|s| !s.is_empty()),
|
||||
roles_claim: p.roles_claim.filter(|s| !s.is_empty()),
|
||||
};
|
||||
db.oidc_provider_upsert(&row)
|
||||
.await
|
||||
.map_err(|e| format!("upsert {}: {}", p.name, e))?;
|
||||
count += 1;
|
||||
log::info!("oidc: provider {:?} configured", p.name);
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Query-string pagination for list endpoints. The Flutter client at
|
||||
/// flutter/lib/models/ab_model.dart and group_model.dart sends
|
||||
/// `?current=1&pageSize=100` against every paginated list. Field names are
|
||||
/// spelled explicitly here — `serde(rename_all = "camelCase")` would also
|
||||
/// rename `current`, which we don't want.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PageQuery {
|
||||
#[serde(default = "default_current")]
|
||||
pub current: i64,
|
||||
#[serde(default = "default_page_size", rename = "pageSize")]
|
||||
pub page_size: i64,
|
||||
}
|
||||
|
||||
fn default_current() -> i64 {
|
||||
1
|
||||
}
|
||||
fn default_page_size() -> i64 {
|
||||
100
|
||||
}
|
||||
|
||||
impl PageQuery {
|
||||
pub fn offset(&self) -> i64 {
|
||||
let cur = self.current.max(1);
|
||||
(cur - 1) * self.limit()
|
||||
}
|
||||
pub fn limit(&self) -> i64 {
|
||||
self.page_size.clamp(1, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
/// Standard envelope: `{ total, data }`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Page<T: Serialize> {
|
||||
pub total: i64,
|
||||
pub data: Vec<T>,
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! `GET /api/peers` — paginated peer list for the Group tab in the desktop
|
||||
//! client. Flutter decoder at flutter/lib/common/hbbs/hbbs.dart:77 expects
|
||||
//! `{ id, user, user_name, device_group_name, note, status, info: {...} }`
|
||||
//! per row.
|
||||
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use crate::api::pagination::{Page, PageQuery};
|
||||
use crate::api::state::AppState;
|
||||
use axum::extract::{Extension, Path, Query};
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PeerOut {
|
||||
pub id: String,
|
||||
pub user: String,
|
||||
pub user_name: String,
|
||||
pub device_group_name: String,
|
||||
pub note: String,
|
||||
pub status: i64,
|
||||
pub info: Value,
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
Query(q): Query<PageQuery>,
|
||||
) -> Result<Json<Page<PeerOut>>, ApiError> {
|
||||
let (total, rows) = state
|
||||
.db
|
||||
.peers_list_accessible(user.user_id, user.is_admin, q.offset(), q.limit())
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let data: Vec<PeerOut> = rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
// Trim the full sysinfo blob to what the client actually reads.
|
||||
let parsed: Value = serde_json::from_str(&r.sysinfo_payload).unwrap_or(Value::Null);
|
||||
let pick = |k: &str| -> String {
|
||||
parsed
|
||||
.get(k)
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
};
|
||||
let info = json!({
|
||||
"username": pick("username"),
|
||||
"device_name": pick("hostname"),
|
||||
"os": pick("os"),
|
||||
});
|
||||
PeerOut {
|
||||
id: r.id,
|
||||
user: r.owner_username,
|
||||
user_name: r.owner_display_name,
|
||||
device_group_name: r.device_group_name,
|
||||
note: r.note,
|
||||
status: r.status,
|
||||
info,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(Page { total, data }))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetManagedBody {
|
||||
pub managed: bool,
|
||||
}
|
||||
|
||||
/// `PUT /api/peers/:id/managed` — admin-only toggle for the signed-API gate.
|
||||
/// Setting `managed=true` is also done TOFU-style by the sig-verify helper
|
||||
/// on the first valid signature, so this endpoint is mainly useful for:
|
||||
/// - Pre-enrolling a peer before its agent boots.
|
||||
/// - Downgrading a peer back to the unsigned path after a managed agent
|
||||
/// is uninstalled or replaced with stock RustDesk.
|
||||
pub async fn set_managed(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<SetManagedBody>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
if !user.is_admin {
|
||||
return Err(ApiError::Forbidden("admin only".into()));
|
||||
}
|
||||
// Confirm the peer exists before flipping, so an admin typo doesn't
|
||||
// silently create a row-not-found situation.
|
||||
let row = state
|
||||
.db
|
||||
.peer_get_auth(&id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
if row.is_none() {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
state
|
||||
.db
|
||||
.peer_set_managed(&id, body.managed)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
hbb_common::log::info!(
|
||||
"admin {} set peer {} managed={}",
|
||||
user.name,
|
||||
id,
|
||||
body.managed
|
||||
);
|
||||
Ok(Json(json!({ "ok": true, "managed": body.managed })))
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! `POST /api/agent/perf-events` — performance-related Windows event log
|
||||
//! entries the agent surfaced from `Microsoft-Windows-Diagnostics-
|
||||
//! Performance/Operational`, `Microsoft-Windows-Resource-Exhaustion-
|
||||
//! Detector/Operational`, and a few hand-picked IDs from `System`
|
||||
//! (unexpected reboots, BSODs, dirty shutdowns). The admin UI shows
|
||||
//! the recent ones in the device's Performance section.
|
||||
//!
|
||||
//! Auth: same per-peer signed-API gate as the other agent endpoints.
|
||||
//! Server-side dedup is via the UNIQUE (peer_id, provider, record_id)
|
||||
//! index — the agent persists a per-channel cursor to disk, but a
|
||||
//! restart that loses the cursor can safely re-emit overlapping ranges.
|
||||
|
||||
use crate::api::device_auth::{self, AuthOutcome};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::state::AppState;
|
||||
use crate::database::PerfEventRow;
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::Extension;
|
||||
use axum::http::HeaderMap;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PerfEventIn {
|
||||
pub at: i64,
|
||||
pub provider: String,
|
||||
pub event_id: i64,
|
||||
#[serde(default = "default_level")]
|
||||
pub level: i64,
|
||||
#[serde(default)]
|
||||
pub record_id: i64,
|
||||
#[serde(default)]
|
||||
pub summary: String,
|
||||
#[serde(default)]
|
||||
pub detail_json: String,
|
||||
}
|
||||
|
||||
fn default_level() -> i64 {
|
||||
4 // WEL "Information"
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PerfEventsBody {
|
||||
pub id: String,
|
||||
pub uuid: String,
|
||||
pub events: Vec<PerfEventIn>,
|
||||
}
|
||||
|
||||
const MAX_EVENTS_PER_POST: usize = 128;
|
||||
const MAX_PROVIDER_LEN: usize = 64;
|
||||
const MAX_SUMMARY_LEN: usize = 512;
|
||||
const MAX_DETAIL_LEN: usize = 8 * 1024;
|
||||
|
||||
pub async fn perf_events(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<String, ApiError> {
|
||||
let outcome =
|
||||
device_auth::verify(&state, "POST", "/api/agent/perf-events", &headers, &body).await?;
|
||||
|
||||
let payload: PerfEventsBody = serde_json::from_slice(&body)
|
||||
.map_err(|_| ApiError::BadRequest("invalid json".into()))?;
|
||||
|
||||
if payload.id.is_empty() || payload.uuid.is_empty() {
|
||||
return Err(ApiError::BadRequest("id and uuid are required".into()));
|
||||
}
|
||||
if payload.events.is_empty() {
|
||||
return Ok("OK".to_string());
|
||||
}
|
||||
if payload.events.len() > MAX_EVENTS_PER_POST {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"too many events in one POST (max {MAX_EVENTS_PER_POST})"
|
||||
)));
|
||||
}
|
||||
|
||||
let id = match outcome {
|
||||
AuthOutcome::Verified { id: signed_id } => {
|
||||
if payload.id != signed_id {
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
signed_id
|
||||
}
|
||||
AuthOutcome::LegacyUnsigned => {
|
||||
device_auth::enforce_managed_for_id(&state, &payload.id).await?;
|
||||
payload.id.clone()
|
||||
}
|
||||
};
|
||||
|
||||
let peer = state
|
||||
.db
|
||||
.get_peer(&id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
if peer.is_none() {
|
||||
return Ok("ID_NOT_FOUND".to_string());
|
||||
}
|
||||
|
||||
let mut accepted = 0usize;
|
||||
for e in &payload.events {
|
||||
let provider = e.provider.trim();
|
||||
if provider.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let row = PerfEventRow {
|
||||
at: e.at,
|
||||
provider: truncate(provider, MAX_PROVIDER_LEN),
|
||||
event_id: e.event_id,
|
||||
level: e.level,
|
||||
record_id: e.record_id,
|
||||
summary: truncate(&e.summary, MAX_SUMMARY_LEN),
|
||||
detail_json: truncate(&e.detail_json, MAX_DETAIL_LEN),
|
||||
received_at: 0, // server fills via DEFAULT on INSERT
|
||||
};
|
||||
if let Err(err) = state.db.perf_event_insert(&id, &payload.uuid, &row).await {
|
||||
hbb_common::log::warn!(
|
||||
"perf_event_insert for peer {} failed: {}",
|
||||
id,
|
||||
err
|
||||
);
|
||||
continue;
|
||||
}
|
||||
accepted += 1;
|
||||
}
|
||||
|
||||
hbb_common::log::debug!(
|
||||
"perf-events: peer={} accepted={}/{}",
|
||||
id,
|
||||
accepted,
|
||||
payload.events.len()
|
||||
);
|
||||
Ok("OK".to_string())
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max_chars: usize) -> String {
|
||||
if s.chars().count() <= max_chars {
|
||||
s.to_string()
|
||||
} else {
|
||||
s.chars().take(max_chars).collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! `POST /lic/web/api/plugin-sign` — signs a plugin's status/heartbeat
|
||||
//! payload with the server's Ed25519 secret. The client (plugin runtime,
|
||||
//! src/plugin/callback_msg.rs:282-296) sends:
|
||||
//!
|
||||
//! `{ "plugin_id": "...", "version": "...", "msg": [u8, u8, ...] }`
|
||||
//!
|
||||
//! and expects:
|
||||
//!
|
||||
//! `{ "signed_msg": [u8, u8, ...] }`
|
||||
//!
|
||||
//! No Authorization header — the client opens this without a token. Auth
|
||||
//! is implicit via the licence-key shared secret on the rest of the
|
||||
//! deployment; we just sign whatever is asked. (Pro can additionally
|
||||
//! validate the plugin against an allowlist; OSS just signs.)
|
||||
|
||||
use crate::api::error::ApiError;
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sodiumoxide::crypto::sign;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PluginSignReq {
|
||||
#[serde(default)]
|
||||
pub plugin_id: String,
|
||||
#[serde(default)]
|
||||
pub version: String,
|
||||
pub msg: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PluginSignResp {
|
||||
pub signed_msg: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The signing key is the same Ed25519 secret hbbs already uses for
|
||||
/// rendezvous KeyExchange (`id_ed25519`). We pull it from the shared
|
||||
/// `RendezvousServer.inner.sk` via the AppState — but `AppState` doesn't
|
||||
/// hold it today, so this handler reads it directly from a process-wide
|
||||
/// `OnceCell` populated at startup. (See `set_signing_key` below.)
|
||||
pub async fn plugin_sign(
|
||||
Json(req): Json<PluginSignReq>,
|
||||
) -> Result<Json<PluginSignResp>, ApiError> {
|
||||
let sk = SIGNING_KEY
|
||||
.get()
|
||||
.ok_or_else(|| ApiError::Internal("plugin signing not configured".into()))?;
|
||||
let signed = sign::sign(&req.msg, sk);
|
||||
Ok(Json(PluginSignResp { signed_msg: signed }))
|
||||
}
|
||||
|
||||
use once_cell::sync::OnceCell;
|
||||
|
||||
static SIGNING_KEY: OnceCell<Arc<sign::SecretKey>> = OnceCell::new();
|
||||
|
||||
/// Called once from `RendezvousServer::start` after the keypair is loaded.
|
||||
/// A no-op if already set; the server will only ever have one Ed25519 key.
|
||||
pub fn set_signing_key(sk: sign::SecretKey) {
|
||||
let _ = SIGNING_KEY.set(Arc::new(sk));
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! `POST /api/record?type={new|part|tail|remove}&file=&offset=&length=`
|
||||
//!
|
||||
//! No Authorization header — clients fire-and-forget. The wire flow is
|
||||
//! defined in CONSOLE_API.md §8 and src/hbbs_http/record_upload.rs in the
|
||||
//! client. We dispatch on `?type=` into the storage state machine.
|
||||
|
||||
pub mod storage;
|
||||
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::state::AppState;
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::{Extension, Query};
|
||||
use axum::http::StatusCode;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RecordQuery {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
pub file: String,
|
||||
#[serde(default)]
|
||||
pub offset: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub length: Option<usize>,
|
||||
}
|
||||
|
||||
pub async fn record(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
Query(q): Query<RecordQuery>,
|
||||
body: Bytes,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
match q.kind.as_str() {
|
||||
"new" => storage::handle_new(&state, &q.file, "").await?,
|
||||
"part" => {
|
||||
let offset = q.offset.unwrap_or(0);
|
||||
let length = q.length.unwrap_or(body.len());
|
||||
storage::handle_part(&state, &q.file, offset, length, &body).await?;
|
||||
}
|
||||
"tail" => {
|
||||
let offset = q.offset.unwrap_or(0);
|
||||
let length = q.length.unwrap_or(body.len());
|
||||
storage::handle_tail(&state, &q.file, offset, length, &body).await?;
|
||||
}
|
||||
"remove" => storage::handle_remove(&state, &q.file).await?,
|
||||
other => {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"unknown record type {:?}",
|
||||
other
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! On-disk file IO for `/api/record`. The wire flow lives in
|
||||
//! [src/hbbs_http/record_upload.rs](file:///Users/sn0/Desktop/rustdesk/src/hbbs_http/record_upload.rs)
|
||||
//! on the client side: the controller emits `?type=new` once, then a series
|
||||
//! of `?type=part&offset=N&length=L` chunks, and finally a `?type=tail`
|
||||
//! header rewrite at offset 0. We mirror that as a tiny state machine.
|
||||
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::state::AppState;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use tokio::fs::{File, OpenOptions};
|
||||
use tokio::io::{AsyncSeekExt, AsyncWriteExt, SeekFrom};
|
||||
|
||||
const TAIL_MAX: usize = 1024;
|
||||
|
||||
/// Reject any filename that contains a path separator or `..` component.
|
||||
/// The client only ever sends a basename per
|
||||
/// `record_upload.rs:118-122`, so anything else is suspicious.
|
||||
pub fn sanitized_path(root: &Path, file: &str) -> Result<PathBuf, ApiError> {
|
||||
if file.is_empty() {
|
||||
return Err(ApiError::BadRequest("file required".into()));
|
||||
}
|
||||
let p = Path::new(file);
|
||||
let mut comps = p.components();
|
||||
let only = comps.next();
|
||||
let extra = comps.next();
|
||||
match (only, extra) {
|
||||
(Some(Component::Normal(name)), None) if !name.is_empty() => Ok(root.join(name)),
|
||||
_ => Err(ApiError::BadRequest("invalid file name".into())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_new(
|
||||
state: &AppState,
|
||||
file: &str,
|
||||
peer_id: &str,
|
||||
) -> Result<(), ApiError> {
|
||||
let path = sanitized_path(&state.cfg.recording_dir, file)?;
|
||||
if let Some(dir) = path.parent() {
|
||||
tokio::fs::create_dir_all(dir)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("mkdir {}: {}", dir.display(), e)))?;
|
||||
}
|
||||
// Truncate (or create) the file.
|
||||
OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(&path)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("create {}: {}", path.display(), e)))?;
|
||||
state
|
||||
.db
|
||||
.recording_new(peer_id, file)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn handle_part(
|
||||
state: &AppState,
|
||||
file: &str,
|
||||
offset: u64,
|
||||
length: usize,
|
||||
body: &[u8],
|
||||
) -> Result<(), ApiError> {
|
||||
if body.len() != length {
|
||||
hbb_common::log::warn!(
|
||||
"record part length mismatch: declared={} actual={}",
|
||||
length,
|
||||
body.len()
|
||||
);
|
||||
}
|
||||
let path = sanitized_path(&state.cfg.recording_dir, file)?;
|
||||
let max = state.cfg.recording_max_size_bytes;
|
||||
if max > 0 && offset.saturating_add(body.len() as u64) > max {
|
||||
return Err(ApiError::Forbidden("recording size cap exceeded".into()));
|
||||
}
|
||||
let mut f: File = OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.open(&path)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("open {}: {}", path.display(), e)))?;
|
||||
f.seek(SeekFrom::Start(offset))
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("seek: {}", e)))?;
|
||||
f.write_all(body)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("write: {}", e)))?;
|
||||
f.flush().await.ok();
|
||||
let new_size = offset + body.len() as u64;
|
||||
state
|
||||
.db
|
||||
.recording_set_state(file, "recording", Some(new_size as i64), false)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn handle_tail(
|
||||
state: &AppState,
|
||||
file: &str,
|
||||
offset: u64,
|
||||
length: usize,
|
||||
body: &[u8],
|
||||
) -> Result<(), ApiError> {
|
||||
if offset != 0 {
|
||||
return Err(ApiError::BadRequest("tail must be at offset 0".into()));
|
||||
}
|
||||
if length > TAIL_MAX || body.len() > TAIL_MAX {
|
||||
return Err(ApiError::BadRequest("tail exceeds 1024 bytes".into()));
|
||||
}
|
||||
let path = sanitized_path(&state.cfg.recording_dir, file)?;
|
||||
let mut f = OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("open {}: {}", path.display(), e)))?;
|
||||
f.seek(SeekFrom::Start(0))
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("seek: {}", e)))?;
|
||||
f.write_all(body)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("write tail: {}", e)))?;
|
||||
f.flush().await.ok();
|
||||
state
|
||||
.db
|
||||
.recording_set_state(file, "finished", None, true)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn handle_remove(state: &AppState, file: &str) -> Result<(), ApiError> {
|
||||
let path = sanitized_path(&state.cfg.recording_dir, file)?;
|
||||
if let Err(e) = tokio::fs::remove_file(&path).await {
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
hbb_common::log::warn!("remove {}: {}", path.display(), e);
|
||||
}
|
||||
}
|
||||
state
|
||||
.db
|
||||
.recording_delete(file)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
use crate::common::{get_arg, get_arg_or};
|
||||
use crate::database::Database;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ApiConfig {
|
||||
pub login_options: Vec<String>,
|
||||
pub sysinfo_ver: String,
|
||||
pub session_ttl_secs: i64,
|
||||
/// When true, `/api/ab/personal` returns 404, forcing the client into the
|
||||
/// legacy single-blob AB path (`GET/POST /api/ab`). The default is the
|
||||
/// modern shared-AB path.
|
||||
pub ab_legacy_mode: bool,
|
||||
/// Surfaced verbatim via `/api/ab/settings.max_peer_one_ab`.
|
||||
pub ab_max_peers_per_book: i64,
|
||||
/// On-disk root for `/api/record` uploads. Created on first use; one
|
||||
/// subdirectory per peer-id under here.
|
||||
pub recording_dir: PathBuf,
|
||||
/// 0 means unlimited.
|
||||
pub recording_max_size_bytes: u64,
|
||||
/// 0 means no retention sweep.
|
||||
pub audit_retention_days: i64,
|
||||
/// SMTP transport for email-code login. `None` = dev mode: codes are
|
||||
/// logged to stdout instead of mailed.
|
||||
pub email: Option<EmailConfig>,
|
||||
/// Externally reachable base URL of this server, e.g. for the OIDC
|
||||
/// redirect_uri. Empty disables OIDC.
|
||||
pub public_base_url: String,
|
||||
/// On-disk root for the admin dashboard's static files. Empty disables
|
||||
/// the dashboard entirely.
|
||||
pub admin_ui_dir: String,
|
||||
}
|
||||
|
||||
/// SMTP wiring for email-code login.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct EmailConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub from: String,
|
||||
pub starttls: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: Database,
|
||||
pub cfg: ApiConfig,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(db: Database) -> Arc<Self> {
|
||||
let ab_legacy_mode = matches!(
|
||||
get_arg_or("ab-legacy-mode", "off".to_string())
|
||||
.to_ascii_lowercase()
|
||||
.as_str(),
|
||||
"on" | "y" | "yes" | "true" | "1"
|
||||
);
|
||||
let ab_max_peers_per_book: i64 = get_arg_or("ab-max-peers-per-book", "100".to_string())
|
||||
.parse()
|
||||
.unwrap_or(100);
|
||||
let recording_dir =
|
||||
PathBuf::from(get_arg_or("recording-dir", "./recordings".to_string()));
|
||||
let recording_max_size_bytes: u64 = get_arg_or("recording-max-size-mb", "0".to_string())
|
||||
.parse::<u64>()
|
||||
.unwrap_or(0)
|
||||
.saturating_mul(1024 * 1024);
|
||||
let audit_retention_days: i64 = get_arg_or("audit-retention-days", "0".to_string())
|
||||
.parse()
|
||||
.unwrap_or(0);
|
||||
let email = build_email_config();
|
||||
let public_base_url = get_arg("public-base-url");
|
||||
let admin_ui_dir = get_arg_or("admin-ui-dir", "./admin_ui".to_string());
|
||||
// login_options advertises every login method this server accepts.
|
||||
// The Flutter client uses this to render the matching button on the
|
||||
// sign-in dialog. `email_code` and `oidc/<name>` are opt-in so a
|
||||
// deployment without SMTP / OIDC doesn't dangle a broken button.
|
||||
let mut login_options = vec!["account".to_string()];
|
||||
if email.is_some() || std::env::var("ALLOW_DEV_EMAIL_CODE").is_ok() {
|
||||
login_options.push("email_code".to_string());
|
||||
}
|
||||
// OIDC providers are mounted dynamically — actual provider names are
|
||||
// appended later by the oidc::providers loader once the DB rows exist.
|
||||
Arc::new(Self {
|
||||
db,
|
||||
cfg: ApiConfig {
|
||||
login_options,
|
||||
sysinfo_ver: "m1-1".to_string(),
|
||||
session_ttl_secs: 30 * 86400,
|
||||
ab_legacy_mode,
|
||||
ab_max_peers_per_book,
|
||||
recording_dir,
|
||||
recording_max_size_bytes,
|
||||
audit_retention_days,
|
||||
email,
|
||||
public_base_url,
|
||||
admin_ui_dir,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn build_email_config() -> Option<EmailConfig> {
|
||||
let host = get_arg("smtp-host");
|
||||
if host.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let port: u16 = get_arg_or("smtp-port", "587".to_string())
|
||||
.parse()
|
||||
.unwrap_or(587);
|
||||
let username = {
|
||||
let u = get_arg("smtp-user");
|
||||
if u.is_empty() { None } else { Some(u) }
|
||||
};
|
||||
let password = {
|
||||
let p = get_arg("smtp-pass");
|
||||
if p.is_empty() { None } else { Some(p) }
|
||||
};
|
||||
let from = {
|
||||
let f = get_arg("smtp-from");
|
||||
if f.is_empty() {
|
||||
format!("noreply@{}", host)
|
||||
} else {
|
||||
f
|
||||
}
|
||||
};
|
||||
let starttls = matches!(
|
||||
get_arg_or("smtp-tls", "on".to_string()).to_ascii_lowercase().as_str(),
|
||||
"on" | "y" | "yes" | "true" | "1"
|
||||
);
|
||||
Some(EmailConfig {
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
from,
|
||||
starttls,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Strategy resolver for the heartbeat path. The actual SQL lives in
|
||||
//! `Database::strategy_resolve_for` — this module exists to give the
|
||||
//! heartbeat handler a stable import surface and to centralize how a
|
||||
//! resolved strategy is converted into the wire-shape JSON the client
|
||||
//! expects (`strategy.config_options` + `strategy.extra` per
|
||||
//! CONSOLE_API.md §6.1).
|
||||
|
||||
use crate::api::state::AppState;
|
||||
use crate::database::ResolvedStrategy;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Resolve and serialize a strategy for `peer_id`. Returns
|
||||
/// `(modified_at, strategy_value)` where `strategy_value` is the JSON object
|
||||
/// the heartbeat reply embeds under `strategy`. When no strategy applies, we
|
||||
/// return an empty `{config_options: {}, extra: {}}` and `modified_at = 0`.
|
||||
pub async fn resolve_for(state: &AppState, peer_id: &str) -> (i64, Value) {
|
||||
let resolved = state
|
||||
.db
|
||||
.strategy_resolve_for(peer_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
serialize(&resolved)
|
||||
}
|
||||
|
||||
fn serialize(r: &ResolvedStrategy) -> (i64, Value) {
|
||||
let cfg: Value = serde_json::from_str(&r.config_options_json).unwrap_or_else(|_| json!({}));
|
||||
let extra: Value = serde_json::from_str(&r.extra_json).unwrap_or_else(|_| json!({}));
|
||||
(
|
||||
r.modified_at,
|
||||
json!({
|
||||
"config_options": cfg,
|
||||
"extra": extra,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// `true` iff the resolved strategy for `peer_id` carries
|
||||
/// `config_options."enable-remote-exec" = "Y"`. Default is `false` (no
|
||||
/// strategy assigned, or strategy didn't set the key) — exec is opt-in
|
||||
/// per the design in docs/AGENT-API-AUTH.md.
|
||||
pub async fn allows_remote_exec(state: &AppState, peer_id: &str) -> bool {
|
||||
let resolved = state
|
||||
.db
|
||||
.strategy_resolve_for(peer_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let cfg: Value = serde_json::from_str(&resolved.config_options_json)
|
||||
.unwrap_or_else(|_| json!({}));
|
||||
cfg.get("enable-remote-exec")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.eq_ignore_ascii_case("Y"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
use crate::api::device_auth::{self, AuthOutcome};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::state::AppState;
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::Extension;
|
||||
use axum::http::HeaderMap;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Plain-text version string that the client compares against its cached
|
||||
/// `sysinfo_ver`. Same value the heartbeat handler echoes via the
|
||||
/// `sysinfo: true` flag.
|
||||
pub async fn sysinfo_ver(Extension(state): Extension<Arc<AppState>>) -> String {
|
||||
state.cfg.sysinfo_ver.clone()
|
||||
}
|
||||
|
||||
/// Bare-string body: `"SYSINFO_UPDATED"` or `"ID_NOT_FOUND"`. The client at
|
||||
/// /Users/sn0/Desktop/rustdesk/src/hbbs_http/sync.rs:212 does a literal
|
||||
/// `==` comparison on these — do not wrap in JSON.
|
||||
pub async fn sysinfo(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<String, ApiError> {
|
||||
// Step 1: signature gate. Verified → trust the id from the signed
|
||||
// header. LegacyUnsigned → fall through but enforce that the body id
|
||||
// isn't a managed peer (would be downgrade attempt).
|
||||
let outcome = device_auth::verify(&state, "POST", "/api/sysinfo", &headers, &body).await?;
|
||||
|
||||
// Step 2: parse body.
|
||||
let payload: Value = serde_json::from_slice(&body)
|
||||
.map_err(|_| ApiError::BadRequest("invalid json".into()))?;
|
||||
let body_id = payload
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
let uuid = payload
|
||||
.get("uuid")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
if body_id.is_empty() || uuid.is_empty() {
|
||||
return Err(ApiError::BadRequest("id and uuid required".into()));
|
||||
}
|
||||
|
||||
// Step 3: bind the trusted identity to the body. For signed requests,
|
||||
// the body id must match the header id — otherwise the agent is trying
|
||||
// to write inventory for someone else.
|
||||
let id = match outcome {
|
||||
AuthOutcome::Verified { id: signed_id } => {
|
||||
if body_id != signed_id {
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
signed_id
|
||||
}
|
||||
AuthOutcome::LegacyUnsigned => {
|
||||
device_auth::enforce_managed_for_id(&state, body_id).await?;
|
||||
body_id.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
// Tie sysinfo storage to a real rendezvous-registered peer. Without this
|
||||
// gate, any caller could populate device_sysinfo for arbitrary IDs.
|
||||
let peer = state
|
||||
.db
|
||||
.get_peer(&id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
if peer.is_none() {
|
||||
return Ok("ID_NOT_FOUND".to_string());
|
||||
}
|
||||
|
||||
let version = parse_version_number(payload.get("version").and_then(|v| v.as_str()));
|
||||
state
|
||||
.db
|
||||
.sysinfo_upsert(&id, uuid, &payload.to_string(), &state.cfg.sysinfo_ver, version)
|
||||
.await?;
|
||||
Ok("SYSINFO_UPDATED".to_string())
|
||||
}
|
||||
|
||||
fn parse_version_number(s: Option<&str>) -> i64 {
|
||||
let Some(s) = s else { return 0 };
|
||||
// hbb_common encodes "1.4.2" as 1*1_000_000 + 4*1_000 + 2 = 1_004_002.
|
||||
let mut parts = s.split('.').map(|p| p.parse::<i64>().unwrap_or(0));
|
||||
let major = parts.next().unwrap_or(0);
|
||||
let minor = parts.next().unwrap_or(0);
|
||||
let patch = parts.next().unwrap_or(0);
|
||||
major * 1_000_000 + minor * 1_000 + patch
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! `POST /api/2fa/enroll` — admin-only TOTP enrollment.
|
||||
//!
|
||||
//! Generates a fresh 20-byte (160-bit) base32 secret, stores it for the
|
||||
//! target user, and returns:
|
||||
//! - `secret_b32` — the literal secret to enter into an authenticator app.
|
||||
//! - `otpauth_url` — the standard `otpauth://totp/...` URL the same apps
|
||||
//! accept as a QR-code or pasted-string.
|
||||
//!
|
||||
//! There is no client-facing UI for this in the desktop app; operators run it
|
||||
//! by curl after creating the user. M4's `--bootstrap-admin-username` admin
|
||||
//! is the natural caller.
|
||||
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use crate::api::state::AppState;
|
||||
use axum::extract::Extension;
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
use totp_rs::Secret;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EnrollBody {
|
||||
/// Either `user_id` or `username` is required. `user_id` wins if both
|
||||
/// are present.
|
||||
#[serde(default)]
|
||||
pub user_id: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
/// Issuer name shown in the authenticator app. Defaults to "RustDesk".
|
||||
#[serde(default)]
|
||||
pub issuer: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UnenrollBody {
|
||||
#[serde(default)]
|
||||
pub user_id: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn enroll(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
caller: AuthedUser,
|
||||
Json(body): Json<EnrollBody>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
if !caller.is_admin {
|
||||
return Err(ApiError::Forbidden("admin required".into()));
|
||||
}
|
||||
let user = resolve_target(&state, body.user_id, body.username.as_deref()).await?;
|
||||
|
||||
// 20 random bytes -> base32 (the standard size for SHA1 TOTP).
|
||||
let raw = sodiumoxide::randombytes::randombytes(20);
|
||||
let secret_b32 = Secret::Raw(raw.clone()).to_encoded().to_string();
|
||||
|
||||
state
|
||||
.db
|
||||
.totp_enroll(user.id, &secret_b32)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
|
||||
let issuer = body
|
||||
.issuer
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("RustDesk");
|
||||
// Build the otpauth:// URL manually rather than depend on totp-rs's
|
||||
// URL helpers (their API has shifted between minor versions). Format
|
||||
// per https://github.com/google/google-authenticator/wiki/Key-Uri-Format.
|
||||
let otpauth_url = format!(
|
||||
"otpauth://totp/{issuer}:{account}?secret={secret}&issuer={issuer}&algorithm=SHA1&digits=6&period=30",
|
||||
issuer = url_encode(issuer),
|
||||
account = url_encode(&user.username),
|
||||
secret = url_encode(&secret_b32),
|
||||
);
|
||||
|
||||
Ok(Json(json!({
|
||||
"user_id": user.id,
|
||||
"username": user.username,
|
||||
"secret_b32": secret_b32,
|
||||
"otpauth_url": otpauth_url,
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn unenroll(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
caller: AuthedUser,
|
||||
Json(body): Json<UnenrollBody>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
if !caller.is_admin {
|
||||
return Err(ApiError::Forbidden("admin required".into()));
|
||||
}
|
||||
let user = resolve_target(&state, body.user_id, body.username.as_deref()).await?;
|
||||
let removed = state
|
||||
.db
|
||||
.totp_unenroll(user.id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(Json(json!({ "removed": removed })))
|
||||
}
|
||||
|
||||
/// Minimal percent-encoder for the otpauth URL fields. Encodes anything
|
||||
/// outside the unreserved URL set (`A-Za-z0-9-_.~`) — keeps the URL short
|
||||
/// and avoids pulling in `urlencoding` just for this single call site.
|
||||
fn url_encode(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for b in s.as_bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(*b as char);
|
||||
}
|
||||
_ => {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(out, "%{:02X}", b);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
async fn resolve_target(
|
||||
state: &AppState,
|
||||
user_id: Option<i64>,
|
||||
username: Option<&str>,
|
||||
) -> Result<crate::database::UserRow, ApiError> {
|
||||
if let Some(id) = user_id {
|
||||
return state
|
||||
.db
|
||||
.user_find_by_id(id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?
|
||||
.ok_or(ApiError::NotFound);
|
||||
}
|
||||
if let Some(name) = username.filter(|s| !s.is_empty()) {
|
||||
return state
|
||||
.db
|
||||
.user_find_by_username(name)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?
|
||||
.ok_or(ApiError::NotFound);
|
||||
}
|
||||
Err(ApiError::BadRequest(
|
||||
"user_id or username required".into(),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! `POST /api/unattended-password` — agent-side reporting of the per-boot
|
||||
//! "permanent password" used for unattended access (no logged-in user to
|
||||
//! click the approval popup). hello-agent generates a random password
|
||||
//! every time the service starts and posts it here so the admin UI can
|
||||
//! surface it for support staff.
|
||||
//!
|
||||
//! Auth: same per-peer signed-API gate as `/api/sysinfo` and
|
||||
//! `/api/heartbeat` — see [`crate::api::device_auth`]. Managed peers
|
||||
//! (`peer.managed = 1`) must carry a valid Ed25519 signature; stock
|
||||
//! clients keep posting unsigned and the first valid signature TOFU-
|
||||
//! promotes the peer.
|
||||
|
||||
use crate::api::device_auth::{self, AuthOutcome};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::state::AppState;
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::Extension;
|
||||
use axum::http::HeaderMap;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Body: `{"id": "...", "uuid": "...", "password": "..."}`
|
||||
/// Response (bare string, like sysinfo): `"OK"` or `"ID_NOT_FOUND"`.
|
||||
pub async fn unattended_password(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<String, ApiError> {
|
||||
let outcome = device_auth::verify(
|
||||
&state,
|
||||
"POST",
|
||||
"/api/unattended-password",
|
||||
&headers,
|
||||
&body,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let payload: Value = serde_json::from_slice(&body)
|
||||
.map_err(|_| ApiError::BadRequest("invalid json".into()))?;
|
||||
let body_id = payload
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
let uuid = payload
|
||||
.get("uuid")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
let password = payload
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
if body_id.is_empty() || uuid.is_empty() || password.is_empty() {
|
||||
return Err(ApiError::BadRequest(
|
||||
"id, uuid, and password are required".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Bind the trusted identity to the body. For signed requests the body
|
||||
// id must match the header id, or the agent is trying to overwrite
|
||||
// someone else's displayed password. For unsigned requests we just
|
||||
// need to ensure the peer isn't already locked down as managed.
|
||||
let id = match outcome {
|
||||
AuthOutcome::Verified { id: signed_id } => {
|
||||
if body_id != signed_id {
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
signed_id
|
||||
}
|
||||
AuthOutcome::LegacyUnsigned => {
|
||||
device_auth::enforce_managed_for_id(&state, body_id).await?;
|
||||
body_id.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let peer = state
|
||||
.db
|
||||
.get_peer(&id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
if peer.is_none() {
|
||||
return Ok("ID_NOT_FOUND".to_string());
|
||||
}
|
||||
|
||||
state
|
||||
.db
|
||||
.set_unattended_password(&id, uuid, password)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok("OK".to_string())
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use crate::api::pagination::{Page, PageQuery};
|
||||
use crate::api::state::AppState;
|
||||
use crate::database::UserRow;
|
||||
use axum::extract::{Extension, Query};
|
||||
use axum::Json;
|
||||
use hbb_common::ResultType;
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UserPayload {
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
pub avatar: String,
|
||||
pub email: String,
|
||||
pub note: String,
|
||||
pub status: i64,
|
||||
pub is_admin: bool,
|
||||
/// The desktop client's OIDC poll loop deserializes the AuthBody using
|
||||
/// the Rust struct in src/hbbs_http/account.rs, where `info` is a
|
||||
/// REQUIRED field (no #[serde(default)]). Missing it makes serde fail,
|
||||
/// the poll loop's `Ok(_)` arm fires, and the client polls forever
|
||||
/// even though the OIDC session was successful. Emit an empty object
|
||||
/// — the client's own UserInfo defaults handle the rest.
|
||||
pub info: Value,
|
||||
}
|
||||
|
||||
impl From<&UserRow> for UserPayload {
|
||||
fn from(u: &UserRow) -> Self {
|
||||
Self {
|
||||
name: u.username.clone(),
|
||||
display_name: u.display_name.clone(),
|
||||
avatar: u.avatar.clone(),
|
||||
email: u.email.clone(),
|
||||
note: u.note.clone(),
|
||||
status: u.status,
|
||||
is_admin: u.is_admin,
|
||||
info: json!({}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn hash_password(plain: String) -> ResultType<String> {
|
||||
Ok(
|
||||
hbb_common::tokio::task::spawn_blocking(move || bcrypt::hash(plain, 10))
|
||||
.await??,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn verify_password(hash: String, plain: String) -> ResultType<bool> {
|
||||
Ok(
|
||||
hbb_common::tokio::task::spawn_blocking(move || bcrypt::verify(plain, &hash))
|
||||
.await??,
|
||||
)
|
||||
}
|
||||
|
||||
/// `GET /api/users` — paginated list of users visible to the caller. Admin
|
||||
/// sees all enabled users; non-admin sees themselves plus members of any
|
||||
/// device-group they share. Flutter decoder at common/hbbs/hbbs.dart:26.
|
||||
pub async fn list(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
user: AuthedUser,
|
||||
Query(q): Query<PageQuery>,
|
||||
) -> Result<Json<Page<UserPayload>>, ApiError> {
|
||||
let (total, rows) = state
|
||||
.db
|
||||
.users_list_accessible(user.user_id, user.is_admin, q.offset(), q.limit())
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(Json(Page {
|
||||
total,
|
||||
data: rows.iter().map(UserPayload::from).collect(),
|
||||
}))
|
||||
}
|
||||
+23
-1
@@ -1,6 +1,7 @@
|
||||
use clap::App;
|
||||
use hbb_common::{
|
||||
allow_err, anyhow::{Context, Result}, get_version_number, log, tokio, ResultType
|
||||
allow_err, anyhow::{Context, Result}, get_version_number, log, tcp::listen_any, tokio,
|
||||
tokio::net::TcpListener, ResultType,
|
||||
};
|
||||
use ini::Ini;
|
||||
use sodiumoxide::crypto::sign;
|
||||
@@ -11,6 +12,27 @@ use std::{
|
||||
time::{Instant, SystemTime},
|
||||
};
|
||||
|
||||
/// Bind a TCP listener for `port`. When `host` is empty (the default for
|
||||
/// every flag that accepts it), falls through to `listen_any` which binds
|
||||
/// the dual-stack `[::]` wildcard. When `host` is set, binds only to that
|
||||
/// address — used by deployments that put nginx/Caddy out front for TLS
|
||||
/// termination on the WS / HTTP ports and want hbbs/hbbr's plain sockets
|
||||
/// reachable only from localhost.
|
||||
pub async fn bind_tcp_listener(host: &str, port: i32) -> ResultType<TcpListener> {
|
||||
if host.is_empty() {
|
||||
return listen_any(port as u16).await;
|
||||
}
|
||||
let host_with_brackets = if host.contains(':') && !host.starts_with('[') {
|
||||
format!("[{}]", host)
|
||||
} else {
|
||||
host.to_string()
|
||||
};
|
||||
let addr: SocketAddr = format!("{}:{}", host_with_brackets, port).parse()?;
|
||||
let l = TcpListener::bind(addr).await?;
|
||||
log::info!("listen on tcp {}", addr);
|
||||
Ok(l)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn get_expired_time() -> Instant {
|
||||
let now = Instant::now();
|
||||
|
||||
+4099
-1
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ fn main() -> ResultType<()> {
|
||||
let args = format!(
|
||||
"-p, --port=[NUMBER(default={RELAY_PORT})] 'Sets the listening port'
|
||||
-k, --key=[KEY] 'Only allow the client with the same key'
|
||||
--ws-listen=[HOST] 'Bind address for the browser-facing WebSocket relay port (port+2). Default = wildcard. Set to 127.0.0.1 (or ::1) when a reverse proxy claims the public port for TLS termination.'
|
||||
",
|
||||
);
|
||||
let matches = App::new("hbbr")
|
||||
@@ -40,6 +41,7 @@ fn main() -> ResultType<()> {
|
||||
matches
|
||||
.value_of("key")
|
||||
.unwrap_or(&std::env::var("KEY").unwrap_or_default()),
|
||||
matches.value_of("ws-listen").unwrap_or(""),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
mod rendezvous_server;
|
||||
pub use rendezvous_server::*;
|
||||
pub mod api;
|
||||
pub mod common;
|
||||
mod database;
|
||||
pub mod database;
|
||||
mod peer;
|
||||
mod version;
|
||||
|
||||
+32
-1
@@ -21,6 +21,26 @@ fn main() -> ResultType<()> {
|
||||
-u, --software-url=[URL] 'Sets download url of RustDesk software of newest version'
|
||||
-r, --relay-servers=[HOST] 'Sets the default relay servers, separated by comma'
|
||||
-M, --rmem=[NUMBER(default={RMEM})] 'Sets UDP recv buffer size, set system rmem_max first, e.g., sudo sysctl -w net.core.rmem_max=52428800. vi /etc/sysctl.conf, net.core.rmem_max=52428800, sudo sysctl –p'
|
||||
--http-port=[NUMBER(default=21114)] 'HTTP management API port (0 disables)'
|
||||
--http-listen=[HOST] 'Bind address for --http-port. Default = wildcard. Set to 127.0.0.1 (or ::1) when nginx/Caddy fronts this port for TLS.'
|
||||
--ws-listen=[HOST] 'Bind address for the browser-facing WebSocket rendezvous port (port+2). Default = wildcard. Set to 127.0.0.1 (or ::1) when a reverse proxy claims the public port for TLS termination.'
|
||||
--bootstrap-admin-username=[USERNAME] 'Username to seed on first startup if users table is empty'
|
||||
--bootstrap-admin-password=[PASSWORD] 'Password to seed on first startup if users table is empty'
|
||||
--ab-legacy-mode=[on|off] 'When on, /api/ab/personal returns 404 to force legacy single-blob AB'
|
||||
--ab-max-peers-per-book=[NUMBER(default=100)] 'Surfaced via /api/ab/settings.max_peer_one_ab'
|
||||
--recording-dir=[PATH(default=./recordings)] 'Root directory for /api/record uploads'
|
||||
--recording-max-size-mb=[NUMBER] 'Optional ceiling per recording file; 0 or unset = unlimited'
|
||||
--audit-retention-days=[NUMBER] 'Hourly task deletes audit rows older than N days; 0 disables'
|
||||
--smtp-host=[HOST] 'SMTP host for email-code login; if empty, codes are logged to stdout (dev mode)'
|
||||
--smtp-port=[NUMBER(default=587)] 'SMTP port'
|
||||
--smtp-user=[USER] 'SMTP username (omit for unauthenticated relays)'
|
||||
--smtp-pass=[PASS] 'SMTP password'
|
||||
--smtp-from=[ADDR] 'From: address for outbound login emails (default: noreply@<smtp-host>)'
|
||||
--smtp-tls=[on|off] 'STARTTLS on the SMTP connection (default: on)'
|
||||
--public-base-url=[URL] 'Externally reachable HTTP base URL (e.g. https://rustdesk.example.com:21114) — required for OIDC redirect callbacks'
|
||||
--oidc-config=[PATH] 'TOML file describing OIDC providers (upserted into oidc_providers at startup)'
|
||||
--admin-ui-dir=[PATH] 'Directory of static admin-dashboard files served at /admin/ (default: ./admin_ui; empty disables)'
|
||||
--unattended-pwd-visibility=[always|logged-out] 'When the admin UI shows a device unattended password. logged-out (default) = only when nobody is logged in; always = also while a user is logged in'
|
||||
, --mask=[MASK] 'Determine if the connection comes from LAN, e.g. 192.168.0.0/16'
|
||||
-k, --key=[KEY] 'Only allow the client with the same key'",
|
||||
);
|
||||
@@ -31,7 +51,18 @@ fn main() -> ResultType<()> {
|
||||
}
|
||||
let rmem = get_arg("rmem").parse::<usize>().unwrap_or(RMEM);
|
||||
let serial: i32 = get_arg("serial").parse().unwrap_or(0);
|
||||
let http_port: i32 = get_arg_or("http-port", "21114".to_string())
|
||||
.parse()
|
||||
.unwrap_or(21114);
|
||||
crate::common::check_software_update();
|
||||
RendezvousServer::start(port, serial, &get_arg_or("key", "-".to_owned()), rmem)?;
|
||||
RendezvousServer::start(
|
||||
port,
|
||||
serial,
|
||||
&get_arg_or("key", "-".to_owned()),
|
||||
rmem,
|
||||
http_port,
|
||||
&get_arg("ws-listen"),
|
||||
&get_arg("http-listen"),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+13
-2
@@ -46,7 +46,7 @@ const BLACKLIST_FILE: &str = "blacklist.txt";
|
||||
const BLOCKLIST_FILE: &str = "blocklist.txt";
|
||||
|
||||
#[tokio::main(flavor = "multi_thread")]
|
||||
pub async fn start(port: &str, key: &str) -> ResultType<()> {
|
||||
pub async fn start(port: &str, key: &str, ws_listen: &str) -> ResultType<()> {
|
||||
let key = get_server_sk(key);
|
||||
if let Ok(mut file) = std::fs::File::open(BLACKLIST_FILE) {
|
||||
let mut contents = String::new();
|
||||
@@ -82,10 +82,21 @@ pub async fn start(port: &str, key: &str) -> ResultType<()> {
|
||||
log::info!("Listening on tcp :{}", port);
|
||||
let port2 = port + 2;
|
||||
log::info!("Listening on websocket :{}", port2);
|
||||
// The WS port (21119 default) is the only browser-facing endpoint at
|
||||
// hbbr — operators put nginx/Caddy in front of it for TLS. Allow
|
||||
// pinning it to localhost so the reverse proxy can claim the public
|
||||
// port without colliding. The plain TCP relay port (21117) is for
|
||||
// desktop clients and stays on the wildcard.
|
||||
let ws_listen = ws_listen.to_owned();
|
||||
let main_task = async move {
|
||||
loop {
|
||||
log::info!("Start");
|
||||
io_loop(listen_any(port).await?, listen_any(port2).await?, &key).await;
|
||||
io_loop(
|
||||
listen_any(port).await?,
|
||||
crate::common::bind_tcp_listener(&ws_listen, port2 as i32).await?,
|
||||
&key,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
};
|
||||
let listen_signal = crate::common::listen_signal();
|
||||
|
||||
+251
-11
@@ -8,7 +8,7 @@ use hbb_common::{
|
||||
futures::future::join_all,
|
||||
futures_util::{
|
||||
sink::SinkExt,
|
||||
stream::{SplitSink, StreamExt},
|
||||
stream::{SplitSink, SplitStream, StreamExt},
|
||||
},
|
||||
log,
|
||||
protobuf::{Message as _, MessageField},
|
||||
@@ -16,7 +16,7 @@ use hbb_common::{
|
||||
register_pk_response::Result::{TOO_FREQUENT, UUID_MISMATCH},
|
||||
*,
|
||||
},
|
||||
tcp::{listen_any, FramedStream},
|
||||
tcp::{listen_any, Encrypt, FramedStream},
|
||||
timeout,
|
||||
tokio::{
|
||||
self,
|
||||
@@ -31,7 +31,7 @@ use hbb_common::{
|
||||
AddrMangle, ResultType,
|
||||
};
|
||||
use ipnetwork::Ipv4Network;
|
||||
use sodiumoxide::crypto::sign;
|
||||
use sodiumoxide::crypto::{box_, sign};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
|
||||
@@ -49,9 +49,14 @@ enum Data {
|
||||
|
||||
const REG_TIMEOUT: i32 = 30_000;
|
||||
type TcpStreamSink = SplitSink<Framed<TcpStream, BytesCodec>, Bytes>;
|
||||
type TcpStreamSrc = SplitStream<Framed<TcpStream, BytesCodec>>;
|
||||
type WsSink = SplitSink<tokio_tungstenite::WebSocketStream<TcpStream>, tungstenite::Message>;
|
||||
enum Sink {
|
||||
TcpStream(TcpStreamSink),
|
||||
/// Plain or encrypted TCP. The optional `Encrypt` is only present after a
|
||||
/// successful server-initiated `secure_tcp` handshake — see
|
||||
/// `try_secure_tcp_handshake`. When `Some`, every outgoing message is
|
||||
/// sealed with secretbox before being framed.
|
||||
TcpStream(TcpStreamSink, Option<Encrypt>),
|
||||
Ws(WsSink),
|
||||
}
|
||||
type Sender = mpsc::UnboundedSender<Data>;
|
||||
@@ -99,11 +104,62 @@ enum LoopFailure {
|
||||
|
||||
impl RendezvousServer {
|
||||
#[tokio::main(flavor = "multi_thread")]
|
||||
pub async fn start(port: i32, serial: i32, key: &str, rmem: usize) -> ResultType<()> {
|
||||
pub async fn start(
|
||||
port: i32,
|
||||
serial: i32,
|
||||
key: &str,
|
||||
rmem: usize,
|
||||
http_port: i32,
|
||||
ws_listen: &str,
|
||||
http_listen: &str,
|
||||
) -> ResultType<()> {
|
||||
let (key, sk) = Self::get_server_sk(key);
|
||||
let nat_port = port - 1;
|
||||
let ws_port = port + 2;
|
||||
// Capture the bind addresses as owned Strings so the async move
|
||||
// closures below can hold onto them across reconnect retries.
|
||||
let ws_listen = ws_listen.to_owned();
|
||||
let http_listen = http_listen.to_owned();
|
||||
let pm = PeerMap::new().await?;
|
||||
// M1: build the HTTP API state and seed the admin user if requested.
|
||||
// Done here (right after PeerMap::new) so the API server, the seeding,
|
||||
// and the rendezvous loop all share the same Database connection pool.
|
||||
let api_state = crate::api::AppState::new(pm.db.clone());
|
||||
// M4: hand the same Ed25519 secret used for the rendezvous key
|
||||
// exchange to the plugin-signing handler. Without this set,
|
||||
// POST /lic/web/api/plugin-sign returns "plugin signing not configured".
|
||||
if let Some(sk_ref) = sk.clone() {
|
||||
crate::api::plugin_sign::set_signing_key(sk_ref);
|
||||
}
|
||||
// M4: load operator-supplied OIDC providers from --oidc-config (TOML).
|
||||
// Errors are logged but don't kill the server — the operator can
|
||||
// hand-insert into oidc_providers as a fallback.
|
||||
let oidc_path = get_arg("oidc-config");
|
||||
if !oidc_path.is_empty() {
|
||||
let public_base = api_state.cfg.public_base_url.clone();
|
||||
let db = pm.db.clone();
|
||||
match crate::api::oidc::providers::load_from_file(
|
||||
&db,
|
||||
std::path::Path::new(&oidc_path),
|
||||
&public_base,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(n) => log::info!("oidc: loaded {} providers from {}", n, oidc_path),
|
||||
Err(e) => log::warn!("oidc: failed to load {}: {}", oidc_path, e),
|
||||
}
|
||||
}
|
||||
{
|
||||
let bn = get_arg("bootstrap-admin-username");
|
||||
let bp = get_arg("bootstrap-admin-password");
|
||||
if !bn.is_empty() && !bp.is_empty() {
|
||||
if let Err(e) = pm.db.bootstrap_admin(&bn, &bp).await {
|
||||
log::warn!("bootstrap admin failed: {}", e);
|
||||
}
|
||||
} else {
|
||||
pm.db.warn_if_no_users().await;
|
||||
}
|
||||
}
|
||||
log::info!("serial={}", serial);
|
||||
let rendezvous_servers = get_servers(&get_arg("rendezvous-servers"), "rendezvous-servers");
|
||||
log::info!("Listening on tcp/udp :{}", port);
|
||||
@@ -149,7 +205,11 @@ impl RendezvousServer {
|
||||
rs.parse_relay_servers(&get_arg("relay-servers"));
|
||||
let mut listener = create_tcp_listener(port).await?;
|
||||
let mut listener2 = create_tcp_listener(nat_port).await?;
|
||||
let mut listener3 = create_tcp_listener(ws_port).await?;
|
||||
// The WS port is the only browser-facing endpoint at hbbs — it's
|
||||
// the one operators put nginx/Caddy in front of for TLS. Allow
|
||||
// pinning it to localhost so the reverse proxy can claim
|
||||
// `[::]:21118` without colliding.
|
||||
let mut listener3 = crate::common::bind_tcp_listener(&ws_listen, ws_port).await?;
|
||||
let test_addr = std::env::var("TEST_HBBS").unwrap_or_default();
|
||||
if std::env::var("ALWAYS_USE_RELAY")
|
||||
.unwrap_or_default()
|
||||
@@ -216,15 +276,37 @@ impl RendezvousServer {
|
||||
}
|
||||
LoopFailure::Listener3 => {
|
||||
drop(listener3);
|
||||
listener3 = create_tcp_listener(ws_port).await?;
|
||||
listener3 = crate::common::bind_tcp_listener(&ws_listen, ws_port).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let listen_signal = listen_signal();
|
||||
// The HTTP API task. `pending()` keeps the select! arm well-typed
|
||||
// when the operator disabled it via `--http-port=0` — that branch
|
||||
// never fires.
|
||||
let api_task: std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = ResultType<()>> + Send>,
|
||||
> = if http_port > 0 {
|
||||
let bind_host = if http_listen.is_empty() { "0.0.0.0" } else { http_listen.as_str() };
|
||||
// Allow IPv6 / [::1] / hostnames — wrap bare IPv6 in brackets for the URL form.
|
||||
let host_with_brackets = if bind_host.contains(':') && !bind_host.starts_with('[') {
|
||||
format!("[{}]", bind_host)
|
||||
} else {
|
||||
bind_host.to_string()
|
||||
};
|
||||
let addr: SocketAddr = format!("{}:{}", host_with_brackets, http_port).parse()?;
|
||||
log::info!("HTTP API listening on {}", addr);
|
||||
let st = api_state.clone();
|
||||
Box::pin(crate::api::serve(addr, st))
|
||||
} else {
|
||||
log::info!("HTTP API disabled (http-port = 0)");
|
||||
Box::pin(std::future::pending::<ResultType<()>>())
|
||||
};
|
||||
tokio::select!(
|
||||
res = main_task => res,
|
||||
res = listen_signal => res,
|
||||
res = api_task => res,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -562,6 +644,16 @@ impl RendezvousServer {
|
||||
});
|
||||
Self::send_to_sink(sink, msg_out).await;
|
||||
}
|
||||
// M4: HTTP-over-rendezvous fallback. The client uses this when
|
||||
// OPTION_USE_RAW_TCP_FOR_API=Y (locked-down networks where
|
||||
// direct HTTPS is blocked). We dispatch the wrapped request
|
||||
// through the SAME axum router as the HTTP listener.
|
||||
Some(rendezvous_message::Union::HttpProxyRequest(req)) => {
|
||||
let resp = crate::api::http_proxy::dispatch(req).await;
|
||||
let mut msg_out = RendezvousMessage::new();
|
||||
msg_out.set_http_proxy_response(resp);
|
||||
Self::send_to_sink(sink, msg_out).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -831,7 +923,12 @@ impl RendezvousServer {
|
||||
if let Some(sink) = sink.as_mut() {
|
||||
if let Ok(bytes) = msg.write_to_bytes() {
|
||||
match sink {
|
||||
Sink::TcpStream(s) => {
|
||||
Sink::TcpStream(s, enc) => {
|
||||
let bytes = if let Some(enc) = enc.as_mut() {
|
||||
enc.enc(&bytes)
|
||||
} else {
|
||||
bytes
|
||||
};
|
||||
allow_err!(s.send(Bytes::from(bytes)).await);
|
||||
}
|
||||
Sink::Ws(ws) => {
|
||||
@@ -1185,9 +1282,70 @@ impl RendezvousServer {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let (a, mut b) = Framed::new(stream, BytesCodec::new()).split();
|
||||
sink = Some(Sink::TcpStream(a));
|
||||
while let Ok(Some(Ok(bytes))) = timeout(30_000, b.next()).await {
|
||||
let (mut a, mut b) = Framed::new(stream, BytesCodec::new()).split();
|
||||
// Server-initiated secure_tcp handshake. Only attempted when the
|
||||
// server has a signing key (the default — `--key=-` auto-generates
|
||||
// one). Signs an ephemeral box public key and sends it to the
|
||||
// client; the client may either reply with a sealed symmetric key
|
||||
// (the secure path used by logged-in clients, see
|
||||
// src/client.rs:427-431 and src/common.rs:1939) or send a regular
|
||||
// protobuf message (plain mode). Plain-mode clients filter out
|
||||
// unsolicited KeyExchange via get_next_nonkeyexchange_msg, so the
|
||||
// KeyExchange we just emitted is harmless to them.
|
||||
let mut decrypter: Option<Encrypt> = None;
|
||||
let mut buffered_first: Option<BytesMut> = None;
|
||||
if let Some(sk) = self.inner.sk.clone() {
|
||||
log::info!("secure_tcp: handshake starting for {}", addr);
|
||||
match try_secure_tcp_handshake(&mut a, &mut b, &sk).await {
|
||||
Ok(HandshakeOutcome::Secure(enc)) => {
|
||||
let send_state = enc.clone();
|
||||
decrypter = Some(enc);
|
||||
log::info!("secure_tcp: handshake completed (encrypted) for {}", addr);
|
||||
sink = Some(Sink::TcpStream(a, Some(send_state)));
|
||||
}
|
||||
Ok(HandshakeOutcome::Plain(bytes)) => {
|
||||
log::info!(
|
||||
"secure_tcp: client sent plain first message ({} bytes) from {}",
|
||||
bytes.len(),
|
||||
addr
|
||||
);
|
||||
buffered_first = Some(bytes);
|
||||
sink = Some(Sink::TcpStream(a, None));
|
||||
}
|
||||
Ok(HandshakeOutcome::Skip) => {
|
||||
log::info!(
|
||||
"secure_tcp: handshake window timed out (client never replied) for {}",
|
||||
addr
|
||||
);
|
||||
sink = Some(Sink::TcpStream(a, None));
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("secure_tcp: handshake error for {}: {}", addr, e);
|
||||
sink = Some(Sink::TcpStream(a, None));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::debug!("secure_tcp: no signing key configured; skipping handshake");
|
||||
sink = Some(Sink::TcpStream(a, None));
|
||||
}
|
||||
// Replay the message we already consumed during the handshake
|
||||
// window before entering the normal read loop.
|
||||
if let Some(bytes) = buffered_first {
|
||||
if !self.handle_tcp(&bytes, &mut sink, addr, key, ws).await {
|
||||
if sink.is_none() {
|
||||
self.tcp_punch.lock().await.remove(&try_into_v4(addr));
|
||||
}
|
||||
log::debug!("Tcp connection from {:?} closed", addr);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
while let Ok(Some(Ok(mut bytes))) = timeout(30_000, b.next()).await {
|
||||
if let Some(dec) = decrypter.as_mut() {
|
||||
if let Err(e) = dec.dec(&mut bytes) {
|
||||
log::warn!("decryption error from {}: {}", addr, e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !self.handle_tcp(&bytes, &mut sink, addr, key, ws).await {
|
||||
break;
|
||||
}
|
||||
@@ -1369,3 +1527,85 @@ async fn create_tcp_listener(port: i32) -> ResultType<TcpListener> {
|
||||
log::debug!("listen on tcp {:?}", s.local_addr());
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Outcome of the server-initiated `secure_tcp` handshake on a fresh TCP
|
||||
/// rendezvous connection. The matching client code lives in
|
||||
/// /Users/sn0/Desktop/rustdesk/src/common.rs:1939 (`secure_tcp_impl`).
|
||||
enum HandshakeOutcome {
|
||||
/// Client cooperated; the resulting `Encrypt` is shared between the
|
||||
/// inbound decrypter and the outbound `Sink`.
|
||||
Secure(Encrypt),
|
||||
/// Client did not opt into encryption — first message we read is a
|
||||
/// regular `RendezvousMessage`. We hand the bytes back to the caller so
|
||||
/// they can be dispatched via `handle_tcp` before the read loop begins.
|
||||
Plain(BytesMut),
|
||||
/// No first message arrived within the handshake window. Fall through
|
||||
/// to plain mode; the next `b.next()` in the main read loop will pick
|
||||
/// up whatever the client eventually sends.
|
||||
Skip,
|
||||
}
|
||||
|
||||
/// Server-side counterpart to the client's `secure_tcp_impl`. Sends a signed
|
||||
/// ephemeral box public key, then reads the first message:
|
||||
///
|
||||
/// 1. If it's a `KeyExchange` carrying `[client_box_pk, sealed_sym_key]`,
|
||||
/// decrypt the sealed sym key with our box secret and return an `Encrypt`
|
||||
/// initialised from that key — ready to use on both directions.
|
||||
/// 2. If it's any other `RendezvousMessage`, return the bytes verbatim so
|
||||
/// the caller can dispatch them as if no handshake had happened.
|
||||
///
|
||||
/// Plain-mode clients (no API token configured) skip unsolicited
|
||||
/// `KeyExchange` via `get_next_nonkeyexchange_msg` on their side, so the
|
||||
/// `KeyExchange` we emit unconditionally is ignored when the client hasn't
|
||||
/// opted into encryption.
|
||||
async fn try_secure_tcp_handshake(
|
||||
sink: &mut TcpStreamSink,
|
||||
src: &mut TcpStreamSrc,
|
||||
sk: &sign::SecretKey,
|
||||
) -> ResultType<HandshakeOutcome> {
|
||||
// Ephemeral Curve25519 keypair for this connection only.
|
||||
let (our_pk_b, our_sk_b) = box_::gen_keypair();
|
||||
// Sign the public key with our long-lived Ed25519 sign key. The client
|
||||
// verifies this signature using the public key the user pasted into
|
||||
// their RustDesk settings.
|
||||
let signed = sign::sign(&our_pk_b.0, sk);
|
||||
let mut msg_out = RendezvousMessage::new();
|
||||
msg_out.set_key_exchange(KeyExchange {
|
||||
keys: vec![Bytes::from(signed)],
|
||||
..Default::default()
|
||||
});
|
||||
let bytes = msg_out.write_to_bytes()?;
|
||||
log::info!("secure_tcp: sending KeyExchange ({} bytes payload)", bytes.len());
|
||||
sink.send(Bytes::from(bytes)).await?;
|
||||
|
||||
// Wait briefly for the client's reply. 5 s is comfortably below the
|
||||
// client's READ_TIMEOUT and the server-loop 30 s timeout, so a slow
|
||||
// plain-mode client just falls through to `Skip`.
|
||||
match timeout(5_000, src.next()).await {
|
||||
Ok(Some(Ok(bytes))) => {
|
||||
log::info!("secure_tcp: received reply ({} bytes)", bytes.len());
|
||||
if let Ok(msg_in) = RendezvousMessage::parse_from_bytes(&bytes) {
|
||||
if let Some(rendezvous_message::Union::KeyExchange(ex)) = msg_in.union {
|
||||
if ex.keys.len() != 2 {
|
||||
bail!(
|
||||
"invalid key exchange response: keys.len() = {}",
|
||||
ex.keys.len()
|
||||
);
|
||||
}
|
||||
let key = Encrypt::decode(&ex.keys[1], &ex.keys[0], &our_sk_b)?;
|
||||
return Ok(HandshakeOutcome::Secure(Encrypt::new(key)));
|
||||
} else {
|
||||
log::info!(
|
||||
"secure_tcp: reply was a non-KeyExchange RendezvousMessage; treating as plain"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
log::info!("secure_tcp: reply did not parse as RendezvousMessage; treating as plain");
|
||||
}
|
||||
Ok(HandshakeOutcome::Plain(bytes))
|
||||
}
|
||||
Ok(Some(Err(e))) => bail!("read error during handshake: {}", e),
|
||||
Ok(None) => bail!("connection closed during handshake"),
|
||||
Err(_) => Ok(HandshakeOutcome::Skip),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
# dist/ is INTENTIONALLY committed — see web_client/README.md.
|
||||
# The Rust hbbs binary serves dist/bundle.js via include_bytes!, so
|
||||
# committing it lets `cargo build` work without a Node toolchain.
|
||||
@@ -0,0 +1,55 @@
|
||||
# RustDesk web client
|
||||
|
||||
Browser-based RustDesk client embedded in `rustdesk-server`. Surfaced from the
|
||||
admin dashboard as a "Connect" button on the Devices page.
|
||||
|
||||
## Architecture (one-liner)
|
||||
|
||||
Plain TypeScript SPA → talks WebSocket directly to `hbbs:21118` (rendezvous)
|
||||
and `hbbr:21119` (relay) → `protobufjs` for wire format → `libsodium-wrappers`
|
||||
for crypto → WebCodecs for video/audio decode → `<canvas>` for display.
|
||||
|
||||
No frameworks. ~1 MB minified.
|
||||
|
||||
## Building
|
||||
|
||||
```sh
|
||||
./build.sh # bundles to dist/bundle.{js,css}
|
||||
git add dist/
|
||||
```
|
||||
|
||||
`dist/` is committed so `cargo build -p hbbs` doesn't need Node. Anyone
|
||||
touching code under `src/` should re-run `./build.sh` and commit the new
|
||||
`dist/` files in the same commit.
|
||||
|
||||
## Regenerating proto bindings
|
||||
|
||||
Rare — only when `libs/hbb_common` bumps and proto fields change:
|
||||
|
||||
```sh
|
||||
npm run protogen
|
||||
./build.sh
|
||||
git add src/proto/generated.* dist/
|
||||
```
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/
|
||||
main.ts boot: read #custom-config, init transport
|
||||
crypto.ts libsodium wrapper
|
||||
proto/ generated protobufjs static modules (committed)
|
||||
transport/ rendezvous WS, relay WS, secure handshake state machine
|
||||
decode/ video (WebCodecs VideoDecoder), audio (AudioDecoder)
|
||||
input/ mouse/keyboard capture → protobuf MouseEvent/KeyEvent
|
||||
ui/ canvas + toolbar + style.css
|
||||
audit.ts POST /api/audit/conn with admin cookie
|
||||
dist/
|
||||
bundle.js + .css committed esbuild output
|
||||
```
|
||||
|
||||
## Wire-protocol references
|
||||
|
||||
- `/Users/sn0/Desktop/rustdesk-server/libs/hbb_common/protos/{rendezvous,message}.proto`
|
||||
- `/Users/sn0/Desktop/rustdesk/src/client.rs` — desktop-client connect/secure/login state machine
|
||||
- `/Users/sn0/Desktop/rustdesk-server/libs/hbb_common/src/tcp.rs:296-344` — secretbox nonce derivation (8-byte LE counter, separate per direction)
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the web client bundle.
|
||||
#
|
||||
# Outputs: dist/bundle.js, dist/bundle.js.map, dist/bundle.css
|
||||
#
|
||||
# Re-run after editing anything under src/. Commit dist/* alongside source so
|
||||
# `cargo build` doesn't need a Node toolchain.
|
||||
#
|
||||
# To regenerate the protobuf bindings (rare — when libs/hbb_common bumps):
|
||||
# npm run protogen && npm run build
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if [ ! -d node_modules ]; then
|
||||
echo "Installing npm dependencies..."
|
||||
npm install
|
||||
fi
|
||||
|
||||
mkdir -p dist
|
||||
echo "Bundling JS..."
|
||||
npm run --silent build:js
|
||||
echo "Copying CSS..."
|
||||
npm run --silent build:css
|
||||
|
||||
echo "Done. Bundle:"
|
||||
ls -lh dist/bundle.js dist/bundle.css 2>/dev/null || true
|
||||
Vendored
+190
@@ -0,0 +1,190 @@
|
||||
/* RustDesk web client — minimal, dark theme to match the admin dashboard. */
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
padding: 32px 40px;
|
||||
max-width: 540px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.placeholder h1 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.placeholder p {
|
||||
margin: 8px 0;
|
||||
font-size: 14px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.placeholder code {
|
||||
background: #0f172a;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: #7dd3fc;
|
||||
}
|
||||
|
||||
.muted { color: #64748b !important; font-size: 12px !important; }
|
||||
|
||||
.pw-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.pw-form input[type="password"] {
|
||||
flex: 1;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
color: #e2e8f0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.pw-form input[type="password"]:focus {
|
||||
outline: none;
|
||||
border-color: #38bdf8;
|
||||
}
|
||||
.pw-form button {
|
||||
background: #0284c7;
|
||||
border: 0;
|
||||
color: #f0f9ff;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pw-form button:hover { background: #0369a1; }
|
||||
|
||||
/* Separator between "waiting for approval / cancel" and the unattended-
|
||||
* password override on the awaiting-approval screen. */
|
||||
.pw-divider {
|
||||
border: none;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||
margin: 20px 0 12px;
|
||||
}
|
||||
|
||||
.error-inline {
|
||||
background: rgba(220, 38, 38, 0.15);
|
||||
border: 1px solid rgba(220, 38, 38, 0.4);
|
||||
color: #fca5a5;
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* ------- Live session ------- */
|
||||
|
||||
.session {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.rd-canvas {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* Letterbox: keep aspect ratio while fitting the browser viewport. */
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.hud {
|
||||
position: fixed;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #cbd5e1;
|
||||
font-size: 11px;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hud-fps {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hud-btn {
|
||||
background: #334155;
|
||||
border: 0;
|
||||
color: #e2e8f0;
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hud-btn:hover { background: #475569; }
|
||||
|
||||
.hud-select {
|
||||
background: #334155;
|
||||
border: 0;
|
||||
color: #e2e8f0;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hud-select:hover { background: #475569; }
|
||||
|
||||
.reconnect-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(15, 23, 42, 0.65);
|
||||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
color: #e2e8f0;
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
z-index: 20;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: rgba(220, 38, 38, 0.15);
|
||||
border: 1px solid rgba(220, 38, 38, 0.4);
|
||||
border-radius: 8px;
|
||||
padding: 24px 32px;
|
||||
color: #fca5a5;
|
||||
max-width: 640px;
|
||||
}
|
||||
.error h1 { margin: 0 0 12px; font-size: 18px; }
|
||||
.error pre { white-space: pre-wrap; font-size: 13px; }
|
||||
Vendored
+30
File diff suppressed because one or more lines are too long
Vendored
+7
File diff suppressed because one or more lines are too long
Generated
+1376
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "rustdesk-web-client",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Browser-based RustDesk client embedded in rustdesk-server admin dashboard.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "./build.sh",
|
||||
"build:js": "esbuild src/main.ts --bundle --minify --format=esm --outfile=dist/bundle.js --target=es2022 --sourcemap=external",
|
||||
"build:css": "cp src/ui/style.css dist/bundle.css",
|
||||
"protogen": "pbjs --target static-module --wrap es6 --es6 --keep-case -o src/proto/generated.js ../libs/hbb_common/protos/rendezvous.proto ../libs/hbb_common/protos/message.proto && pbts -o src/proto/generated.d.ts src/proto/generated.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.21.0",
|
||||
"protobufjs-cli": "^1.1.3",
|
||||
"typescript": "^5.4.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"protobufjs": "^7.2.6",
|
||||
"tweetnacl": "^1.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Crypto wrapper around tweetnacl.
|
||||
//
|
||||
// The byte-for-byte references for everything here are:
|
||||
// /Users/sn0/Desktop/rustdesk/src/common.rs:2005-2031 (decode_id_pk, create_symmetric_key_msg)
|
||||
// /Users/sn0/Desktop/rustdesk-server/libs/hbb_common/src/tcp.rs:296-344 (Encrypt + nonce derivation)
|
||||
//
|
||||
// tweetnacl exposes the same NaCl primitives the desktop client's
|
||||
// `sodiumoxide` uses:
|
||||
// - Ed25519: nacl.sign.open (verifies + unwraps a signed message)
|
||||
// - Curve25519 box: nacl.box (asymmetric one-shot — we use the all-zero
|
||||
// 24-byte nonce per the desktop client's create_symmetric_key_msg)
|
||||
// - Secretbox: nacl.secretbox / nacl.secretbox.open (XSalsa20-Poly1305,
|
||||
// symmetric per-message — nonce derivation is per-direction 8-byte LE
|
||||
// sequence counter || 16 zero bytes)
|
||||
//
|
||||
// Pure JS, ~50 KB minified, no WASM, no module-resolution drama.
|
||||
|
||||
import nacl from "tweetnacl";
|
||||
|
||||
// Resolved immediately — kept as a Promise so the call site doesn't change
|
||||
// when we swap implementations later.
|
||||
export const sodiumReady: Promise<void> = Promise.resolve();
|
||||
|
||||
export const SIGN_PUBLICKEYBYTES = nacl.sign.publicKeyLength; // 32
|
||||
export const BOX_PUBLICKEYBYTES = nacl.box.publicKeyLength; // 32
|
||||
export const BOX_SECRETKEYBYTES = nacl.box.secretKeyLength; // 32
|
||||
export const BOX_NONCEBYTES = nacl.box.nonceLength; // 24
|
||||
export const SECRETBOX_KEYBYTES = nacl.secretbox.keyLength; // 32
|
||||
export const SECRETBOX_NONCEBYTES = nacl.secretbox.nonceLength; // 24
|
||||
|
||||
/** Verify and unwrap an Ed25519-signed message. Throws on auth failure. */
|
||||
export function signOpen(signed: Uint8Array, publicKey: Uint8Array): Uint8Array {
|
||||
if (publicKey.length !== SIGN_PUBLICKEYBYTES) {
|
||||
throw new Error(`signOpen: bad pk length ${publicKey.length}`);
|
||||
}
|
||||
const out = nacl.sign.open(signed, publicKey);
|
||||
if (!out) throw new Error("signOpen: signature verification failed");
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Generate an ephemeral Curve25519 keypair for the box handshake. */
|
||||
export function genBoxKeypair(): { publicKey: Uint8Array; secretKey: Uint8Array } {
|
||||
const kp = nacl.box.keyPair();
|
||||
return { publicKey: kp.publicKey, secretKey: kp.secretKey };
|
||||
}
|
||||
|
||||
/** Generate a fresh symmetric key for the per-session secretbox stream. */
|
||||
export function genSecretboxKey(): Uint8Array {
|
||||
return nacl.randomBytes(SECRETBOX_KEYBYTES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seal `msg` under the peer's Curve25519 public key with our secret key.
|
||||
* All-zero 24-byte nonce — matches `sodiumoxide::crypto::box_::seal` in the
|
||||
* desktop client's create_symmetric_key_msg.
|
||||
*/
|
||||
export function boxSeal(
|
||||
msg: Uint8Array,
|
||||
peerPublicKey: Uint8Array,
|
||||
ourSecretKey: Uint8Array,
|
||||
): Uint8Array {
|
||||
const nonce = new Uint8Array(BOX_NONCEBYTES); // all zeros
|
||||
return nacl.box(msg, nonce, peerPublicKey, ourSecretKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt with secretbox using the per-direction nonce derivation:
|
||||
* nonce[0..8] = sequence_counter as little-endian u64
|
||||
* nonce[8..24] = zeros
|
||||
* Returns ciphertext (which includes the 16-byte Poly1305 tag).
|
||||
*/
|
||||
export function secretboxSeal(
|
||||
msg: Uint8Array,
|
||||
sequence: bigint,
|
||||
key: Uint8Array,
|
||||
): Uint8Array {
|
||||
return nacl.secretbox(msg, makeNonce(sequence), key);
|
||||
}
|
||||
|
||||
/** Inverse of secretboxSeal — throws on auth failure. */
|
||||
export function secretboxOpen(
|
||||
cipher: Uint8Array,
|
||||
sequence: bigint,
|
||||
key: Uint8Array,
|
||||
): Uint8Array {
|
||||
const out = nacl.secretbox.open(cipher, makeNonce(sequence), key);
|
||||
if (!out) throw new Error("secretboxOpen: authentication failed");
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Build a 24-byte nonce: little-endian u64 in [0..8], zeros in [8..24]. */
|
||||
function makeNonce(sequence: bigint): Uint8Array {
|
||||
const n = new Uint8Array(SECRETBOX_NONCEBYTES);
|
||||
const view = new DataView(n.buffer);
|
||||
view.setBigUint64(0, sequence, /*littleEndian=*/ true);
|
||||
return n;
|
||||
}
|
||||
|
||||
// Pure-JS SHA-256 from @noble/hashes — works in non-secure contexts
|
||||
// (browsers gate `crypto.subtle` to HTTPS / localhost only). Audited,
|
||||
// minimal, no WASM. The dashboard often runs on plain http for self-hosted
|
||||
// deployments, which would break SubtleCrypto.
|
||||
import { sha256 as nobleSha256 } from "@noble/hashes/sha2.js";
|
||||
|
||||
/** SHA-256 of `data`. Used for the Hash/LoginRequest password challenge. */
|
||||
export function sha256(data: Uint8Array): Uint8Array {
|
||||
return nobleSha256(data);
|
||||
}
|
||||
|
||||
/** Concatenate two byte arrays. */
|
||||
export function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
|
||||
const out = new Uint8Array(a.length + b.length);
|
||||
out.set(a, 0);
|
||||
out.set(b, a.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Standard base64 (with padding) → bytes. Used to decode the server pk. */
|
||||
export function base64Decode(s: string): Uint8Array {
|
||||
const bin = atob(s);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Bytes → standard base64 (with padding). */
|
||||
export function base64Encode(bytes: Uint8Array): string {
|
||||
let s = "";
|
||||
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
||||
return btoa(s);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Audio pipeline using WebCodecs AudioDecoder.
|
||||
//
|
||||
// Wire format (from libs/hbb_common/protos/message.proto):
|
||||
// Misc{audio_format: {sample_rate, channels}} sent once at session start
|
||||
// Message{audio_frame: {data: <opus frame>}} periodic
|
||||
//
|
||||
// We feed each opus frame into AudioDecoder, get back an AudioData with PCM
|
||||
// samples, copy into an AudioBufferSourceNode, and schedule it on a single
|
||||
// AudioContext "playhead" timeline so consecutive packets play seamlessly.
|
||||
|
||||
import { hbb } from "../proto/generated.js";
|
||||
|
||||
export class AudioPipeline {
|
||||
private decoder: AudioDecoder | null = null;
|
||||
private ctx: AudioContext | null = null;
|
||||
private playhead = 0; // next absolute scheduling time, in ctx seconds
|
||||
private timestamp = 0; // monotonic counter for EncodedAudioChunk
|
||||
private muted = false;
|
||||
private gain: GainNode | null = null;
|
||||
|
||||
/** Configure on the first AudioFormat message. Throws if WebCodecs unavailable. */
|
||||
configure(format: hbb.IAudioFormat): void {
|
||||
if (typeof AudioDecoder === "undefined") {
|
||||
throw new Error("WebCodecs AudioDecoder unavailable. Open via http://localhost or https:// — secure-context only.");
|
||||
}
|
||||
const sampleRate = format.sample_rate || 48000;
|
||||
const channels = format.channels || 2;
|
||||
if (this.decoder) this.decoder.close();
|
||||
this.ctx = new AudioContext({ sampleRate, latencyHint: "interactive" });
|
||||
this.gain = this.ctx.createGain();
|
||||
this.gain.connect(this.ctx.destination);
|
||||
this.playhead = this.ctx.currentTime + 0.05; // 50ms initial buffer
|
||||
this.timestamp = 0;
|
||||
|
||||
this.decoder = new AudioDecoder({
|
||||
output: (data) => this.onAudioData(data),
|
||||
error: (e) => console.error("[rustdesk-web] AudioDecoder error:", e),
|
||||
});
|
||||
this.decoder.configure({
|
||||
codec: "opus",
|
||||
sampleRate,
|
||||
numberOfChannels: channels,
|
||||
});
|
||||
}
|
||||
|
||||
/** Feed one opus packet (raw bytes from AudioFrame.data). */
|
||||
pushFrame(data: Uint8Array): void {
|
||||
if (!this.decoder || data.length === 0 || this.muted) return;
|
||||
try {
|
||||
const chunk = new EncodedAudioChunk({
|
||||
type: "key", // opus is all-key per packet
|
||||
timestamp: this.timestamp,
|
||||
data,
|
||||
});
|
||||
this.timestamp += 20_000; // ~20ms per opus frame in microseconds
|
||||
this.decoder.decode(chunk);
|
||||
} catch (e) {
|
||||
console.error("[rustdesk-web] audio decode failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Resume the AudioContext after a user gesture (some browsers gate
|
||||
* audio playback to the first interaction). */
|
||||
async resume(): Promise<void> {
|
||||
if (this.ctx && this.ctx.state === "suspended") {
|
||||
await this.ctx.resume();
|
||||
}
|
||||
}
|
||||
|
||||
setMuted(muted: boolean): void {
|
||||
this.muted = muted;
|
||||
if (this.gain && this.ctx) {
|
||||
this.gain.gain.setValueAtTime(muted ? 0 : 1, this.ctx.currentTime);
|
||||
}
|
||||
}
|
||||
|
||||
isMuted(): boolean {
|
||||
return this.muted;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.decoder) {
|
||||
try { this.decoder.close(); } catch { /* ignore */ }
|
||||
this.decoder = null;
|
||||
}
|
||||
if (this.ctx) {
|
||||
try { this.ctx.close(); } catch { /* ignore */ }
|
||||
this.ctx = null;
|
||||
}
|
||||
}
|
||||
|
||||
private onAudioData(data: AudioData): void {
|
||||
if (!this.ctx || !this.gain) {
|
||||
data.close();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const numChannels = data.numberOfChannels;
|
||||
const numFrames = data.numberOfFrames;
|
||||
const sampleRate = data.sampleRate;
|
||||
const buffer = this.ctx.createBuffer(numChannels, numFrames, sampleRate);
|
||||
|
||||
// WebCodecs opus output is typically `f32` (interleaved), not
|
||||
// `f32-planar`. Detect the format and deinterleave when needed.
|
||||
const format = data.format || "f32";
|
||||
if (format.endsWith("-planar")) {
|
||||
// Each plane is one channel — copy directly.
|
||||
for (let ch = 0; ch < numChannels; ch++) {
|
||||
data.copyTo(buffer.getChannelData(ch), { planeIndex: ch });
|
||||
}
|
||||
} else {
|
||||
// Interleaved: copy all samples, then deinterleave into the
|
||||
// AudioBuffer's planar channels.
|
||||
const interleaved = new Float32Array(numChannels * numFrames);
|
||||
data.copyTo(interleaved, { planeIndex: 0 });
|
||||
for (let ch = 0; ch < numChannels; ch++) {
|
||||
const dest = buffer.getChannelData(ch);
|
||||
for (let i = 0; i < numFrames; i++) {
|
||||
dest[i] = interleaved[i * numChannels + ch]!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const src = this.ctx.createBufferSource();
|
||||
src.buffer = buffer;
|
||||
src.connect(this.gain);
|
||||
|
||||
// Schedule on a sliding playhead so consecutive packets are gap-less.
|
||||
// If we've fallen behind real time (network hiccup / decoder stall),
|
||||
// re-anchor to "now + 50ms" to avoid scheduling a runaway pile-up.
|
||||
const now = this.ctx.currentTime;
|
||||
if (this.playhead < now) {
|
||||
this.playhead = now + 0.05;
|
||||
}
|
||||
src.start(this.playhead);
|
||||
this.playhead += numFrames / sampleRate;
|
||||
} catch (e) {
|
||||
console.error("[rustdesk-web] audio render failed:", e);
|
||||
} finally {
|
||||
data.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user