From 47f0d0fff22eb0f4a7ef7fb62bb38ee26bd37f9c Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Thu, 7 May 2026 09:39:23 +0200 Subject: [PATCH] Implement CI workflow for Gitea. Include provision scripts for Gitea runners. --- .gitea/workflows/build-linux.yml | 250 +++++++ .gitea/workflows/build-macos.yml | 231 +++++++ .gitea/workflows/build-windows.yml | 487 +++++++++++++ .gitmodules | 3 +- ci/runners/linux/provision.sh | 264 +++++++ ci/runners/macos/provision.sh | 291 ++++++++ ci/runners/windows/provision.ps1 | 324 +++++++++ docs/CLIENT_VS_SERVER_GAPS.md | 260 +++++++ docs/CONSOLE_API.md | 1038 ++++++++++++++++++++++++++++ 9 files changed, 3147 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/build-linux.yml create mode 100644 .gitea/workflows/build-macos.yml create mode 100644 .gitea/workflows/build-windows.yml create mode 100755 ci/runners/linux/provision.sh create mode 100755 ci/runners/macos/provision.sh create mode 100644 ci/runners/windows/provision.ps1 create mode 100644 docs/CLIENT_VS_SERVER_GAPS.md create mode 100644 docs/CONSOLE_API.md diff --git a/.gitea/workflows/build-linux.yml b/.gitea/workflows/build-linux.yml new file mode 100644 index 000000000..a0946e61f --- /dev/null +++ b/.gitea/workflows/build-linux.yml @@ -0,0 +1,250 @@ +name: build-linux + +on: + push: + branches: [pro-features] + workflow_dispatch: + inputs: + version_suffix: + description: "Version suffix (e.g. 'cst', 'beta1'). Empty = vanilla." + type: string + default: "cst" + +env: + RUST_VERSION: "1.75" + LLVM_VERSION: "15.0.6" + # Where provision.sh installs LLVM (binary tarball from llvm.org). Same exact + # version as the Windows runner uses, distro-portable. + LLVM_HOME: '/opt/llvm-15.0.6' + FLUTTER_VERSION: "3.24.5" + VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" + CARGO_EXPAND_VERSION: "1.0.95" + FLUTTER_RUST_BRIDGE_VERSION: "1.80.1" + VERSION_BASE: "1.4.6" + VERSION_SUFFIX: ${{ inputs.version_suffix || 'cst' }} + +jobs: + build-x64: + name: build-linux-x64 + # Pinned to ubuntu-22.04 because flutter_rust_bridge_codegen 1.80.1 emits + # broken Dart on Debian 13 (matches upstream rustdesk's bridge.yml host). + # provision.sh tags Ubuntu 22.04 hosts with the `ubuntu-22.04` label. + runs-on: [self-hosted, Linux, X64, ubuntu-22.04] + timeout-minutes: 240 + env: + VCPKG_ROOT: /opt/vcpkg + VCPKG_BINARY_SOURCES: "clear;files,/var/cache/vcpkg,readwrite" + LIBCLANG_PATH: /opt/llvm-15.0.6/lib + steps: + - name: Checkout source + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Verify host toolchain + shell: bash + run: | + required=(node git bash python3 rustc cargo rustup clang flutter cmake ninja nasm pkg-config 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[*]}. Re-run provision.sh on the runner host." + exit 1 + fi + [[ -d "$VCPKG_ROOT" && -x "$VCPKG_ROOT/vcpkg" ]] || { + echo "VCPKG_ROOT=$VCPKG_ROOT invalid"; exit 1; } + [[ -f "$LIBCLANG_PATH/libclang.so" ]] || { + echo "libclang.so not found at $LIBCLANG_PATH"; exit 1; } + + - name: Compute version strings + shell: bash + run: | + base="${VERSION_BASE}" + suffix="${VERSION_SUFFIX}" + [[ "$base" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "VERSION_BASE '$base' must be major.minor.patch"; exit 1; } + if [[ -n "$suffix" ]]; then display="${base}-${suffix}"; else display="${base}"; fi + echo "Base : $base" + echo "Suffix : $suffix" + echo "Display : $display" + echo "VERSION_DISPLAY=$display" >> "$GITHUB_ENV" + + - name: Patch Cargo.toml with display version + shell: bash + run: | + sed -i -E "0,/^version[[:space:]]*=/{s/^version[[:space:]]*=[[:space:]]*\"${VERSION_BASE}\"/version = \"${VERSION_DISPLAY}\"/}" Cargo.toml + grep '^version' Cargo.toml | head -1 + + - name: Ensure Rust toolchain configured + shell: bash + run: | + # provision.sh installs Rust machine-wide at /opt/cargo + /opt/rustup, + # so this is normally a no-op verification. Kept as a guardrail. + 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: Install flutter_rust_bridge codegen tools + shell: bash + run: | + # Pin install destination so binaries land in a deterministic path + # regardless of CARGO_HOME. + tools=/opt/cargo-tools + mkdir -p "$tools/bin" + cargo install --root "$tools" cargo-expand --version "$CARGO_EXPAND_VERSION" --locked + cargo install --root "$tools" flutter_rust_bridge_codegen --version "$FLUTTER_RUST_BRIDGE_VERSION" --features uuid --locked + ls -la "$tools/bin" + [[ -x "$tools/bin/flutter_rust_bridge_codegen" ]] || { echo "missing fr_bridge_codegen"; exit 1; } + echo "$tools/bin" >> "$GITHUB_PATH" + + - name: Generate Rust <-> Dart bridge (with Flutter 3.22.3) + shell: bash + run: | + set -e + # flutter_rust_bridge_codegen 1.80.1 + freezed produces broken Dart + # output (wrong FFI types, unprefixed Int/Pointer in part files) when + # run under Flutter 3.24.5 on Linux. Upstream's bridge.yml works around + # this by running the bridge generation under Flutter 3.22.3 -- the + # produced .dart/.freezed.dart files are then compatible with the + # 3.24.5 build that follows. + # + # provision.sh installs Flutter 3.22.3 at /opt/flutter-bridge alongside + # /opt/flutter (3.24.5). Use the bridge SDK ONLY for pub get + codegen, + # then unset PATH overrides so subsequent steps use the build SDK. + export PATH="/opt/cargo-tools/bin:/opt/flutter-bridge/bin:$PATH" + flutter --version + command -v flutter_rust_bridge_codegen + + # CRITICAL: use the apt-installed libclang (libclang-dev), NOT the + # LLVM 15.0.6 tarball at /opt/llvm-15.0.6 that the rest of the build + # uses. fr_bridge_codegen 1.80.1 emits broken Dart (stray + # `typedef bool = NativeFunction<...>`, unprefixed Int/Pointer in + # part files) when its internal ffigen runs against the tarball + # libclang. Upstream bridge.yml uses apt's libclang and the same + # codegen produces clean output. Don't pass --llvm-path either; let + # ffigen find libclang via system defaults. + unset LIBCLANG_PATH + + # extended_text 14.0.0 requires Dart >=3.5 (Flutter 3.24+). Flutter + # 3.22.3 has Dart 3.4.4, so downgrade to 13.0.0 for the duration of + # bridge generation. Mirrors upstream bridge.yml. + (cd flutter && sed -i -e 's/extended_text: 14.0.0/extended_text: 13.0.0/g' pubspec.yaml) + (cd flutter && flutter pub get) + + # flutter_rust_bridge_codegen invokes `flutter pub run ffigen` + # internally, which re-validates pubspec.yaml -- so pubspec.yaml must + # still be the downgraded version when this runs. + flutter_rust_bridge_codegen \ + --rust-input ./src/flutter_ffi.rs \ + --dart-output ./flutter/lib/generated_bridge.dart \ + --c-output ./flutter/macos/Runner/bridge_generated.h + cp ./flutter/macos/Runner/bridge_generated.h ./flutter/ios/Runner/bridge_generated.h + + # Now bridge gen is done. Restore the original pubspec.yaml and + # re-resolve under the build SDK (3.24.5) so pubspec.lock has the + # correct entries for the final flutter build linux step. + (cd flutter && git checkout -- pubspec.yaml) + (cd flutter && /opt/flutter/bin/flutter pub get) + + - name: Diagnose generated bridge files + shell: bash + run: | + set +e + echo "============================================================" + echo " generated_bridge.dart (first 80 lines)" + echo "============================================================" + head -80 flutter/lib/generated_bridge.dart 2>/dev/null || echo "(missing)" + echo + echo "============================================================" + echo " generated_bridge.freezed.dart (first 80 lines)" + echo "============================================================" + head -80 flutter/lib/generated_bridge.freezed.dart 2>/dev/null || echo "(missing)" + echo + echo "============================================================" + echo " Search: typedef declarations that might shadow bool/Int/Pointer" + echo "============================================================" + grep -nE 'typedef (bool|Int|Pointer|Bool)' \ + flutter/lib/generated_bridge.dart \ + flutter/lib/generated_bridge.freezed.dart 2>/dev/null || echo "(no shadowing typedefs)" + echo + echo "============================================================" + echo " Search: imports that might pull weird symbols" + echo "============================================================" + grep -nE '^import|^export|^part ' \ + flutter/lib/generated_bridge.dart \ + flutter/lib/generated_bridge.freezed.dart 2>/dev/null + echo + echo "============================================================" + echo " store_dart_post_cobject signature in generated_bridge.dart" + echo "============================================================" + grep -nA2 'store_dart_post_cobject' flutter/lib/generated_bridge.dart 2>/dev/null | head -30 + echo + echo "============================================================" + echo " Line 25 of generated_bridge.freezed.dart (the failing one)" + echo "============================================================" + sed -n '20,30p' flutter/lib/generated_bridge.freezed.dart 2>/dev/null + echo + echo "============================================================" + echo " flutter_rust_bridge package version actually resolved" + echo "============================================================" + grep -A2 'flutter_rust_bridge:' flutter/pubspec.lock | head -10 + + - name: vcpkg install dependencies (x64-linux) + shell: bash + env: + VCPKG_DEFAULT_HOST_TRIPLET: x64-linux + run: | + mkdir -p /var/cache/vcpkg + if ! "$VCPKG_ROOT/vcpkg" install \ + --triplet x64-linux \ + --x-install-root="$VCPKG_ROOT/installed"; then + find "$VCPKG_ROOT/" -name '*.log' -exec sh -c 'echo "===== {} ====="; cat "{}"' \; + exit 1 + fi + + - name: Build RustDesk (.deb) + shell: bash + run: | + set -e + # build.py on Linux (no pacman/yum/zypper detected) goes to + # build_flutter_deb() which does: + # - cargo build --features --lib --release + # - flutter build linux --release + # - assembles tmpdeb/ and runs dpkg-deb -b + # Output: ./rustdesk-.deb in the repo root. + python3 build.py --flutter --hwcodec --unix-file-copy-paste + + mkdir -p ./SignOutput + # build.py names the .deb after Cargo.toml's version which we patched + # above, so the file should already carry $VERSION_DISPLAY. + mv "./rustdesk-${VERSION_DISPLAY}.deb" "./SignOutput/rustdesk-${VERSION_DISPLAY}-amd64.deb" + + - 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 + # Gitea/GHA-style annotation so it surfaces in the run summary. + echo "::warning title=Unsigned .deb::Wire up debsigs / repo signing before distributing." + + - name: Upload artifacts + uses: actions/upload-artifact@v3 + with: + name: rustdesk-linux-x64-${{ github.sha }} + path: SignOutput/rustdesk-*.deb + if-no-files-found: warn + retention-days: 14 diff --git a/.gitea/workflows/build-macos.yml b/.gitea/workflows/build-macos.yml new file mode 100644 index 000000000..9f169d08c --- /dev/null +++ b/.gitea/workflows/build-macos.yml @@ -0,0 +1,231 @@ +name: build-macos + +on: + push: + branches: [pro-features] + workflow_dispatch: + inputs: + version_suffix: + description: "Version suffix (e.g. 'cst', 'beta1'). Empty = vanilla." + type: string + default: "cst" + +env: + RUST_VERSION: "1.81" # MAC_RUST_VERSION upstream (cidre needs >=1.81) + FLUTTER_VERSION: "3.24.5" + VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" + CARGO_EXPAND_VERSION: "1.0.95" + FLUTTER_RUST_BRIDGE_VERSION: "1.80.1" + VERSION_BASE: "1.4.6" + VERSION_SUFFIX: ${{ inputs.version_suffix || 'cst' }} + +jobs: + build-x64: + name: build-macos-x64 + # Intel Mac runner. provision.sh tags x86_64 macOS hosts with the X64 label. + runs-on: [self-hosted, macOS, X64] + timeout-minutes: 240 + env: + VCPKG_ROOT: /opt/vcpkg + VCPKG_BINARY_SOURCES: "clear;files,/var/cache/vcpkg,readwrite" + VCPKG_DEFAULT_TRIPLET: x64-osx + VCPKG_DEFAULT_HOST_TRIPLET: x64-osx + steps: + - name: Checkout source + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Verify host toolchain + shell: bash + run: | + required=(git bash python3 rustc cargo rustup clang flutter cmake ninja nasm pkg-config create-dmg) + missing=() + for t in "${required[@]}"; do + if command -v "$t" >/dev/null 2>&1; then + printf '%-15s %s\n' "$t" "$(command -v "$t")" + else + missing+=("$t") + printf '%-15s MISSING\n' "$t" + fi + done + if [[ ${#missing[@]} -gt 0 ]]; then + echo "Missing tools: ${missing[*]}. Re-run provision.sh on the runner host." + exit 1 + fi + [[ -d "$VCPKG_ROOT" && -x "$VCPKG_ROOT/vcpkg" ]] || { + echo "VCPKG_ROOT=$VCPKG_ROOT invalid"; exit 1; } + + - name: Compute version strings + shell: bash + run: | + base="${VERSION_BASE}" + suffix="${VERSION_SUFFIX}" + [[ "$base" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "VERSION_BASE '$base' must be major.minor.patch"; exit 1; } + if [[ -n "$suffix" ]]; then display="${base}-${suffix}"; else display="${base}"; fi + echo "Display : $display" + echo "VERSION_DISPLAY=$display" >> "$GITHUB_ENV" + + - name: Patch Cargo.toml with display version + shell: bash + run: | + # BSD sed (macOS): -i requires an empty backup-suffix arg. + sed -i '' -E "1,/^version[[:space:]]*=/{s/^version[[:space:]]*=[[:space:]]*\"${VERSION_BASE}\"/version = \"${VERSION_DISPLAY}\"/;}" Cargo.toml + grep '^version' Cargo.toml | head -1 + + # No deployment-target patch on x86_64: build.py's build_flutter_dmg() + # already exports MACOSX_DEPLOYMENT_TARGET=10.14 internally, matching the + # Flutter Xcode project. Upstream only patches the target for arm64 + # (which needs 12.3+). + + - 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-apple-darwin + rustc --version + cargo --version + + - name: Install flutter_rust_bridge codegen tools + shell: bash + run: | + tools=/opt/cargo-tools + mkdir -p "$tools/bin" + cargo install --root "$tools" cargo-expand --version "$CARGO_EXPAND_VERSION" --locked + cargo install --root "$tools" flutter_rust_bridge_codegen --version "$FLUTTER_RUST_BRIDGE_VERSION" --features uuid --locked + ls -la "$tools/bin" + [[ -x "$tools/bin/flutter_rust_bridge_codegen" ]] || { echo "missing fr_bridge_codegen"; exit 1; } + echo "$tools/bin" >> "$GITHUB_PATH" + + - name: Generate Rust <-> Dart bridge (with Flutter 3.22.3) + shell: bash + run: | + set -e + # Same dual-SDK trick as Linux: bridge codegen 1.80.1 produces broken + # Dart on Flutter 3.24.5; provision.sh installs 3.22.3 at + # /opt/flutter-bridge for codegen only. Switch back to 3.24.5 after. + export PATH="/opt/cargo-tools/bin:/opt/flutter-bridge/bin:$PATH" + flutter --version + command -v flutter_rust_bridge_codegen + + # Match the Linux fix: don't pass --llvm-path, let ffigen find Xcode + # CLT's libclang via system defaults. Passing brew's llvm or a custom + # libclang path triggered fr_bridge_codegen 1.80.1's bad-emission bug + # (stray `typedef bool = NativeFunction<...>`) on Linux; same logic + # carries over here. + unset LIBCLANG_PATH + + (cd flutter && sed -i '' -e 's/extended_text: 14.0.0/extended_text: 13.0.0/g' pubspec.yaml) + (cd flutter && flutter pub get) + + flutter_rust_bridge_codegen \ + --rust-input ./src/flutter_ffi.rs \ + --dart-output ./flutter/lib/generated_bridge.dart \ + --c-output ./flutter/macos/Runner/bridge_generated.h + cp ./flutter/macos/Runner/bridge_generated.h ./flutter/ios/Runner/bridge_generated.h + + (cd flutter && git checkout -- pubspec.yaml) + (cd flutter && /opt/flutter/bin/flutter pub get) + + - name: Diagnose generated bridge files + shell: bash + run: | + set +e + echo "============================================================" + echo " generated_bridge.dart (first 80 lines)" + echo "============================================================" + head -80 flutter/lib/generated_bridge.dart 2>/dev/null || echo "(missing)" + echo + echo "============================================================" + echo " Search: typedef declarations that might shadow bool/Int/Pointer" + echo "============================================================" + grep -nE 'typedef (bool|Int|Pointer|Bool)' \ + flutter/lib/generated_bridge.dart \ + flutter/lib/generated_bridge.freezed.dart 2>/dev/null || echo "(no shadowing typedefs)" + + - name: vcpkg install dependencies (x64-osx) + shell: bash + run: | + mkdir -p /var/cache/vcpkg + if ! "$VCPKG_ROOT/vcpkg" install \ + --triplet x64-osx \ + --x-install-root="$VCPKG_ROOT/installed"; then + find "$VCPKG_ROOT/" -name '*.log' -exec sh -c 'echo "===== {} ====="; cat "{}"' \; + exit 1 + fi + + - name: Build RustDesk (.app + .dmg) + shell: bash + run: | + set -e + # build.py -> build_flutter_dmg() does: + # - MACOSX_DEPLOYMENT_TARGET=10.14 cargo build --features --release + # - cp liblibrustdesk.dylib librustdesk.dylib + # - flutter build macos --release + # - cp -rf ../target/release/service .../RustDesk.app/Contents/MacOS/ + # It does NOT package the .dmg (the create-dmg call is commented out). + # We package below. + # + # No --screencapturekit on x86_64: upstream's matrix only enables it + # on aarch64 (cidre's ScreenCaptureKit bindings target arm64-only APIs). + python3 build.py --flutter --hwcodec --unix-file-copy-paste + + # Ad-hoc re-sign the whole bundle in one pass. + # `flutter build macos --release` ad-hoc signs the main binary, but + # FlutterMacOS.framework already carries its own ad-hoc signature + # from Flutter's engine artifacts. dyld on Apple Silicon (macOS 13+) + # enforces Team ID match between the main process and every loaded + # framework -- two ad-hoc signatures from different signing passes + # have different per-binary cdhashes and fail the check, producing + # `mapping process and mapped file have different Team IDs` at + # launch time on M-series Macs. `codesign --deep --sign -` re-signs + # every nested binary/framework/dylib with the same ad-hoc identity + # in one pass, so all components share a consistent signing context. + # When we wire up real Developer ID + notarization later, replace + # `-` with the cert identity and drop --deep in favor of inside-out + # signing. + codesign --force --deep --sign - \ + ./flutter/build/macos/Build/Products/Release/RustDesk.app + codesign --verify --deep --strict --verbose=2 \ + ./flutter/build/macos/Build/Products/Release/RustDesk.app + + mkdir -p ./SignOutput + # Use hdiutil (not create-dmg) because the runner is a LaunchDaemon + # with no GUI/Finder session. create-dmg drives Finder via AppleScript + # for icon layout and fails with `-10810` in daemon context. hdiutil + # produces a fully functional compressed DMG with no GUI calls. + dmg_staging="$(mktemp -d -t rustdesk-dmg)" + cp -R ./flutter/build/macos/Build/Products/Release/RustDesk.app "$dmg_staging/" + ln -s /Applications "$dmg_staging/Applications" + hdiutil create \ + -volname "RustDesk" \ + -srcfolder "$dmg_staging" \ + -ov \ + -format UDZO \ + "./SignOutput/rustdesk-${VERSION_DISPLAY}-x86_64.dmg" + rm -rf "$dmg_staging" + + - name: Report signing status of build artifacts + shell: bash + run: | + for f in ./SignOutput/*.dmg; do + [[ -f "$f" ]] || continue + size=$(stat -f%z "$f") + sig=$(codesign -dv "$f" 2>&1 | grep -E '^Authority' | head -1 || true) + if [[ -z "$sig" ]]; then + printf '[UNSIGNED] %s (%d bytes)\n' "$(basename "$f")" "$size" + else + printf '[ SIGNED ] %s (%d bytes) %s\n' "$(basename "$f")" "$size" "$sig" + fi + done + echo "::warning title=Unsigned .dmg::Wire up codesign + notarytool before distributing." + + - name: Upload artifacts + uses: actions/upload-artifact@v3 + with: + name: rustdesk-macos-x64-${{ github.sha }} + path: SignOutput/rustdesk-*.dmg + if-no-files-found: warn + retention-days: 14 diff --git a/.gitea/workflows/build-windows.yml b/.gitea/workflows/build-windows.yml new file mode 100644 index 000000000..9e826bfbd --- /dev/null +++ b/.gitea/workflows/build-windows.yml @@ -0,0 +1,487 @@ +name: build-windows + +on: + push: + branches: [pro-features] + workflow_dispatch: + inputs: + version_suffix: + description: "Version suffix (e.g. 'cst', 'beta1'). Empty = vanilla." + type: string + default: "cst" + +env: + RUST_VERSION: "1.75" + LLVM_VERSION: "15.0.6" + # Where provision.ps1 installs LLVM. ffigen and flutter_rust_bridge_codegen need + # the install root (they append bin\libclang.dll themselves). + LLVM_HOME: 'C:\tools\llvm-15.0.6' + FLUTTER_VERSION: "3.24.5" + VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" + CARGO_EXPAND_VERSION: "1.0.95" + FLUTTER_RUST_BRIDGE_VERSION: "1.80.1" + # Numeric base, must match Cargo.toml's ... + # MSI ProductVersion is forced to this (Windows Installer rejects non-numeric). + VERSION_BASE: "1.4.6" + # Default suffix on push events. workflow_dispatch can override per-run. + VERSION_SUFFIX: ${{ inputs.version_suffix || 'cst' }} + +jobs: + build-x64: + name: build-windows-x64 + runs-on: [self-hosted, windows-10] + timeout-minutes: 240 + env: + VCPKG_ROOT: C:\vcpkg + VCPKG_BINARY_SOURCES: "clear;files,C:\\vcpkg-cache,readwrite" + steps: + - name: Checkout source + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Verify host toolchain + shell: pwsh + run: | + $required = 'node','pwsh','git','bash','python','rustc','cargo','rustup','clang','flutter','nuget','cmake','ninja','dotnet' + $missing = @() + foreach ($tool in $required) { + $cmd = Get-Command $tool -ErrorAction SilentlyContinue + if (-not $cmd) { $missing += $tool; continue } + $ver = & $tool --version 2>&1 | Select-Object -First 1 + Write-Host ("{0,-10} {1} ({2})" -f $tool, $cmd.Source, $ver) + } + if ($missing.Count -gt 0) { + Write-Error ("Missing tools on runner: {0}. Re-run provision.ps1 or install manually." -f ($missing -join ', ')) + exit 1 + } + if (-not $env:VCPKG_ROOT -or -not (Test-Path "$env:VCPKG_ROOT\vcpkg.exe")) { + Write-Error "VCPKG_ROOT not set or vcpkg.exe missing at $env:VCPKG_ROOT" + exit 1 + } + Write-Host "VCPKG_ROOT $env:VCPKG_ROOT" + + - name: Compute version strings + shell: pwsh + run: | + $base = "${env:VERSION_BASE}" + $suffix = "${env:VERSION_SUFFIX}" + if ($base -notmatch '^\d+\.\d+\.\d+$') { + Write-Error "VERSION_BASE '$base' must be major.minor.patch numeric" + exit 1 + } + if ($suffix) { $display = "$base-$suffix" } else { $display = $base } + Write-Host ("Base : {0} (used for MSI ProductVersion)" -f $base) + Write-Host ("Suffix : {0}" -f $suffix) + Write-Host ("Display : {0} (used for exe filename + Cargo.toml)" -f $display) + "VERSION_DISPLAY=$display" | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Patch Cargo.toml with display version + shell: bash + run: | + # Cargo accepts SemVer-style suffix with a hyphen (e.g. 1.4.6-cst). + sed -i -E "0,/^version[[:space:]]*=/{s/^version[[:space:]]*=[[:space:]]*\"${VERSION_BASE}\"/version = \"${VERSION_DISPLAY}\"/}" Cargo.toml + echo "--- Cargo.toml [package] ---" + awk '/^\[package\]/{f=1} f; /^\[/&&!/^\[package\]/{f=0}' Cargo.toml | head -10 + + - name: Ensure Rust toolchain configured for runner user + shell: pwsh + run: | + # provision.ps1 installs rust machine-wide (CARGO_HOME=C:\cargo, + # RUSTUP_HOME=C:\rustup) so this is normally a no-op verification. + # Kept as a guardrail in case the runner was provisioned with an + # older script or by hand. + rustup --version + rustup toolchain install $env:RUST_VERSION --profile minimal --component rustfmt + if ($LASTEXITCODE -ne 0) { throw "rustup toolchain install failed ($LASTEXITCODE)" } + rustup default $env:RUST_VERSION + if ($LASTEXITCODE -ne 0) { throw "rustup default failed ($LASTEXITCODE)" } + rustup target add x86_64-pc-windows-msvc + rustc --version + cargo --version + + - name: Install flutter_rust_bridge codegen tools + shell: pwsh + run: | + # Pin the install destination with --root so the resulting binaries land + # in a deterministic, machine-wide path regardless of which user the + # runner service is configured to run as. + $tools = 'C:\cargo-tools' + New-Item -ItemType Directory -Force -Path "$tools\bin" | Out-Null + + cargo install --root $tools cargo-expand --version "$env:CARGO_EXPAND_VERSION" --locked + if ($LASTEXITCODE -ne 0) { throw "cargo install cargo-expand failed ($LASTEXITCODE)" } + + cargo install --root $tools flutter_rust_bridge_codegen --version "$env:FLUTTER_RUST_BRIDGE_VERSION" --features uuid --locked + if ($LASTEXITCODE -ne 0) { throw "cargo install flutter_rust_bridge_codegen failed ($LASTEXITCODE)" } + + Write-Host "--- $tools\bin ---" + Get-ChildItem "$tools\bin" | Format-Table Name, Length, LastWriteTime + + $expected = Join-Path $tools 'bin\flutter_rust_bridge_codegen.exe' + if (-not (Test-Path $expected)) { throw "missing: $expected" } + + Add-Content -Path $env:GITHUB_PATH -Value "$tools\bin" + + - name: Generate Rust <-> Dart bridge + shell: pwsh + env: + LIBCLANG_PATH: '${{ env.LLVM_HOME }}\bin' + run: | + $ErrorActionPreference = 'Stop' + + # Force-prepend cargo-tools and Git so they win the lookup race even if + # something earlier in PATH owns the same name. + $env:PATH = "C:\cargo-tools\bin;C:\Program Files\Git\cmd;C:\Program Files\Git\bin;$env:PATH" + + Write-Host "== Tool resolution ==" + foreach ($t in 'git','flutter','flutter_rust_bridge_codegen','dart') { + $cmd = Get-Command $t -ErrorAction SilentlyContinue + if ($cmd) { Write-Host ("{0,-30} {1}" -f $t, $cmd.Source) } + else { Write-Host ("{0,-30} MISSING" -f $t) } + } + + # `where git` shows ALL git.exe matches on PATH. If multiple, the first + # is what CreateProcess will pick -- and it might be a broken shim. + Write-Host "`n== where git ==" + where.exe git 2>&1 + + Write-Host "`n== git --version ==" + git --version + + # Diagnose the real reason flutter pub get says "Unable to find git in + # your PATH". On a self-hosted runner where the Flutter SDK at + # C:\tools\flutter was provisioned by a different user, git refuses to + # touch it with "fatal: detected dubious ownership". Flutter's tool + # catches that ProcessException and (misleadingly) reports it as a + # missing-git-on-PATH error. + Write-Host "`n== git ops on flutter SDK (probe for dubious ownership) ==" + git -C 'C:\tools\flutter' rev-parse HEAD 2>&1 | Out-Host + Write-Host "`n== Configuring safe.directory globally for runner user ==" + git config --global --add safe.directory '*' + git config --global --get-all safe.directory + + # Workaround: stage git.exe next to dart.exe. CreateProcessW searches + # the calling exe's directory before PATH -- so dropping git.exe here + # bypasses any PATH oddities the dart child may inherit from its + # parents (powershell -> cmd -> flutter.bat -> dart.exe). + $dartBin = 'C:\tools\flutter\bin\cache\dart-sdk\bin' + $stagedGit = Join-Path $dartBin 'git.exe' + $sourceGit = 'C:\Program Files\Git\cmd\git.exe' + if ((Test-Path $dartBin) -and -not (Test-Path $stagedGit)) { + Write-Host "`nStaging $sourceGit -> $stagedGit" + Copy-Item -Force $sourceGit $stagedGit + } + + if (-not (Test-Path "$env:LIBCLANG_PATH\libclang.dll")) { + throw "libclang.dll not found at $env:LIBCLANG_PATH" + } + + Write-Host "`n== flutter pub get ==" + Push-Location flutter + flutter pub get + $rc = $LASTEXITCODE + Pop-Location + if ($rc -ne 0) { throw "flutter pub get failed ($rc)" } + + Write-Host "`n== flutter_rust_bridge_codegen ==" + flutter_rust_bridge_codegen ` + --llvm-path "$env:LLVM_HOME" ` + --rust-input ./src/flutter_ffi.rs ` + --dart-output ./flutter/lib/generated_bridge.dart ` + --c-output ./flutter/macos/Runner/bridge_generated.h + if ($LASTEXITCODE -ne 0) { throw "flutter_rust_bridge_codegen failed ($LASTEXITCODE)" } + + Copy-Item -Force ` + ./flutter/macos/Runner/bridge_generated.h ` + ./flutter/ios/Runner/bridge_generated.h + + - name: Replace Flutter engine with rustdesk custom engine + shell: pwsh + run: | + flutter precache --windows + Invoke-WebRequest -Uri https://github.com/rustdesk/engine/releases/download/main/windows-x64-release.zip -OutFile windows-x64-release.zip + Expand-Archive -Force -Path windows-x64-release.zip -DestinationPath windows-x64-release + $engineDir = "C:\tools\flutter\bin\cache\artifacts\engine\windows-x64-release" + New-Item -ItemType Directory -Force -Path $engineDir | Out-Null + Move-Item -Force windows-x64-release\* $engineDir\ + + - name: Patch Flutter (dropdown_menu enableFilter) + shell: bash + run: | + patch_file=".github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff" + if [ -f "$patch_file" ]; then + flutter_root="$(dirname "$(dirname "$(which flutter)")")" + cp "$patch_file" "$flutter_root/" + (cd "$flutter_root" && git apply "$(basename "$patch_file")" || true) + fi + + - name: vcpkg install dependencies (x64-windows-static) + shell: bash + env: + VCPKG_DEFAULT_HOST_TRIPLET: x64-windows-static + run: | + mkdir -p /c/vcpkg-cache + if ! "$VCPKG_ROOT/vcpkg" install \ + --triplet x64-windows-static \ + --x-install-root="$VCPKG_ROOT/installed"; then + find "$VCPKG_ROOT/" -name "*.log" -exec sh -c 'echo "===== {} ====="; cat "{}"' \; + exit 1 + fi + + - name: Diagnose + restore flutter/windows if missing + shell: bash + run: | + echo "== pwd ==" + pwd + echo + echo "== flutter/ (top level) ==" + ls -la flutter/ 2>/dev/null || echo "flutter/ missing" + echo + if [ -d flutter/windows ]; then + echo "== flutter/windows/ exists, file count ==" + find flutter/windows -type f | wc -l + ls -la flutter/windows/ + else + echo "!! flutter/windows MISSING !!" + echo "git status (filtered to flutter/windows):" + git status --porcelain | grep -E "flutter/windows" | head -30 || echo "(no entries)" + echo + echo "Restoring flutter/windows from git index..." + git checkout HEAD -- flutter/windows + if [ -d flutter/windows ]; then + echo "Restored: $(find flutter/windows -type f | wc -l) files" + else + echo "RESTORE FAILED -- check git ls-files:" + git ls-files flutter/windows | head + exit 1 + fi + fi + + # The upstream GitHub workflow runs `python build.py` which chains cargo + + # `flutter build windows` in one step. We split that here so we can: + # - get a checkpoint between cargo and flutter (~14 min apart) + # - restore flutter/windows from git index if anything has clobbered it + # (early in our self-hosted bring-up, flutter/windows occasionally + # vanished during the cargo phase under LocalSystem; the restore is + # defensive and a no-op once the runner is on a normal user) + + - name: Cargo build (virtual_display dylib + main lib) + shell: pwsh + run: | + $features = "hwcodec,vram,flutter" + + Push-Location libs\virtual_display\dylib + cargo build --release + if ($LASTEXITCODE -ne 0) { throw "cargo build (virtual_display) failed" } + Pop-Location + + cargo build --features $features --lib --release + if ($LASTEXITCODE -ne 0) { throw "cargo build (rustdesk lib) failed" } + if (-not (Test-Path target\release\librustdesk.dll)) { + throw "target\release\librustdesk.dll missing after cargo build" + } + + # Restore + build are intentionally one step. Closes any potential window + # for flutter/windows to vanish between a separate restore step and the + # build, regardless of root cause. + - name: Flutter build windows (with last-second restore) + shell: pwsh + run: | + $cmake = '.\flutter\windows\CMakeLists.txt' + + Write-Host "== Pre-flight check ==" + Write-Host "pwd: $(Get-Location)" + if (Test-Path .\flutter\windows) { + Write-Host "flutter/windows present: $((Get-ChildItem -Recurse .\flutter\windows -File).Count) files" + } else { + Write-Host "flutter/windows MISSING" + } + + if (-not (Test-Path $cmake)) { + Write-Host "`n== Restoring flutter/windows from git index ==" + git status --porcelain | Where-Object { $_ -match 'flutter/windows' } | Select-Object -First 10 + git checkout HEAD -- flutter/windows + if ($LASTEXITCODE -ne 0) { throw "git checkout HEAD -- flutter/windows failed" } + if (-not (Test-Path $cmake)) { + Write-Host "git ls-files flutter/windows:" + git ls-files flutter/windows | Select-Object -First 5 + throw "Restore did not produce $cmake" + } + Write-Host "Restored: $((Get-ChildItem -Recurse .\flutter\windows -File).Count) files" + } + + # Touch a marker so we can confirm later (in logs) that this guard ran. + $marker = ".\flutter\windows\.restored-at" + Set-Content -Path $marker -Value (Get-Date -Format o) + + Write-Host "`n== Running flutter build windows --release ==" + Push-Location flutter + flutter build windows --release + $rc = $LASTEXITCODE + Pop-Location + if ($rc -ne 0) { + Write-Host "`n!! flutter build failed -- post-mortem state of flutter/windows: !!" + if (Test-Path .\flutter\windows) { Get-ChildItem .\flutter\windows -Force } + else { Write-Host "(does not exist)" } + throw "flutter build windows failed ($rc)" + } + + # Mirror what build.py's build_flutter_windows() does after flutter build. + Copy-Item -Force ` + target\release\deps\dylib_virtual_display.dll ` + flutter\build\windows\x64\runner\Release\ + + Move-Item -Force flutter\build\windows\x64\runner\Release rustdesk + + - name: Bundle usbmmidd_v2 + printer driver + shell: pwsh + continue-on-error: true + run: | + Invoke-WebRequest -Uri https://github.com/rustdesk-org/rdev/releases/download/usbmmidd_v2/usbmmidd_v2.zip -OutFile usbmmidd_v2.zip + Expand-Archive -Force usbmmidd_v2.zip -DestinationPath . + Remove-Item -Recurse -Force usbmmidd_v2\Win32 + Remove-Item -Force usbmmidd_v2\deviceinstaller64.exe, usbmmidd_v2\deviceinstaller.exe, usbmmidd_v2\usbmmidd.bat + Move-Item -Force .\usbmmidd_v2 .\rustdesk\ + + Invoke-WebRequest -Uri https://github.com/rustdesk/hbb_common/releases/download/driver/rustdesk_printer_driver_v4-1.4.zip -OutFile printer.zip + Invoke-WebRequest -Uri https://github.com/rustdesk/hbb_common/releases/download/driver/printer_driver_adapter.zip -OutFile printer_adapter.zip + Expand-Archive -Force printer.zip -DestinationPath . + Expand-Archive -Force printer_adapter.zip -DestinationPath . + New-Item -ItemType Directory -Force -Path .\rustdesk\drivers | Out-Null + Move-Item -Force .\rustdesk_printer_driver_v4-1.4 .\rustdesk\drivers\RustDeskPrinterDriver + Move-Item -Force .\printer_driver_adapter.dll .\rustdesk\ + + - name: Copy Runner.res for portable packer + shell: bash + continue-on-error: true + run: | + runner_res=$(find . -name Runner.res | head -1) + [ -n "$runner_res" ] && cp "$runner_res" ./libs/portable/Runner.res + + - name: Build portable self-extracting exe + shell: bash + run: | + sed -i '/dpiAware/d' res/manifest.xml + pushd ./libs/portable + pip install -r requirements.txt + python ./generate.py -f ../../rustdesk/ -o . -e ../../rustdesk/rustdesk.exe + popd + mkdir -p ./SignOutput + mv ./target/release/rustdesk-portable-packer.exe "./SignOutput/rustdesk-${VERSION_DISPLAY}-x86_64.exe" + + - name: Build MSI installer + shell: pwsh + run: | + Push-Location .\res\msi + # Pass numeric VERSION_BASE explicitly: MSI ProductVersion must be numeric, + # so we cannot let preprocess.py auto-detect from rustdesk.exe (which now + # carries the suffixed VERSION_DISPLAY). + python preprocess.py --arp -d ..\..\rustdesk -v "${env:VERSION_BASE}" + + # Resolve MSBuild from the installed VS Build Tools. + # Resolve vswhere.exe explicitly. ${env:ProgramFiles(x86)} interpolation + # has parser-version quirks; use [Environment] to avoid them. + $pfx86 = [Environment]::GetEnvironmentVariable('ProgramFiles(x86)') + $vswhere = Join-Path $pfx86 'Microsoft Visual Studio\Installer\vswhere.exe' + if (-not (Test-Path $vswhere)) { + throw "vswhere.exe not found at $vswhere -- VS Installer missing?" + } + Write-Host "vswhere: $vswhere" + + # Diagnostic: show what VS installs vswhere can see. + Write-Host "`n== vswhere installations ==" + & $vswhere -all -prerelease -products '*' -property installationPath + Write-Host "===" + + # Try the targeted -requires first; fall back to a broader search if the + # component name doesn't match (e.g. on Build Tools the component IDs differ). + $msbuild = & $vswhere -latest -products '*' ` + -requires Microsoft.Component.MSBuild ` + -find "MSBuild\**\Bin\MSBuild.exe" | Select-Object -First 1 + if (-not $msbuild) { + Write-Host "Targeted lookup failed; trying broader search." + $msbuild = & $vswhere -latest -products '*' -prerelease ` + -find "MSBuild\**\Bin\MSBuild.exe" | Select-Object -First 1 + } + if (-not $msbuild) { + # Last resort: any MSBuild.exe under any VS install path. + $vsRoots = & $vswhere -all -prerelease -products '*' -property installationPath + foreach ($r in $vsRoots) { + $candidate = Get-ChildItem -Path $r -Recurse -Filter 'MSBuild.exe' -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -like '*\Bin\MSBuild.exe' -and $_.FullName -notlike '*\amd64\*' } | + Select-Object -First 1 + if ($candidate) { $msbuild = $candidate.FullName; break } + } + } + if (-not $msbuild) { throw "MSBuild not found via vswhere or filesystem search" } + Write-Host "msbuild: $msbuild" + + # Two-stage restore covers both project flavors in this solution: + # - CustomActions.vcxproj uses old-style packages.config -> nuget restore + # - Package.wixproj is SDK-style PackageReference -> msbuild -t:Restore + nuget restore msi.sln + & $msbuild msi.sln -t:Restore -p:Configuration=Release -p:Platform=x64 + if ($LASTEXITCODE -ne 0) { throw "MSBuild restore failed ($LASTEXITCODE)" } + + # WiX's WindowsInstallerValidation target invokes ICEs through the + # local Windows Installer service (msiserver). When the runner user + # lacks the COM/RPC rights to that service, every ICE check fails + # with WIX0217. Skip ICE validation -- it's a development-time lint, + # not a functional requirement, and the produced MSI is identical. + # Defensively also nudge the service in case some other validation + # path (e.g. signing tools) needs it. + try { Start-Service msiserver -ErrorAction SilentlyContinue } catch {} + + & $msbuild msi.sln ` + -p:Configuration=Release -p:Platform=x64 ` + /p:TargetVersion=Windows10 ` + /p:SuppressValidation=true + if ($LASTEXITCODE -ne 0) { throw "MSBuild build failed ($LASTEXITCODE)" } + + Move-Item -Force .\Package\bin\x64\Release\en-us\Package.msi "..\..\SignOutput\rustdesk-${env:VERSION_DISPLAY}-x86_64.msi" + Pop-Location + + - name: Report signing status of build artifacts + shell: pwsh + run: | + $artifacts = Get-ChildItem .\SignOutput -Include *.exe,*.msi -File + if (-not $artifacts) { + Write-Warning "No artifacts found in SignOutput\" + return + } + + $unsigned = @() + foreach ($f in $artifacts) { + $sig = Get-AuthenticodeSignature -FilePath $f.FullName + $size = '{0,8:N0}' -f $f.Length + switch ($sig.Status) { + 'Valid' { + Write-Host ("[ SIGNED ] {0} ({1} bytes) signed by: {2}" -f $f.Name, $size, $sig.SignerCertificate.Subject) + } + 'NotSigned' { + Write-Host ("[UNSIGNED] {0} ({1} bytes)" -f $f.Name, $size) + $unsigned += $f.Name + } + default { + Write-Host ("[ {0,-7} ] {1} ({2} bytes) -- {3}" -f $sig.Status, $f.Name, $size, $sig.StatusMessage) + $unsigned += $f.Name + } + } + } + + if ($unsigned.Count -gt 0) { + # Render a Gitea/GHA-style annotation so it shows up prominently in the run summary. + $list = $unsigned -join ', ' + Write-Host "::warning title=Unsigned artifacts::$list -- SmartScreen will warn end users. Wire up signing before distributing." + } + + - name: Upload artifacts + uses: actions/upload-artifact@v3 + with: + name: rustdesk-windows-x64-${{ github.sha }} + path: | + SignOutput/rustdesk-*.exe + SignOutput/rustdesk-*.msi + if-no-files-found: warn + retention-days: 14 diff --git a/.gitmodules b/.gitmodules index d80e69aa8..6a18f2121 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,4 @@ [submodule "libs/hbb_common"] path = libs/hbb_common - url = https://github.com/rustdesk/hbb_common + url = https://gitea.cstudio.ch/mike/hbb_common.git + branch = pro-features diff --git a/ci/runners/linux/provision.sh b/ci/runners/linux/provision.sh new file mode 100755 index 000000000..4c0b424ee --- /dev/null +++ b/ci/runners/linux/provision.sh @@ -0,0 +1,264 @@ +#!/usr/bin/env bash +# Provisions an Ubuntu 22.04 LTS or Debian 13 (Trixie) host as a Gitea Actions +# runner for RustDesk desktop (.deb) builds. Idempotent: safe to re-run. +# +# Versions are pinned to .gitea/workflows/build-linux.yml. Bump them there and +# here together. +# +# Build host vs. user host: the resulting .deb links against the host's glibc. +# Build on the OLDEST distro your users have, otherwise the .deb won't install +# on older systems. +# - Ubuntu 22.04 build -> runs on Ubuntu 22.04+, Debian 12+, derivatives +# - Debian 13 build -> runs on Debian 13+, Ubuntu 24.04+ only +# +# Usage: +# sudo ./provision.sh \ +# --gitea-url https://gitea.example.com \ +# --runner-token +# +# All toolchains land in /opt and are readable by the gitea-runner user. +# Service is installed as a systemd unit running as that user. + +set -euo pipefail + +# ---- pinned versions (mirror .gitea/workflows/build-linux.yml env block) ---- +RUST_VERSION="1.75.0" +FLUTTER_VERSION="3.24.5" # used for `flutter build linux` +FLUTTER_BRIDGE_VERSION="3.22.3" # used for `flutter pub get` + flutter_rust_bridge_codegen +LLVM_VERSION="15.0.6" +VCPKG_COMMIT="120deac3062162151622ca4860575a33844ba10b" +RUNNER_VERSION="0.2.11" + +# ---- defaults ---- +RUNNER_NAME="$(hostname)-rustdesk" +RUNNER_LABELS="" # auto-derived from /etc/os-release if empty +SERVICE_USER="gitea-runner" +GITEA_URL="" +RUNNER_TOKEN="" + +# ---- arg parse ---- +while [[ $# -gt 0 ]]; do + case "$1" in + --gitea-url) GITEA_URL="$2"; shift 2 ;; + --runner-token) RUNNER_TOKEN="$2"; shift 2 ;; + --runner-name) RUNNER_NAME="$2"; shift 2 ;; + --runner-labels) RUNNER_LABELS="$2"; shift 2 ;; + --service-user) SERVICE_USER="$2"; shift 2 ;; + -h|--help) + sed -n '2,18p' "$0" + exit 0 ;; + *) echo "Unknown arg: $1" >&2; exit 2 ;; + esac +done + +[[ "$EUID" -eq 0 ]] || { echo "Run as root (use sudo)." >&2; exit 1; } +[[ -n "$GITEA_URL" && -n "$RUNNER_TOKEN" ]] \ + || { echo "Missing --gitea-url or --runner-token" >&2; exit 2; } + +. /etc/os-release +case "${ID}-${VERSION_ID:-}" in + ubuntu-22.04) DISTRO_LABEL="ubuntu-22.04" ;; + debian-13|debian-trixie) DISTRO_LABEL="debian-13" ;; + *) + echo "WARNING: tested only on Ubuntu 22.04 and Debian 13. You're on $PRETTY_NAME." + echo "Package names may differ; build outputs may not run on user systems." + DISTRO_LABEL="${ID}-${VERSION_ID:-unknown}" + sleep 3 ;; +esac + +# If --runner-labels wasn't passed, derive a sensible default that includes the +# detected distro so workflows can target a specific build host when needed. +if [[ -z "$RUNNER_LABELS" ]]; then + RUNNER_LABELS="${DISTRO_LABEL},self-hosted,X64,Linux" +fi + +log() { printf '\n==> %s\n' "$*"; } + +# ---- 1. apt packages ---- +log "Installing apt packages" +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +apt-get install -y --no-install-recommends \ + build-essential clang gcc g++ cmake ninja-build pkg-config nasm yasm \ + autoconf automake libtool libtool-bin \ + libclang-dev llvm-dev \ + libgtk-3-dev libayatana-appindicator3-dev \ + libasound2-dev libpulse-dev libpam0g-dev libssl-dev \ + libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ + libva-dev libxdo-dev libxfixes-dev \ + libxcb-randr0-dev libxcb-shape0-dev libxcb-xfixes0-dev \ + git curl wget zip unzip tar xz-utils ca-certificates \ + python3 python3-pip \ + rpm tree dpkg-dev sudo + +# Node.js (act_runner spawns node for JS actions like actions/checkout) +if ! command -v node >/dev/null; then + log "Installing Node.js LTS" + curl -fsSL https://deb.nodesource.com/setup_20.x | bash - + apt-get install -y --no-install-recommends nodejs +fi + +# ---- 2. LLVM (binary tarball; libclang-15-dev was dropped from Debian 13) ---- +LLVM_DIR="/opt/llvm-${LLVM_VERSION}" +if [[ ! -x "$LLVM_DIR/bin/clang" ]]; then + log "Installing LLVM $LLVM_VERSION (binary tarball)" + arch="$(uname -m)" + case "$arch" in + x86_64) llvm_arch="x86_64-linux-gnu-ubuntu-18.04" ;; + aarch64) llvm_arch="aarch64-linux-gnu" ;; + *) echo "Unsupported arch for LLVM tarball: $arch" >&2; exit 1 ;; + esac + tmp="$(mktemp -d)" + curl -fsSL -o "$tmp/llvm.tar.xz" \ + "https://github.com/llvm/llvm-project/releases/download/llvmorg-${LLVM_VERSION}/clang+llvm-${LLVM_VERSION}-${llvm_arch}.tar.xz" + mkdir -p "$LLVM_DIR" + tar --strip-components=1 -xJf "$tmp/llvm.tar.xz" -C "$LLVM_DIR" + rm -rf "$tmp" +fi + +# ---- 3. dedicated runner user ---- +if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then + log "Creating user $SERVICE_USER" + useradd --system --create-home --shell /bin/bash "$SERVICE_USER" +fi +RUNNER_HOME="$(getent passwd "$SERVICE_USER" | cut -d: -f6)" + +# ---- 4. Rust (machine-wide) ---- +export RUSTUP_HOME=/opt/rustup +export CARGO_HOME=/opt/cargo +mkdir -p "$RUSTUP_HOME" "$CARGO_HOME" + +if [[ ! -x "$CARGO_HOME/bin/rustup" ]]; then + log "Installing rustup at $RUSTUP_HOME / $CARGO_HOME" + curl -fsSL https://sh.rustup.rs | RUSTUP_HOME="$RUSTUP_HOME" CARGO_HOME="$CARGO_HOME" \ + sh -s -- -y --default-toolchain none --profile minimal --no-modify-path +fi +"$CARGO_HOME/bin/rustup" toolchain install "$RUST_VERSION" --profile minimal --component rustfmt +"$CARGO_HOME/bin/rustup" target add --toolchain "$RUST_VERSION" x86_64-unknown-linux-gnu +"$CARGO_HOME/bin/rustup" default "$RUST_VERSION" + +# ---- 5. Flutter (two SDKs: 3.24.5 for build, 3.22.3 for bridge gen) ---- +# Why two: the bridge codegen (flutter_rust_bridge_codegen 1.80.1 + freezed) +# produces broken Dart output when run under newer Flutter SDKs on Linux. +# Upstream's bridge.yml uses 3.22.3 specifically; we mirror that. The .deb +# build itself uses 3.24.5. +install_flutter() { + local ver="$1" dir="$2" + if [[ ! -x "$dir/bin/flutter" ]]; then + log "Installing Flutter $ver -> $dir" + local tmp; tmp="$(mktemp -d)" + local parent; parent="$(dirname "$dir")" + curl -fsSL -o "$tmp/flutter.tar.xz" \ + "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${ver}-stable.tar.xz" + # Tarball extracts into a top-level `flutter/` dir; rename to target. + tar -xJf "$tmp/flutter.tar.xz" -C "$tmp" + mkdir -p "$parent" + mv "$tmp/flutter" "$dir" + rm -rf "$tmp" + fi + "$dir/bin/flutter" config --no-analytics >/dev/null + "$dir/bin/flutter" precache --linux >/dev/null +} +install_flutter "$FLUTTER_VERSION" /opt/flutter +install_flutter "$FLUTTER_BRIDGE_VERSION" /opt/flutter-bridge +FLUTTER_DIR=/opt/flutter + +# ---- 6. vcpkg ---- +VCPKG_DIR=/opt/vcpkg +if [[ ! -d "$VCPKG_DIR/.git" ]]; then + log "Cloning vcpkg" + git clone https://github.com/microsoft/vcpkg.git "$VCPKG_DIR" +fi +git -C "$VCPKG_DIR" fetch --tags origin +git -C "$VCPKG_DIR" -c advice.detachedHead=false checkout "$VCPKG_COMMIT" +[[ -x "$VCPKG_DIR/vcpkg" ]] || "$VCPKG_DIR/bootstrap-vcpkg.sh" -disableMetrics + +# vcpkg binary cache (file-backed -- same scheme as build-windows.yml) +mkdir -p /var/cache/vcpkg +chown -R "$SERVICE_USER:$SERVICE_USER" /var/cache/vcpkg + +# ---- 7. Permissions ---- +log "Setting up permissions for $SERVICE_USER" +chown -R "$SERVICE_USER:$SERVICE_USER" "$CARGO_HOME" +# rustup state needs to be writable too -- toolchain installs touch it. +chown -R "$SERVICE_USER:$SERVICE_USER" "$RUSTUP_HOME" +# Flutter SDK: r/x is enough for builds, but `flutter pub get` writes to its +# own cache subdir so we make it writable as well. +chown -R "$SERVICE_USER:$SERVICE_USER" "$FLUTTER_DIR" +chown -R "$SERVICE_USER:$SERVICE_USER" /opt/flutter-bridge +# vcpkg: builds write under installed/, buildtrees/, etc. +chown -R "$SERVICE_USER:$SERVICE_USER" "$VCPKG_DIR" +# LLVM: read+execute is enough; we never write here at build time. +chown -R "$SERVICE_USER:$SERVICE_USER" "$LLVM_DIR" +# /opt/cargo-tools: workflow installs cargo-expand and flutter_rust_bridge_codegen +# here via `cargo install --root`. Pre-create with the right owner so the first +# job doesn't try to mkdir under root-owned /opt. +mkdir -p /opt/cargo-tools +chown -R "$SERVICE_USER:$SERVICE_USER" /opt/cargo-tools + +# git "dubious ownership": same fix as Windows. Trust system-wide. +git config --system --add safe.directory '*' || true + +# ---- 8. act_runner ---- +RUNNER_DIR=/var/lib/gitea-runner +mkdir -p "$RUNNER_DIR" +chown -R "$SERVICE_USER:$SERVICE_USER" "$RUNNER_DIR" + +if [[ ! -x "$RUNNER_DIR/act_runner" ]]; then + log "Downloading act_runner $RUNNER_VERSION" + curl -fsSL -o "$RUNNER_DIR/act_runner" \ + "https://gitea.com/gitea/act_runner/releases/download/v${RUNNER_VERSION}/act_runner-${RUNNER_VERSION}-linux-amd64" + chmod +x "$RUNNER_DIR/act_runner" + chown "$SERVICE_USER:$SERVICE_USER" "$RUNNER_DIR/act_runner" +fi + +if [[ ! -f "$RUNNER_DIR/.runner" ]]; then + log "Registering runner with $GITEA_URL" + sudo -u "$SERVICE_USER" -H bash -c " + cd '$RUNNER_DIR' && \ + ./act_runner register --no-interactive \ + --instance '$GITEA_URL' \ + --token '$RUNNER_TOKEN' \ + --name '$RUNNER_NAME' \ + --labels '$RUNNER_LABELS' + " +fi + +# ---- 9. systemd service ---- +log "Installing systemd unit" +cat > /etc/systemd/system/gitea-act-runner.service < Site Admin > Actions > Runners" diff --git a/ci/runners/macos/provision.sh b/ci/runners/macos/provision.sh new file mode 100755 index 000000000..2331720fb --- /dev/null +++ b/ci/runners/macos/provision.sh @@ -0,0 +1,291 @@ +#!/usr/bin/env bash +# Provisions a macOS host (Apple Silicon, macOS 14+) as a Gitea Actions runner +# for RustDesk desktop (.dmg) builds. Idempotent: safe to re-run. +# +# Versions are pinned to .gitea/workflows/build-macos.yml. Bump them there and +# here together. +# +# Usage: +# sudo ./provision.sh \ +# --gitea-url https://gitea.example.com \ +# --runner-token +# +# Toolchains land in /opt/* (chowned to the runner user). Service is installed +# as a LaunchDaemon running as that user. + +set -euo pipefail + +# ---- pinned versions (mirror .gitea/workflows/build-macos.yml env block) ---- +RUST_VERSION="1.81.0" # MAC_RUST_VERSION upstream (cidre crate needs >=1.81) +FLUTTER_VERSION="3.24.5" # used for `flutter build macos` +FLUTTER_BRIDGE_VERSION="3.22.3" # used for `flutter pub get` + flutter_rust_bridge_codegen +VCPKG_COMMIT="120deac3062162151622ca4860575a33844ba10b" +NASM_VERSION="2.16.03" # 3.x has incompatible CLI; aom/dav1d need 2.x +RUNNER_VERSION="0.2.11" + +# ---- defaults ---- +RUNNER_NAME="$(hostname -s)-rustdesk" +RUNNER_LABELS="" +SERVICE_USER="gitea-runner" +GITEA_URL="" +RUNNER_TOKEN="" + +# ---- arg parse ---- +while [[ $# -gt 0 ]]; do + case "$1" in + --gitea-url) GITEA_URL="$2"; shift 2 ;; + --runner-token) RUNNER_TOKEN="$2"; shift 2 ;; + --runner-name) RUNNER_NAME="$2"; shift 2 ;; + --runner-labels) RUNNER_LABELS="$2"; shift 2 ;; + --service-user) SERVICE_USER="$2"; shift 2 ;; + -h|--help) + sed -n '2,15p' "$0" + exit 0 ;; + *) echo "Unknown arg: $1" >&2; exit 2 ;; + esac +done + +[[ "$EUID" -eq 0 ]] || { echo "Run as root (use sudo)." >&2; exit 1; } +[[ -n "$GITEA_URL" && -n "$RUNNER_TOKEN" ]] \ + || { echo "Missing --gitea-url or --runner-token" >&2; exit 2; } + +# ---- arch + macOS version detection ---- +ARCH="$(uname -m)" +case "$ARCH" in + arm64) HOMEBREW_PREFIX="/opt/homebrew"; ARCH_LABEL="ARM64" ;; + x86_64) HOMEBREW_PREFIX="/usr/local"; ARCH_LABEL="X64" ;; + *) echo "Unsupported arch: $ARCH" >&2; exit 1 ;; +esac + +OS_MAJOR="$(sw_vers -productVersion | cut -d. -f1)" +[[ "$OS_MAJOR" -ge 14 ]] || { + echo "WARNING: tested only on macOS 14+. You're on $(sw_vers -productVersion)." + sleep 3 +} +DISTRO_LABEL="macos-${OS_MAJOR}" + +if [[ -z "$RUNNER_LABELS" ]]; then + RUNNER_LABELS="${DISTRO_LABEL},self-hosted,${ARCH_LABEL},macOS" +fi + +log() { printf '\n==> %s\n' "$*"; } + +# ---- 1. Xcode Command Line Tools ---- +log "Verifying Xcode Command Line Tools" +if ! /usr/bin/xcode-select -p >/dev/null 2>&1; then + echo "Xcode Command Line Tools not installed. Run:" >&2 + echo " xcode-select --install" >&2 + echo "Then re-run this script." >&2 + exit 1 +fi +echo " $(xcode-select -p)" + +# ---- 2. Homebrew (machine-wide) ---- +# Homebrew refuses to install under root (its installer aborts with +# "Don't run this as root!"). It must be installed manually by a regular +# user before this script runs. +log "Verifying Homebrew" +if [[ ! -x "$HOMEBREW_PREFIX/bin/brew" ]]; then + echo "Homebrew not installed at $HOMEBREW_PREFIX." >&2 + echo "Install it as your regular user (NOT root), then re-run this script:" >&2 + echo " /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"" >&2 + exit 1 +fi +export PATH="$HOMEBREW_PREFIX/bin:$PATH" +echo " $(brew --version | head -1)" + +# brew install must also run as a non-root user. Determine which user invoked +# sudo so we can drop privileges for brew commands below. +BREW_USER="${SUDO_USER:-}" +if [[ -z "$BREW_USER" || "$BREW_USER" == "root" ]]; then + echo "Could not determine the non-root user that ran sudo (SUDO_USER unset)." >&2 + echo "Re-run with: sudo ./provision.sh ..." >&2 + exit 1 +fi +brew_as_user() { sudo -u "$BREW_USER" -H "$HOMEBREW_PREFIX/bin/brew" "$@"; } + +# ---- 3. brew packages ---- +log "Installing brew packages" +brew_pkgs=(node cocoapods llvm create-dmg pkg-config cmake ninja yasm autoconf automake libtool wget) +for p in "${brew_pkgs[@]}"; do + if brew_as_user list --versions "$p" >/dev/null 2>&1; then + echo " $p (already installed)" + else + brew_as_user install "$p" + fi +done + +# ---- 4. NASM 2.16.x (NOT brew's nasm 3.x; aom/dav1d need 2.x) ---- +if ! /usr/local/bin/nasm --version 2>/dev/null | grep -q "version $NASM_VERSION"; then + log "Installing NASM $NASM_VERSION" + tmp="$(mktemp -d)" + curl -fsSL -o "$tmp/nasm.zip" \ + "https://www.nasm.us/pub/nasm/releasebuilds/${NASM_VERSION}/macosx/nasm-${NASM_VERSION}-macosx.zip" + unzip -q "$tmp/nasm.zip" -d "$tmp" + install -m 0755 "$tmp/nasm-${NASM_VERSION}/nasm" /usr/local/bin/nasm + rm -rf "$tmp" +fi +/usr/local/bin/nasm --version | head -1 + +# ---- 5. dedicated runner user ---- +if ! /usr/bin/id -u "$SERVICE_USER" >/dev/null 2>&1; then + log "Creating user $SERVICE_USER" + # Find an unused UID >= 600 + uid=600 + while dscl . -list /Users UniqueID | awk '{print $2}' | grep -qx "$uid"; do + uid=$((uid + 1)) + done + dscl . -create "/Users/$SERVICE_USER" + dscl . -create "/Users/$SERVICE_USER" UserShell /bin/bash + dscl . -create "/Users/$SERVICE_USER" RealName "Gitea Runner" + dscl . -create "/Users/$SERVICE_USER" UniqueID "$uid" + dscl . -create "/Users/$SERVICE_USER" PrimaryGroupID 20 + dscl . -create "/Users/$SERVICE_USER" NFSHomeDirectory "/Users/$SERVICE_USER" + mkdir -p "/Users/$SERVICE_USER" + chown "$SERVICE_USER:staff" "/Users/$SERVICE_USER" +fi +RUNNER_HOME="/Users/$SERVICE_USER" + +# ---- 6. Rust (machine-wide) ---- +export RUSTUP_HOME=/opt/rustup +export CARGO_HOME=/opt/cargo +mkdir -p "$RUSTUP_HOME" "$CARGO_HOME" + +if [[ ! -x "$CARGO_HOME/bin/rustup" ]]; then + log "Installing rustup at $RUSTUP_HOME / $CARGO_HOME" + curl -fsSL https://sh.rustup.rs | RUSTUP_HOME="$RUSTUP_HOME" CARGO_HOME="$CARGO_HOME" \ + sh -s -- -y --default-toolchain none --profile minimal --no-modify-path +fi +"$CARGO_HOME/bin/rustup" toolchain install "$RUST_VERSION" --profile minimal --component rustfmt +"$CARGO_HOME/bin/rustup" target add --toolchain "$RUST_VERSION" aarch64-apple-darwin x86_64-apple-darwin +"$CARGO_HOME/bin/rustup" default "$RUST_VERSION" + +# ---- 7. Flutter (two SDKs: 3.24.5 for build, 3.22.3 for bridge gen) ---- +# Same rationale as Linux: bridge codegen 1.80.1 + freezed produces broken Dart +# under newer Flutter. Run codegen under 3.22.3, build under 3.24.5. +install_flutter() { + local ver="$1" dir="$2" + if [[ ! -x "$dir/bin/flutter" ]]; then + log "Installing Flutter $ver -> $dir" + local tmp; tmp="$(mktemp -d)" + local parent; parent="$(dirname "$dir")" + # Flutter URL pattern differs between archs: Apple Silicon has an + # `_arm64_` infix, Intel has no arch infix at all. + local flutter_url_base="https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos" + local flutter_url + case "$ARCH" in + arm64) flutter_url="${flutter_url_base}_arm64_${ver}-stable.zip" ;; + x86_64) flutter_url="${flutter_url_base}_${ver}-stable.zip" ;; + esac + curl -fsSL -o "$tmp/flutter.zip" "$flutter_url" + mkdir -p "$parent" + unzip -q "$tmp/flutter.zip" -d "$tmp" + mv "$tmp/flutter" "$dir" + rm -rf "$tmp" + fi + "$dir/bin/flutter" config --no-analytics >/dev/null + "$dir/bin/flutter" precache --macos >/dev/null +} +install_flutter "$FLUTTER_VERSION" /opt/flutter +install_flutter "$FLUTTER_BRIDGE_VERSION" /opt/flutter-bridge + +# ---- 8. vcpkg ---- +VCPKG_DIR=/opt/vcpkg +if [[ ! -d "$VCPKG_DIR/.git" ]]; then + log "Cloning vcpkg" + git clone https://github.com/microsoft/vcpkg.git "$VCPKG_DIR" +fi +git -C "$VCPKG_DIR" fetch --tags origin +git -C "$VCPKG_DIR" -c advice.detachedHead=false checkout "$VCPKG_COMMIT" +[[ -x "$VCPKG_DIR/vcpkg" ]] || "$VCPKG_DIR/bootstrap-vcpkg.sh" -disableMetrics + +mkdir -p /var/cache/vcpkg +chown -R "$SERVICE_USER:staff" /var/cache/vcpkg + +# ---- 9. Permissions ---- +log "Setting up permissions for $SERVICE_USER" +chown -R "$SERVICE_USER:staff" "$CARGO_HOME" "$RUSTUP_HOME" \ + /opt/flutter /opt/flutter-bridge "$VCPKG_DIR" +mkdir -p /opt/cargo-tools +chown -R "$SERVICE_USER:staff" /opt/cargo-tools + +git config --system --add safe.directory '*' || true + +# ---- 10. act_runner ---- +RUNNER_DIR="/usr/local/var/gitea-runner" +mkdir -p "$RUNNER_DIR" +chown -R "$SERVICE_USER:staff" "$RUNNER_DIR" + +if [[ ! -x "$RUNNER_DIR/act_runner" ]]; then + log "Downloading act_runner $RUNNER_VERSION" + case "$ARCH" in + arm64) rarch="arm64" ;; + x86_64) rarch="amd64" ;; + esac + curl -fsSL -o "$RUNNER_DIR/act_runner" \ + "https://gitea.com/gitea/act_runner/releases/download/v${RUNNER_VERSION}/act_runner-${RUNNER_VERSION}-darwin-${rarch}" + chmod +x "$RUNNER_DIR/act_runner" + chown "$SERVICE_USER:staff" "$RUNNER_DIR/act_runner" +fi + +if [[ ! -f "$RUNNER_DIR/.runner" ]]; then + log "Registering runner with $GITEA_URL" + sudo -u "$SERVICE_USER" -H bash -c " + cd '$RUNNER_DIR' && \ + ./act_runner register --no-interactive \ + --instance '$GITEA_URL' \ + --token '$RUNNER_TOKEN' \ + --name '$RUNNER_NAME' \ + --labels '$RUNNER_LABELS' + " +fi + +# ---- 11. launchd service ---- +log "Installing LaunchDaemon" +PLIST=/Library/LaunchDaemons/com.rustdesk.gitea-runner.plist +cat > "$PLIST" < + + + + Label + com.rustdesk.gitea-runner + UserName + ${SERVICE_USER} + WorkingDirectory + ${RUNNER_DIR} + ProgramArguments + + ${RUNNER_DIR}/act_runner + daemon + + EnvironmentVariables + + RUSTUP_HOME ${RUSTUP_HOME} + CARGO_HOME ${CARGO_HOME} + VCPKG_ROOT ${VCPKG_DIR} + HOMEBREW_PREFIX ${HOMEBREW_PREFIX} + PATH + ${CARGO_HOME}/bin:/opt/flutter/bin:/opt/cargo-tools/bin:${HOMEBREW_PREFIX}/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + + RunAtLoad + KeepAlive + StandardOutPath${RUNNER_DIR}/stdout.log + StandardErrorPath${RUNNER_DIR}/stderr.log + SoftResourceLimits + + NumberOfFiles 65535 + + + +EOF +chmod 0644 "$PLIST" + +launchctl bootout system "$PLIST" 2>/dev/null || true +launchctl bootstrap system "$PLIST" +launchctl enable "system/com.rustdesk.gitea-runner" + +log "Done." +echo " Verify with: sudo launchctl print system/com.rustdesk.gitea-runner | head" +echo " Tail logs with: tail -F $RUNNER_DIR/stderr.log" +echo " Runner should appear (online) at $GITEA_URL > Site Admin > Actions > Runners" diff --git a/ci/runners/windows/provision.ps1 b/ci/runners/windows/provision.ps1 new file mode 100644 index 000000000..a82affcd8 --- /dev/null +++ b/ci/runners/windows/provision.ps1 @@ -0,0 +1,324 @@ +# Provisions a Windows host (Windows 10/11 or Server 2019+) as a Gitea Actions +# runner for RustDesk desktop builds. Idempotent: safe to re-run. +# +# Versions are pinned to .gitea/workflows/build-windows.yml. Bump them there and +# here together. +# +# Usage (Administrator PowerShell): +# Set-ExecutionPolicy -Scope Process Bypass -Force +# .\provision.ps1 -GiteaUrl https://gitea.example.com -RunnerToken +# +# By default the runner service is created under a dedicated local user +# (`gitea-runner`) -- LocalSystem has been observed to break flutter pub get, +# symlink creation, and git's "dubious ownership" check on this codebase. To +# opt out, pass `-ServiceAccount LocalSystem` (not recommended). + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] [string] $GiteaUrl, + [Parameter(Mandatory = $true)] [string] $RunnerToken, + [string] $RunnerName = "$env:COMPUTERNAME-rustdesk", + [string] $RunnerLabels = "windows-10,self-hosted,X64", + [string] $RunnerVersion = "0.2.11", + [string] $ServiceAccount = "gitea-runner", + [SecureString] $ServiceAccountPassword +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +# Must run elevated -- nearly every step writes Machine env, HKLM, or service config. +$me = [Security.Principal.WindowsIdentity]::GetCurrent() +if (-not (New-Object Security.Principal.WindowsPrincipal $me).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Run this script in an elevated (Administrator) PowerShell session.' +} + +# --- pinned versions (mirror .gitea/workflows/build-windows.yml env block) --- +$RUST_VERSION = '1.75.0' +$RUST_NIGHTLY = 'nightly-2023-10-13' +$LLVM_VERSION = '15.0.6' +$FLUTTER_VERSION = '3.24.5' +$VCPKG_COMMIT = '120deac3062162151622ca4860575a33844ba10b' + +$ToolsRoot = 'C:\tools' +New-Item -ItemType Directory -Force -Path $ToolsRoot | Out-Null + +# Exact-segment-match version of PATH augmentation. Substring matching would +# falsely find C:\bin when C:\binaries is on PATH. +function Add-MachinePath([string]$Dir) { + $cur = [Environment]::GetEnvironmentVariable('Path', 'Machine') + $segments = $cur -split ';' | Where-Object { $_ } + if ($segments -notcontains $Dir) { + [Environment]::SetEnvironmentVariable('Path', "$cur;$Dir", 'Machine') + } + if (($env:Path -split ';') -notcontains $Dir) { $env:Path = "$env:Path;$Dir" } +} + +# --- 1. Chocolatey (used for git, python, nuget, 7zip, node, dotnet, ...) --- +if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { + Write-Host '==> Installing Chocolatey' + Set-ExecutionPolicy Bypass -Scope Process -Force + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Invoke-Expression ((New-Object Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) +} + +Write-Host '==> Installing base packages' +# nodejs-lts: act_runner spawns Node to execute JavaScript actions. +# powershell-core: workflows use `shell: pwsh` (PS 7), not the OS's PS 5.1. +# dotnet-sdk: WiX 4 SDK-style projects (.wixproj) need it for the MSI build. +choco install -y --no-progress ` + git python311 nuget.commandline 7zip cmake ninja ` + nodejs-lts powershell-core dotnet-sdk +Add-MachinePath 'C:\Program Files\Git\cmd' +Add-MachinePath 'C:\Program Files\Git\bin' # bash.exe + posix tools (sed, find, ...) +Add-MachinePath 'C:\Python311' +Add-MachinePath 'C:\Python311\Scripts' +Add-MachinePath 'C:\Program Files\nodejs' +Add-MachinePath 'C:\Program Files\PowerShell\7' +Add-MachinePath 'C:\Program Files\dotnet' + +# --- 2. Visual Studio 2022 Build Tools (MSVC v143 + Win10 SDK) --- +# Use [Environment]::GetEnvironmentVariable to avoid the PowerShell parser quirk +# that mis-tokenises `$env:ProgramFiles(x86)` as `$env:ProgramFiles` + `(x86)`. +$pfx86 = [Environment]::GetEnvironmentVariable('ProgramFiles(x86)') +$vsInstaller = Join-Path $pfx86 'Microsoft Visual Studio\Installer\vswhere.exe' +$vsPresent = (Test-Path $vsInstaller) -and ` + ((& $vsInstaller -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath) -ne $null) +if (-not $vsPresent) { + Write-Host '==> Installing VS 2022 Build Tools (this takes a while)' + $vsBootstrapper = "$env:TEMP\vs_buildtools.exe" + Invoke-WebRequest -Uri 'https://aka.ms/vs/17/release/vs_buildtools.exe' -OutFile $vsBootstrapper + $vsArgs = @( + '--quiet','--wait','--norestart','--nocache', + '--add','Microsoft.VisualStudio.Workload.VCTools', + '--add','Microsoft.VisualStudio.Component.VC.Tools.x86.x64', + '--add','Microsoft.VisualStudio.Component.VC.ATL', + '--add','Microsoft.VisualStudio.Component.Windows10SDK.20348', + '--add','Microsoft.VisualStudio.Component.VC.CMake.Project', + '--includeRecommended' + ) + $p = Start-Process -FilePath $vsBootstrapper -ArgumentList $vsArgs -Wait -PassThru + if ($p.ExitCode -notin 0,3010) { throw "VS Build Tools installer exit $($p.ExitCode)" } +} + +# --- 3. Rust (stable + nightly with i686 target) --- +# Install machine-wide so any user (including the dedicated runner account) +# shares one toolchain registry. Without this, rustup state lives in the +# installing user's profile and the service user has no default toolchain. +$rustupHome = 'C:\rustup' +$cargoHome = 'C:\cargo' +[Environment]::SetEnvironmentVariable('RUSTUP_HOME', $rustupHome, 'Machine') +[Environment]::SetEnvironmentVariable('CARGO_HOME', $cargoHome, 'Machine') +$env:RUSTUP_HOME = $rustupHome +$env:CARGO_HOME = $cargoHome +Add-MachinePath "$cargoHome\bin" + +if (-not (Test-Path "$cargoHome\bin\rustup.exe")) { + Write-Host '==> Installing rustup (machine-wide at C:\rustup, C:\cargo)' + Invoke-WebRequest -Uri 'https://win.rustup.rs/x86_64' -OutFile "$env:TEMP\rustup-init.exe" + & "$env:TEMP\rustup-init.exe" -y --default-toolchain none --profile minimal +} +rustup toolchain install $RUST_VERSION --profile minimal --component rustfmt +rustup target add --toolchain $RUST_VERSION x86_64-pc-windows-msvc +rustup toolchain install $RUST_NIGHTLY --profile minimal --component rustfmt +rustup target add --toolchain $RUST_NIGHTLY i686-pc-windows-msvc +rustup default $RUST_VERSION + +# --- 4. LLVM/Clang (matches KyleMayes/install-llvm-action layout) --- +$llvmDir = "$ToolsRoot\llvm-$LLVM_VERSION" +if (-not (Test-Path "$llvmDir\bin\clang.exe")) { + Write-Host "==> Installing LLVM $LLVM_VERSION" + $llvmExe = "$env:TEMP\LLVM-$LLVM_VERSION-win64.exe" + Invoke-WebRequest -Uri "https://github.com/llvm/llvm-project/releases/download/llvmorg-$LLVM_VERSION/LLVM-$LLVM_VERSION-win64.exe" -OutFile $llvmExe + & $llvmExe /S "/D=$llvmDir" | Out-Null +} +[Environment]::SetEnvironmentVariable('LIBCLANG_PATH', "$llvmDir\bin", 'Machine') +Add-MachinePath "$llvmDir\bin" + +# --- 5. Flutter (stable channel, with windows precache) --- +$flutterDir = "$ToolsRoot\flutter" +if (-not (Test-Path "$flutterDir\bin\flutter.bat")) { + Write-Host "==> Installing Flutter $FLUTTER_VERSION" + $flutterZip = "$env:TEMP\flutter.zip" + Invoke-WebRequest -Uri "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_$FLUTTER_VERSION-stable.zip" -OutFile $flutterZip + Expand-Archive -Force -Path $flutterZip -DestinationPath $ToolsRoot +} +Add-MachinePath "$flutterDir\bin" +& "$flutterDir\bin\flutter.bat" config --no-analytics | Out-Null +& "$flutterDir\bin\flutter.bat" precache --windows | Out-Null + +# --- 6. vcpkg pinned to commit --- +$vcpkgDir = 'C:\vcpkg' +if (-not (Test-Path "$vcpkgDir\.git")) { + Write-Host '==> Cloning vcpkg' + git clone https://github.com/microsoft/vcpkg.git $vcpkgDir +} +Push-Location $vcpkgDir +git fetch --tags origin +git -c advice.detachedHead=false checkout $VCPKG_COMMIT +if (-not (Test-Path "$vcpkgDir\vcpkg.exe")) { & "$vcpkgDir\bootstrap-vcpkg.bat" -disableMetrics } +Pop-Location +[Environment]::SetEnvironmentVariable('VCPKG_ROOT', $vcpkgDir, 'Machine') +Add-MachinePath $vcpkgDir + +# --- 7. CI prerequisites that aren't tools, but environmental switches --- + +# git's "dubious ownership" check (>= 2.35.2) refuses to operate on a repo whose +# .git directory is owned by a different user than the one running git. The +# Flutter SDK at C:\tools\flutter is provisioned by this script as Administrator +# but the runner service runs as a non-admin user. Trust everything system-wide. +git config --system --add safe.directory '*' 2>$null + +# Flutter on Windows needs SeCreateSymbolicLinkPrivilege to build plugins. +# Enable Developer Mode (registry) AND grant the privilege via Local Security +# Policy to the built-in "Users" group (SID S-1-5-32-545). Either alone has been +# observed to not take effect until logon-token refresh; doing both is +# belt-and-suspenders. The privilege only reaches a long-running service after +# a reboot or a fresh service-token issuance. +$devKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock' +if (-not (Test-Path $devKey)) { New-Item -Path $devKey -Force | Out-Null } +New-ItemProperty -Path $devKey -Name 'AllowDevelopmentWithoutDevLicense' ` + -PropertyType DWORD -Value 1 -Force | Out-Null + +$secCfg = "$env:TEMP\sec-symlink.cfg" +secedit /export /cfg $secCfg | Out-Null +$secContent = Get-Content $secCfg -Raw +if ($secContent -match 'SeCreateSymbolicLinkPrivilege\s*=\s*([^\r\n]*)') { + $cur = $matches[1] + if ($cur -notmatch '\*S-1-5-32-545') { + $secContent = $secContent -replace ` + '(SeCreateSymbolicLinkPrivilege\s*=\s*)([^\r\n]*)', ` + '$1$2,*S-1-5-32-545' + } +} else { + $secContent = $secContent -replace ` + '(\[Privilege Rights\][\r\n]+)', ` + "`$1SeCreateSymbolicLinkPrivilege = *S-1-5-32-545`r`n" +} +$secContent | Set-Content $secCfg +secedit /configure /db "$env:TEMP\sec-symlink.sdb" /cfg $secCfg /areas USER_RIGHTS /quiet +Remove-Item $secCfg, "$env:TEMP\sec-symlink.sdb" -ErrorAction SilentlyContinue + +# --- 8. Dedicated runner user --- +# Running as LocalSystem causes a cascade of issues: +# - $USERPROFILE = C:\Windows\System32\config\systemprofile, which Flutter, +# dart pub, and other POSIX-leaning tools mis-handle. +# - cargo install lands binaries in that systemprofile path -> not on PATH. +# - flutter/windows occasionally vanishes during long cargo builds. +# A normal local user fixes all of these. +if ($ServiceAccount -ne 'LocalSystem') { + if (-not (Get-LocalUser -Name $ServiceAccount -ErrorAction SilentlyContinue)) { + if (-not $ServiceAccountPassword) { + # Generate a 32-byte random password using the OS RNG. Encoded as + # base64 (alphanumeric + +/) and trimmed of padding -- meets local + # password complexity without needing System.Web (which is missing + # on Server Core). + $bytes = New-Object byte[] 24 + [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) + $plain = ([Convert]::ToBase64String($bytes)).TrimEnd('=') + 'A1!' + $ServiceAccountPassword = ConvertTo-SecureString $plain -AsPlainText -Force + Remove-Variable plain, bytes + } + Write-Host "==> Creating local user '$ServiceAccount'" + New-LocalUser -Name $ServiceAccount -Password $ServiceAccountPassword ` + -PasswordNeverExpires -AccountNeverExpires ` + -Description 'Gitea Actions runner service account' | Out-Null + Add-LocalGroupMember -Group 'Users' -Member $ServiceAccount + } + + # Grant "Log on as a service" via secedit (no PS native cmdlet for this). + $sid = (Get-LocalUser $ServiceAccount).SID.Value + $svcCfg = "$env:TEMP\sec-svc.cfg" + secedit /export /cfg $svcCfg | Out-Null + $svcContent = Get-Content $svcCfg -Raw + if ($svcContent -match "SeServiceLogonRight\s*=\s*([^\r\n]*)") { + if ($matches[1] -notmatch [regex]::Escape($sid)) { + $svcContent = $svcContent -replace ` + '(SeServiceLogonRight\s*=\s*)([^\r\n]*)', ` + "`$1`$2,*$sid" + } + } else { + $svcContent = $svcContent -replace ` + '(\[Privilege Rights\][\r\n]+)', ` + "`$1SeServiceLogonRight = *$sid`r`n" + } + $svcContent | Set-Content $svcCfg + secedit /configure /db "$env:TEMP\sec-svc.sdb" /cfg $svcCfg /areas USER_RIGHTS /quiet + Remove-Item $svcCfg, "$env:TEMP\sec-svc.sdb" -ErrorAction SilentlyContinue + + # Ensure the user can read/write everything it needs for builds. + foreach ($p in @('C:\actions-runner','C:\cargo','C:\cargo-tools','C:\vcpkg','C:\vcpkg-cache')) { + New-Item -ItemType Directory -Force -Path $p | Out-Null + icacls $p /grant "${ServiceAccount}:(OI)(CI)F" /T 2>$null | Out-Null + } + foreach ($p in @('C:\rustup','C:\tools')) { + if (Test-Path $p) { icacls $p /grant "${ServiceAccount}:(OI)(CI)RX" /T 2>$null | Out-Null } + } +} + +# --- 9. Gitea act_runner --- +$runnerDir = 'C:\actions-runner' +New-Item -ItemType Directory -Force -Path $runnerDir | Out-Null +$runnerExe = "$runnerDir\act_runner.exe" +if (-not (Test-Path $runnerExe)) { + Write-Host "==> Downloading act_runner $RunnerVersion" + Invoke-WebRequest -Uri "https://gitea.com/gitea/act_runner/releases/download/v$RunnerVersion/act_runner-$RunnerVersion-windows-amd64.exe" -OutFile $runnerExe +} + +Push-Location $runnerDir +if (-not (Test-Path "$runnerDir\.runner")) { + Write-Host '==> Registering runner' + & $runnerExe register --no-interactive ` + --instance $GiteaUrl ` + --token $RunnerToken ` + --name $RunnerName ` + --labels $RunnerLabels +} + +# Reconfigure the service every run so re-running with a different +# -ServiceAccount actually takes effect. +$svc = Get-Service -Name 'gitea-act-runner' -ErrorAction SilentlyContinue +if ($svc) { + if ($svc.Status -eq 'Running') { Stop-Service gitea-act-runner } +} else { + Write-Host '==> Installing runner as Windows service' + choco install -y --no-progress nssm + nssm install gitea-act-runner $runnerExe daemon | Out-Null +} +nssm set gitea-act-runner AppDirectory $runnerDir | Out-Null +nssm set gitea-act-runner Start SERVICE_AUTO_START | Out-Null +nssm set gitea-act-runner AppStdout "$runnerDir\runner.log" | Out-Null +nssm set gitea-act-runner AppStderr "$runnerDir\runner.log" | Out-Null + +if ($ServiceAccount -eq 'LocalSystem') { + nssm set gitea-act-runner ObjectName 'LocalSystem' | Out-Null +} else { + $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($ServiceAccountPassword) + try { + $plain = [System.Runtime.InteropServices.Marshal]::PtrToStringUni($bstr) + nssm set gitea-act-runner ObjectName ".\$ServiceAccount" $plain | Out-Null + } finally { + [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) + Remove-Variable plain -ErrorAction SilentlyContinue + } +} + +# Start may fail before reboot if the new SeServiceLogonRight hasn't reached +# SCM yet -- that's expected; the service will start cleanly after reboot. +try { + Start-Service gitea-act-runner +} catch { + Write-Warning "Could not start gitea-act-runner now ($($_.Exception.Message)). It will start on reboot." +} +Pop-Location + +Write-Host '' +Write-Host '==> Done.' +Write-Host ' A reboot is REQUIRED before the first build run, so:' +Write-Host ' - the runner service inherits the new SeCreateSymbolicLinkPrivilege token' +Write-Host ' - all PATH/env changes propagate to the SCM-launched service' +Write-Host ' After reboot, verify the runner shows up in Gitea > Site Admin > Actions > Runners.' +if ($ServiceAccount -eq 'LocalSystem') { + Write-Warning 'Service is running as LocalSystem. RustDesk builds have been observed to fail in this configuration (Flutter pub get, symlinks, dubious ownership). Re-run with -ServiceAccount gitea-runner to switch.' +} diff --git a/docs/CLIENT_VS_SERVER_GAPS.md b/docs/CLIENT_VS_SERVER_GAPS.md new file mode 100644 index 000000000..2f7872239 --- /dev/null +++ b/docs/CLIENT_VS_SERVER_GAPS.md @@ -0,0 +1,260 @@ +# RustDesk Client vs OSS Server — Feature Gap Analysis + +This document compares the RustDesk **client** ([/Users/sn0/Desktop/rustdesk](../)) against the OSS **server** ([/Users/sn0/Desktop/rustdesk-server](../../rustdesk-server/)) and lists every feature the client implements or expects, but that the OSS server (`hbbs` + `hbbr` + `rustdesk-utils`) does **not** provide. + +The OSS server is, by upstream's own description in its README, deliberately minimal: + +> Self-host your own RustDesk server, it is free and open source. +> If you want extra features, **RustDesk Server Pro** might suit you better. + +Almost every gap below is filled by RustDesk Server Pro (closed source). What follows is the concrete list. + +--- + +## TL;DR + +| Area | OSS server | Client expects | +|-----------------------------------------------------|-----------------|----------------------------------------------| +| Rendezvous protocol (UDP/TCP/WS) | ✅ implemented | ✅ | +| Relay protocol (`hbbr`) | ✅ implemented | ✅ | +| `RegisterPk` over **TCP** | ❌ `NOT_SUPPORT`| Uses UDP — non-issue if UDP reachable | +| `HttpProxyRequest` / `HttpProxyResponse` (HTTP-via-rendezvous tunnel) | ❌ no handler | Used when `USE_RAW_TCP_FOR_API=Y` | +| **Entire `/api/*` HTTP surface** (35 endpoints in [CONSOLE_API.md](CONSOLE_API.md)) | ❌ no HTTP server | All login, AB, group, audit, sysinfo, etc. | +| User / password / token authentication | ❌ | Bearer-token model | +| Address book (legacy or shared) | ❌ | Personal AB, shared AB, tags, peers | +| Device groups, users, accessible peers list | ❌ | "Group" tab in UI | +| Audit logging (conn, file, alarm, note) | ❌ | Background fire-and-forget | +| Session recording upload | ❌ | Chunked uploader | +| Sysinfo / heartbeat-based device tracking | ❌ | Every 15 s (3 s when active) | +| Strategy/policy push via heartbeat | ❌ | `config_options` / `disconnect` / `sysinfo` flag | +| 2FA, email-code, SMS-code, OIDC/SSO | ❌ | Login challenge variants | +| Plugin signing (`/lic/web/api/plugin-sign`) | ❌ | Optional, only if signed plugins shipped | +| CLI bulk device assignment (`rustdesk --assign`) | ❌ | `POST /api/devices/cli` | +| Per-tenant licensing / Pro status flag | ❌ | Inferred from sysinfo success | + +--- + +## 1. Rendezvous-protocol gaps + +The OSS server *does* speak the rendezvous protocol — it accepts and responds to all of the variants the client sends in the normal connection path. There are exactly **two** variant-level gaps: + +### 1.1 `RegisterPk` over TCP — explicitly rejected + +[src/rendezvous_server.rs:556-563](../../rustdesk-server/src/rendezvous_server.rs#L556) returns `RegisterPkResponse { result: NOT_SUPPORT }` for any TCP-arriving `RegisterPk`. UDP works fine. The client has UDP `RegisterPk` as the primary path ([src/rendezvous_mediator.rs:685-693](../src/rendezvous_mediator.rs#L685-L693)), so this is only an issue in fully UDP-blocked environments where the client falls back to TCP for everything. + +### 1.2 `HttpProxyRequest` / `HttpProxyResponse` — no handler + +When `USE_RAW_TCP_FOR_API` is enabled (and WebSocket is off), the client tunnels its HTTP API calls **as protobuf messages** over the rendezvous server's TCP socket — see [src/common.rs:1188-1250](../src/common.rs#L1188-L1250) (`tcp_proxy_request`). + +- **Wire format:** an encrypted `KeyExchange` handshake, then a `RendezvousMessage::set_http_proxy_request(HttpProxyRequest { method, path, headers, body })`, expecting `HttpProxyResponse { status_code, headers, body }` back. +- **OSS handling:** there is no match arm for `HttpProxyRequest` in [/Users/sn0/Desktop/rustdesk-server/src/rendezvous_server.rs](../../rustdesk-server/src/rendezvous_server.rs); it falls into the `_ => {}` catch-all and is silently dropped. +- **Impact:** any client that has been pushed `OPTION_USE_RAW_TCP_FOR_API=Y` (typical for restricted networks) cannot reach the HTTP API at all on an OSS-only deployment, even if the API itself were implemented. + +### 1.3 What the OSS server *does* handle (not gaps, for completeness) + +- UDP: `RegisterPeer`, `RegisterPk`, `PunchHoleRequest`, `PunchHoleSent`, `LocalAddr`, `ConfigureUpdate` (loopback only), `SoftwareUpdate`. +- TCP: `PunchHoleRequest`, `RequestRelay`, `RelayResponse`, `PunchHoleSent`, `LocalAddr`, `TestNatRequest`. +- TCP listener2 (`port - 1`): `TestNatRequest`, `OnlineRequest`, plus a loopback admin CLI. +- WebSocket on `port + 2` (default 21118): same handler set as TCP. +- The relay (`hbbr`) listens on `port` (default 21117) and `port + 2` (WebSocket, default 21119), and handles `RequestRelay` for tunnelling. + +So the **rendezvous + relay surface for plain peer-to-peer use is complete in the OSS server.** All the actual gaps are above the rendezvous layer. + +--- + +## 2. HTTP API — none of it exists in OSS + +[/Users/sn0/Desktop/rustdesk-server/Cargo.toml](../../rustdesk-server/Cargo.toml) pulls in `axum` and `tower-http`, but they are unused — no `Router::new`, no route definitions, no HTTP listener bound. **All 35 endpoints documented in [CONSOLE_API.md](CONSOLE_API.md) are gaps.** + +Grouped by feature area: + +### 2.1 Authentication & session (`/api/login`, `/api/login-options`, `/api/logout`, `/api/currentUser`) + +- No user table in the OSS schema (which is a single `peer` table — see §4 below). No password storage, hashing, salting. +- No bearer-token issuance or validation. +- No `email_check` / `tfa_check` challenge types (no email sender, no TOTP store). +- No login-options registry — client always gets an empty `oidc/...` list. +- No OIDC / SSO device flow (`/api/oidc/auth`, `/api/oidc/auth-query`). + +### 2.2 Address book — both modes missing + +The client supports two AB modes ([CONSOLE_API.md §4](CONSOLE_API.md)) and the OSS server implements neither: + +- **Legacy single-blob mode** (`GET/POST /api/ab`) — needs a per-user blob store + gzip handling. +- **Shared mode** (`/api/ab/settings`, `/api/ab/personal`, `/api/ab/shared/profiles`, `/api/ab/peers`, `/api/ab/tags/{guid}`, plus the per-peer and per-tag CRUD on `{guid}`) — needs a normalized AB / peer / tag / share-rule schema with `read | read/write | full control` ACLs. + +Without this, the client falls back to its local-only "Recents" / "Favorites" lists; nothing syncs across devices. + +### 2.3 Device groups, users, accessible peers (`/api/device-group/accessible`, `/api/users`, `/api/peers`) + +The "Group" tab in the desktop UI populates from these three endpoints. With OSS the tab is empty; the client logs `get accessible device groups: ` and silently swallows it. + +### 2.4 Heartbeat / sysinfo / strategy push (`/api/heartbeat`, `/api/sysinfo`, `/api/sysinfo_ver`) + +This is the agent-management heartbeat loop ([CONSOLE_API.md §6](CONSOLE_API.md)). + +OSS gaps: + +- No device-tracking table (last-seen, OS, hostname, version, IP, online state). +- No sysinfo cache or version string. +- No mechanism to **push back** in the heartbeat response: + - `sysinfo: ` to force an immediate sysinfo re-upload. + - `disconnect: [conn_id, ...]` to force a remote session to close. + - `modified_at` / `strategy.config_options` to push policy. +- Without strategy push, the operator cannot remotely set: + - `whitelist`, `relay-server`, `rendezvous-servers`, `direct-access-port`, + `stop-service`, `OPTION_DISABLE_UDP`, `OPTION_ENABLE_UDP_PUNCH`, + `OPTION_ENABLE_IPV6_PUNCH`, `OPTION_USE_RAW_TCP_FOR_API`, + `OPTION_DIRECT_SERVER`, etc. + - Or any of the `OPTION_PRESET_*` keys that pre-fill the address book / username / device group / strategy on a freshly enrolled client. +- `is_pro()` is set to `true` only when `/api/sysinfo` returns `SYSINFO_UPDATED` ([src/hbbs_http/sync.rs:219](../src/hbbs_http/sync.rs#L219)) — with OSS, `is_pro()` is permanently `false`, so any client behavior gated on it is disabled. + +### 2.5 Audit (`/api/audit/conn`, `/api/audit/file`, `/api/audit/alarm`, `PUT /api/audit`) + +The client emits these fire-and-forget on every: + +- new remote session ([src/server/connection.rs:1248-1252](../src/server/connection.rs#L1248-L1252)), +- file send/receive ([src/server/connection.rs:1297-1330](../src/server/connection.rs#L1297-L1330)), +- security alarm — IP whitelist hit, brute-force thresholds ([src/server/connection.rs:1332-1349](../src/server/connection.rs#L1332-L1349)), and +- the operator-typed end-of-session note (PUT, [flutter/lib/common/widgets/dialog.dart:1656-1687](../flutter/lib/common/widgets/dialog.dart#L1656-L1687)). + +Even though the client doesn't *block* on these, OSS silently swallows all of them, so: + +- No central session log. +- No file-transfer log (incl. the top-10-by-size summary the client computes). +- No alarm notifications for IP-whitelist hits or brute-force attempts. +- No audit-row GUID exists, so the "leave a note when the session ends" dialog has nothing to attach to. + +### 2.6 Session recording upload (`POST /api/record`) + +Chunked uploader ([src/hbbs_http/record_upload.rs](../src/hbbs_http/record_upload.rs)) with `?type=new|part|tail|remove`. OSS has no `/api/record` route and no on-disk recording store, so server-side recording is impossible. + +(Local-only recording on the controlling side still works — that's not a server feature.) + +### 2.7 OIDC / SSO device flow (`/api/oidc/auth`, `/api/oidc/auth-query`) + +The polled device-code flow ([src/hbbs_http/account.rs](../src/hbbs_http/account.rs)) requires an OIDC client implementation, browser-flow URL generation, and a poll-for-token side. None of this is in OSS. + +### 2.8 CLI bulk assign (`POST /api/devices/cli`) + +[src/core_main.rs:519-616](../src/core_main.rs#L519-L616). Used by `rustdesk --assign --token ...` for mass-deploy scripts to register a freshly-installed agent into a tenant, optionally setting `user_name`, `strategy_name`, `address_book_*`, `device_group_name`, `device_username`, `device_name`, `note`. Requires user/group/AB tables, none of which exist in OSS. + +### 2.9 Plugin signature service (`POST /lic/web/api/plugin-sign`) + +[src/plugin/callback_msg.rs:282-296](../src/plugin/callback_msg.rs#L282-L296). Required only if the deployment ships signed plugins. OSS has no plugin infrastructure of any kind. + +### 2.10 Generic file downloader + +`HEAD` then `GET` against an arbitrary URL with a required `Content-Length` ([src/hbbs_http/downloader.rs](../src/hbbs_http/downloader.rs)). Works against any static file server — OSS doesn't *need* to serve this, but a complete Pro-replacement backend usually exposes installer/plugin/recording downloads via this. + +--- + +## 3. Schema gaps + +The OSS database is a single SQLite table called `peer`: + +``` +guid (PK), id (UNIQUE), uuid, pk, created_at, user (unused), +status (unused), note (unused), info (JSON: { ip }) +``` + +[/Users/sn0/Desktop/rustdesk-server/src/database.rs:71-144](../../rustdesk-server/src/database.rs#L71-L144). + +To support the client's HTTP surface a backend needs at minimum: + +- `users` (id, name, display_name, avatar, email, note, password_hash, status, is_admin, totp_secret, oidc_subject, …) +- `tokens` (token_hash, user_id, expires_at) +- `oidc_sessions` (poll_code, state, created_at, access_token, …) +- `address_books` (guid, owner_user_id, name, note, kind=personal|shared) +- `address_book_shares` (ab_guid, user_or_group_id, rule={1,2,3}) +- `address_book_peers` (ab_guid, peer_id, alias, tags[], note, password|hash, username, hostname, platform) +- `address_book_tags` (ab_guid, name, color) +- `device_groups` (id, name) +- `device_group_members` (device_group_id, user_or_group_id) +- `peers_extended` (peer_id, user_id, device_group_id, last_seen, version, sysinfo_blob, sysinfo_hash, sysinfo_ver, online, …) +- `audit_conn` (guid, peer_id, conn_id, session_id, action, ip, started_at, ended_at, note) +- `audit_file` (peer_id, peer_remote, type, path, is_file, info_json) +- `audit_alarm` (peer_id, typ, info_json) +- `recordings` (filename, peer_id, size, header_blob, started_at, finished_at) +- `strategies` (id, name, modified_at, config_options_json, extra_json) +- `peer_strategy_assignment` / `device_group_strategy_assignment` + +The OSS schema covers exactly **one row** of one of those tables (`peers_extended.peer_id` plus `pk`/`uuid`). Everything else is a gap. + +--- + +## 4. Authentication / authorization gaps + +- **No user/password.** The OSS server identifies a peer entirely by `(id, uuid, pk)`. There is no concept of a logged-in *human user*, no password, no session, no role. +- **No bearer tokens.** The client adds `Authorization: Bearer ` to every authenticated HTTP call ([flutter/lib/common.dart:2691-2695](../flutter/lib/common.dart#L2691-L2695)). With no HTTP API and no user store, OSS has nothing to validate against. +- **No 2FA.** The client supports TOTP challenge (`type: tfa_check`, `tfa_type`, `secret`) and per-device 2FA-trust ([src/ui_session_interface.rs](../src/ui_session_interface.rs)). Not present in OSS. +- **No email/SMS verification.** The `email_check` challenge type and `verificationCode` field have no sender on the OSS side. +- **No SSO / OIDC.** No identity-provider integration. +- **No admin/role concept.** The `UserPayload.is_admin` flag (used to gate the user-management UI) has no source. +- **No per-AB ACL.** `AbProfile.rule` (read / read-write / full control) has no enforcement layer. +- **No IP allowlisting / per-IP rate-limiting on HTTP endpoints.** OSS rate-limits `RegisterPk` per source IP ([src/rendezvous_server.rs:891-919](../../rustdesk-server/src/rendezvous_server.rs#L891-L919)) but that's at the rendezvous layer only. + +What the OSS server *does* offer in this space: + +- Optional symmetric server key (`-k`) checked against `licence_key` in `PunchHoleRequest` and `RequestRelay`. This is shared-secret deployment lockdown, not user auth. +- Ed25519 signing of `RelayResponse` payloads using the server's private key. + +--- + +## 5. Operations / fleet management gaps + +These are conveniences a Pro server offers via the strategy/heartbeat channel; OSS has no equivalent because heartbeat itself is not implemented. + +- **Force-disconnect a remote session** from the admin console (heartbeat returns `disconnect: [conn_id]` — [src/hbbs_http/sync.rs:251-254](../src/hbbs_http/sync.rs#L251-L254)). +- **Force-refresh sysinfo** (`sysinfo` truthy in heartbeat). +- **Push global config** to all enrolled agents (the `strategy.config_options` map). Without this, every option must be set per-machine. +- **Pre-seed an agent** at install time with an address-book entry, alias, password, note, strategy, device group, custom hostname/username (`OPTION_PRESET_ADDRESS_BOOK_*`, `OPTION_PRESET_USERNAME`, `OPTION_PRESET_STRATEGY_NAME`, `OPTION_PRESET_DEVICE_GROUP_NAME`, …). Client emits these preset values on every sysinfo, but OSS discards them. +- **Operator end-of-session notes** (PUT `/api/audit`). +- **`rustdesk --assign --token …`** for mass deployment. +- **Brute-force / IP-whitelist alarms** to a central log. + +What OSS *does* offer for ops: + +- A loopback-only TCP admin CLI on `port - 1` (default 21115) for hbbs and on `port` for hbbr ([src/rendezvous_server.rs:1102-1116](../../rustdesk-server/src/rendezvous_server.rs#L1102-L1116), [src/relay_server.rs:152-323](../../rustdesk-server/src/relay_server.rs#L152-L323)) — `relay-servers`, `ip-blocker`, `ip-changes`, `punch-requests`, `always-use-relay`, `test-geo`, `blacklist-add`, `blocklist-add`, `total-bandwidth`, `usage`, etc. +- A `ConfigureUpdate` push *only from loopback* — the operator can update the rendezvous-server list pushed to clients, but only by `nc 127.0.0.1 21115` on the server box itself. + +--- + +## 6. Client-side features that work fine against OSS + +For balance — these features in the client need no Pro-server support at all: + +- All in-session protocol (after the relay/direct connection is established): screen sharing, file transfer, terminal, RDP / VNC tunnel, port forward, voice call, view-only mode, whiteboard, printer, clipboard, multi-monitor, mouse/keyboard injection. These are negotiated on the session stream itself and never touch the management server. +- LAN discovery (when both ends are reachable on the same LAN, no rendezvous server needed at all). +- The client's local 2FA on the *controlled* side ("ask the operator for a one-time code"). That's a peer-to-peer protocol negotiation, not a server feature. +- IP-whitelist enforcement on the controlled side ([src/server/connection.rs:1202-1228](../src/server/connection.rs#L1202-L1228)) — done locally against the `whitelist` config option. (But the *operator UX* of pushing that whitelist to a fleet is missing — see §5.) +- Self-update — the client checks a hardcoded URL on the public update server, not the configured rendezvous/API server. +- Custom-server bootstrap via filename (`rustdesk-host=…,key=…,api=…,relay=….exe`, [src/custom_server.rs](../src/custom_server.rs)) — works against OSS as long as the `api=` field is left empty / public. + +--- + +## 7. What you'd need to build to fully replace Pro + +Given the analysis above, a full Pro-replacement backend on top of OSS would need: + +1. **Add an HTTP server** (axum is already in the Cargo.toml of OSS, unused). Implement the 35 routes in [CONSOLE_API.md](CONSOLE_API.md). +2. **Add a `HttpProxyRequest` handler** in `rendezvous_server.rs` so that locked-down clients can reach the HTTP API through the rendezvous TCP port (decode the protobuf, replay the request internally, wrap the response). +3. **Extend the schema** along the lines of §3. +4. **Add user / token / OIDC / 2FA layers**, plus an email sender for `email_check`. +5. **Implement the strategy / push-config side of `/api/heartbeat`** and the sysinfo cache for `/api/sysinfo*`. +6. **Add audit + recording stores** with retention and access-control. +7. (Optional) **Plugin signing service** if you're shipping signed plugins. + +The rendezvous + relay protocol itself does not need to change — OSS is correct and complete there. + +--- + +## Source-of-truth references + +- Client HTTP API the server must serve: [docs/CONSOLE_API.md](CONSOLE_API.md). +- Client rendezvous receive loop: [src/rendezvous_mediator.rs](../src/rendezvous_mediator.rs). +- Client HTTP-via-TCP fallback: [src/common.rs:1188-1250](../src/common.rs#L1188-L1250). +- Client heartbeat loop: [src/hbbs_http/sync.rs](../src/hbbs_http/sync.rs). +- OSS rendezvous handler: [/Users/sn0/Desktop/rustdesk-server/src/rendezvous_server.rs](../../rustdesk-server/src/rendezvous_server.rs). +- OSS relay handler: [/Users/sn0/Desktop/rustdesk-server/src/relay_server.rs](../../rustdesk-server/src/relay_server.rs). +- OSS schema: [/Users/sn0/Desktop/rustdesk-server/src/database.rs](../../rustdesk-server/src/database.rs). diff --git a/docs/CONSOLE_API.md b/docs/CONSOLE_API.md new file mode 100644 index 000000000..55e6e1736 --- /dev/null +++ b/docs/CONSOLE_API.md @@ -0,0 +1,1038 @@ +# RustDesk Console API — Backend Specification + +This document specifies every HTTP/HTTPS endpoint that the RustDesk client in this +workspace calls against its server-side **Console / API server** (the API surface +exposed by RustDesk Server Pro / `hbbs`). The intent is that a backend implemented +to this spec will be able to serve a stock RustDesk client with no client-side +changes. + +The client base URL is whatever the user / installer has configured as +`api-server` (or, falling back, derived from `custom-rendezvous-server`). All +paths below are relative to that base URL. + +If the configured API host string `contains "rustdesk.com/"` or ends with +`"rustdesk.com"`, the client treats it as the public/managed server and +**suppresses** the heartbeat, sysinfo, and audit endpoints +([`is_public()` in src/common.rs:1088](../src/common.rs#L1088)). +A self-hosted backend MUST NOT use a hostname matching that pattern. + +--- + +## 1. Conventions + +### 1.1 Transport + +- HTTPS expected in production. The client probes TLS (`rustls-tls` first, then + `native-tls`, then with `danger_accept_invalid_certs`) on the first call and + caches the result. See [src/hbbs_http/http_client.rs](../src/hbbs_http/http_client.rs). +- A non-200 response body that is valid JSON with an `"error"` key is treated as + a structured error. See [`HbbHttpResponse::parse` in src/hbbs_http.rs:24-39](../src/hbbs_http.rs#L24-L39): + ```json + { "error": "human readable message" } + ``` +- Default request timeout: **12 seconds** + ([src/common.rs:1431](../src/common.rs#L1431)). +- Logout has a hard **2 second** timeout + ([flutter/lib/models/user_model.dart:168](../flutter/lib/models/user_model.dart#L168)). +- The plugin-sign call uses **10 seconds** ([src/plugin/callback_msg.rs:293](../src/plugin/callback_msg.rs#L293)). + +### 1.2 Headers + +For every authenticated request the client adds: + +``` +Authorization: Bearer +Content-Type: application/json +``` + +`Authorization` is built by [`getHttpHeaders()` in flutter/lib/common.dart:2691-2695](../flutter/lib/common.dart#L2691-L2695) +from the locally stored `access_token`. Unauthenticated calls (login, OIDC, +heartbeat, sysinfo, audit, record, plugin-sign) omit `Authorization`. + +### 1.3 Error envelope + +Successful action endpoints (peer add/update/delete, tag CRUD, etc.) typically +return either an empty body / `null` / `{}` on success, or +`{ "error": "" }` on failure with HTTP 200. The status code alone +is not a sufficient success indicator — the client always inspects the JSON +`error` field. + +### 1.4 Pagination + +List endpoints use 1-indexed pagination, query parameters: + +``` +?current=&pageSize= +``` + +The client always uses `pageSize=100` and iterates pages until +`current * pageSize >= total`. Response: + +```json +{ + "total": , + "data": [ ... ] +} +``` + +### 1.5 IDs and identifiers + +- **`id`** — RustDesk peer/device ID (the 9-digit number shown in the UI). +- **`uuid`** — A per-install machine identifier, base64-encoded by the client + before transmission (`crate::encode64(hbb_common::get_uuid())`). +- **`guid`** — Server-assigned GUID for an address book. +- **`conn_id`**, **`session_id`** — Internal ints assigned by the client per + remote session, echoed back in audit logs. + +### 1.6 `deviceInfo` object + +Sent inside login bodies. Shape (from +[src/hbbs_http/account.rs:31-44](../src/hbbs_http/account.rs#L31-L44)): + +```json +{ + "os": "Linux | Windows | macOS | Android | iOS", + "type": "client | browser", + "name": "" +} +``` + +--- + +## 2. Base URL & API discovery + +The client resolves its base URL via +[`get_api_server()` in src/common.rs](../src/common.rs); a custom-rendezvous +server with no explicit `api-server` is mapped to +`https://` (port 21114 if specified inline). + +The client makes a `HEAD` request against `/api/login-options` on +startup as a connectivity / TLS-detection probe +([src/hbbs_http/account.rs:155](../src/hbbs_http/account.rs#L155), +[src/hbbs_http/record_upload.rs:34](../src/hbbs_http/record_upload.rs#L34)). +A backend SHOULD respond 200 to a HEAD on `/api/login-options`. + +--- + +## 3. Authentication + +### 3.1 `POST /api/login` + +User credential / 2FA / SSO completion. + +- **Source:** [flutter/lib/models/user_model.dart:178-202](../flutter/lib/models/user_model.dart#L178-L202) +- **Auth:** none +- **Headers:** `Content-Type: application/json` (sent implicitly by `http.post`) + +**Request body** (from `LoginRequest.toJson()` at +[flutter/lib/common/hbbs/hbbs.dart:133-178](../flutter/lib/common/hbbs/hbbs.dart#L133-L178)): + +```json +{ + "username": "string", // optional + "password": "string", // optional + "id": "string", // RustDesk peer ID + "uuid": "string", // base64 + "autoLogin": true, // optional bool + "type": "account | mobile | sms_code | email_code | tfa_code | oidc/", + "verificationCode": "string", // optional, for email/SMS challenge + "tfaCode": "string", // optional + "secret": "string", // optional, echoed back from a tfa_check response + "deviceInfo": { "os": "...", "type": "...", "name": "..." } +} +``` + +`type` constants +([flutter/lib/common/hbbs/hbbs.dart:11-15](../flutter/lib/common/hbbs/hbbs.dart#L11-L15)): +`account`, `mobile`, `sms_code`, `email_code`, `tfa_code`. `oidc/` is +also accepted (the `oidc/` prefix triggers the device-flow described in §3.5). + +**Successful response** — `LoginResponse` shape +([flutter/lib/common/hbbs/hbbs.dart:180-197](../flutter/lib/common/hbbs/hbbs.dart#L180-L197)): + +```json +{ + "access_token": "opaque-bearer-token", + "type": "access_token", + "tfa_type": "totp | ...", // present when type == "tfa_check" + "secret": "string", // present when type == "tfa_check" + "user": { + "name": "string", + "display_name": "string", + "avatar": "string", + "email": "string", + "note": "string", + "status": 1, // -1=unverified, 0=disabled, 1=normal + "is_admin": false, + "verifier": "string" // optional, used by web build only + } +} +``` + +**Challenge responses.** When 2FA / verification is required, `type` is one of +([flutter/lib/common/hbbs/hbbs.dart:17-19](../flutter/lib/common/hbbs/hbbs.dart#L17-L19)): + +| `type` | Meaning | +|----------------|-----------------------------------------------------------------| +| `access_token` | Login complete, `access_token` populated. | +| `email_check` | Server emailed a code; client must POST again with `type:"email_code"` and `verificationCode`. | +| `tfa_check` | TOTP required; server returns `tfa_type` and `secret` to echo back with `type:"tfa_code"` + `tfaCode`. | + +**Status / error semantics:** +- Non-200 with body `{"error": "..."}` → client surfaces the error. +- 401 from this endpoint is treated as bad credentials (no auto-logout). +- 401 from any **other** authenticated endpoint clears the local token (see §3.6). + +### 3.2 `GET /api/login-options` + +Returns the list of login methods the server is configured to expose. + +- **Source:** [flutter/lib/models/user_model.dart:222-245](../flutter/lib/models/user_model.dart#L222-L245) +- **Auth:** none +- **Method:** `GET` (no body) + +**Response** — JSON array of strings: + +```json +[ + "account", + "oidc/google", + "oidc/github", + "common-oidc/[ {\"name\":\"google\",\"icon\":\"...\"} ]" +] +``` + +Two recognised conventions: + +- `oidc/` — exposed in the UI as an SSO button labelled ``. +- `common-oidc/` — the suffix is a JSON-encoded array of provider + descriptors `{ "name": "...", ... }`. If present anywhere in the list, it + takes precedence over individual `oidc/...` entries. + +### 3.3 `POST /api/currentUser` + +Refreshes the cached profile of the currently logged-in user. + +- **Source:** [flutter/lib/models/user_model.dart:60-99](../flutter/lib/models/user_model.dart#L60-L99) +- **Auth:** Bearer + +**Request body:** + +```json +{ "id": "", "uuid": "" } +``` + +**Response:** `UserPayload` (see §3.1). On HTTP 401 or 400 the client clears +the access token (401 also wipes the address-book and group caches). + +### 3.4 `POST /api/logout` + +- **Source:** [flutter/lib/models/user_model.dart:155-175](../flutter/lib/models/user_model.dart#L155-L175) +- **Auth:** Bearer +- **Timeout:** 2 s; failures are silently ignored client-side. + +**Request body:** + +```json +{ "id": "", "uuid": "" } +``` + +**Response:** body is ignored. + +### 3.5 OIDC / SSO device flow + +Two endpoints implement a polled device-code flow. + +#### `POST /api/oidc/auth` + +- **Source:** [src/hbbs_http/account.rs:160-176](../src/hbbs_http/account.rs#L160-L176) +- **Auth:** none + +**Request body:** + +```json +{ + "op": "", // matches an entry from /api/login-options + "id": "", + "uuid": "", + "deviceInfo": { "os": "...", "type": "...", "name": "..." } +} +``` + +**Response** — `OidcAuthUrl` ([src/hbbs_http/account.rs:24-28](../src/hbbs_http/account.rs#L24-L28)): + +```json +{ + "code": "opaque-poll-handle", + "url": "https://server/oidc/redirect?code=...&..." +} +``` + +The client opens `url` in the user's browser and polls (§ next). + +#### `GET /api/oidc/auth-query` + +- **Source:** [src/hbbs_http/account.rs:178-202](../src/hbbs_http/account.rs#L178-L202) +- **Auth:** none +- **Polling:** 1 s interval, **180 s** timeout (`QUERY_TIMEOUT_SECS = 60*3`). + +**Query parameters:** `code` (from auth response), `id`, `uuid`. + +**Response wrapper** — the response is an outer envelope whose `body` field is +itself JSON: + +```json +{ + "body": "" +} +``` + +The inner body, once parsed, is either: +- A normal `AuthBody` (same shape as §3.1 success), **or** +- `{ "error": "No authed oidc is found" }` while waiting (the client keeps + polling until success or timeout), **or** +- Any other `{ "error": "..." }` (terminates polling with an error). + +### 3.6 401 handling + +A 401 from any authenticated endpoint causes the Flutter client to: + +1. Clear `access_token` and cached `user_info` from local config. +2. Reset address-book and group state. + +A 400 from `/api/currentUser` clears the access token but does not reset other state +([flutter/lib/models/user_model.dart:81-83](../flutter/lib/models/user_model.dart#L81-L83)). + +--- + +## 4. Address book + +The client supports two modes: + +- **Legacy mode** — the whole address book is GET/PUT as a single JSON blob. + The server returns 404 on `/api/ab/personal` to signal legacy mode + ([flutter/lib/models/ab_model.dart:271-274](../flutter/lib/models/ab_model.dart#L271-L274)). +- **Shared mode** — the personal AB and zero or more shared address books are + represented as separate `guid`-keyed objects with paginated peer/tag lists. + +A backend SHOULD implement shared mode (legacy mode is supported only as +fall-back). + +### 4.1 `POST /api/ab/settings` + +Capability/limits probe. Called once per pull cycle. + +- **Source:** [flutter/lib/models/ab_model.dart:230-258](../flutter/lib/models/ab_model.dart#L230-L258) +- **Auth:** Bearer +- **Request body:** empty (Content-Type still `application/json`) +- **Special status:** `404` ⇒ "this server does not support shared AB; abort + init". Any other non-200 surfaces `pull_ab_failed_tip`. + +**Response:** + +```json +{ "max_peer_one_ab": 100 } +``` + +### 4.2 `POST /api/ab/personal` + +Returns the GUID of the caller's personal address book. + +- **Source:** [flutter/lib/models/ab_model.dart:262-293](../flutter/lib/models/ab_model.dart#L262-L293) +- **Auth:** Bearer +- **Request body:** empty +- **Special status:** `404` ⇒ legacy mode; client falls back to §4.7/§4.8. + +**Response:** + +```json +{ "guid": "personal-ab-guid" } +``` + +### 4.3 `POST /api/ab/shared/profiles` + +Paginated list of shared address books visible to the user. + +- **Source:** [flutter/lib/models/ab_model.dart:295-360](../flutter/lib/models/ab_model.dart#L295-L360) +- **Auth:** Bearer +- **Method:** `POST` (the resource is fetched with POST in the Flutter client, + query params are still used for pagination) +- **Query:** `current`, `pageSize` +- **Request body:** empty +- **Special status:** `404` ⇒ no shared-AB support. + +**Response — page of `AbProfile`:** + +```json +{ + "total": 12, + "data": [ + { + "guid": "ab-guid", + "name": "Engineering", + "owner": "alice", + "note": "optional string", + "rule": 2, // 1=read, 2=read/write, 3=full control + "info": { ... } // opaque, surfaced in the UI as-is + } + ] +} +``` + +`rule` enum from [flutter/lib/common/hbbs/hbbs.dart:210-256](../flutter/lib/common/hbbs/hbbs.dart#L210-L256). + +### 4.4 `POST /api/ab/peers?ab=` + +Paginated list of peers in a given address book. + +- **Source:** [flutter/lib/models/ab_model.dart:1432-1497](../flutter/lib/models/ab_model.dart#L1432-L1497) +- **Auth:** Bearer +- **Method:** `POST` +- **Query:** `current`, `pageSize`, `ab=` +- **Request body:** empty + +**Response page entry — `Peer`:** + +```json +{ + "id": "123456789", + "alias": "string", + "tags": ["tag1", "tag2"], + "note": "string", + "hash": "string", // present in personal AB only (password hash) + "password": "string", // present in shared AB only (encrypted password) + "username": "string", // OS username on the remote device + "hostname": "string", + "platform": "Windows | Linux | Mac OS | Android | iOS" +} +``` + +`hash`/`password` are mutually exclusive: the client strips `password` for personal +ABs and `hash` for shared ABs before pushing +([flutter/lib/models/ab_model.dart:1561-1565](../flutter/lib/models/ab_model.dart#L1561-L1565)). + +### 4.5 `POST /api/ab/tags/{guid}` + +List the tags of an address book. Returns a JSON array (no pagination). + +- **Source:** [flutter/lib/models/ab_model.dart:1499-1544](../flutter/lib/models/ab_model.dart#L1499-L1544) +- **Auth:** Bearer +- **Method:** `POST` +- **Request body:** empty + +**Response:** + +```json +[ + { "name": "Production", "color": -16776961 }, + { "name": "QA", "color": -65536 } +] +``` + +`color` is a Flutter `Color.value` (signed 32-bit ARGB packed integer). + +### 4.6 Peer mutations on a shared/personal AB + +All four take `Authorization` + `Content-Type: application/json`. Success is +either an empty body or `{}`; failure is HTTP 200 with `{"error":"..."}` (the +client reads `error` regardless of status). + +#### `POST /api/ab/peer/add/{guid}` — add a peer + +Source: [flutter/lib/models/ab_model.dart:1548-1578](../flutter/lib/models/ab_model.dart#L1548-L1578). + +```json +{ + "id": "123456789", + "alias": "string", + "tags": ["..."], + "note": "string", + "password": "string", // shared AB + "hash": "string", // personal AB + "username": "string", + "hostname": "string", + "platform": "string" +} +``` + +The client adds peers one-by-one (one HTTP call per peer). + +#### `PUT /api/ab/peer/update/{guid}` — partial peer update + +Source: [flutter/lib/models/ab_model.dart:1580-1729](../flutter/lib/models/ab_model.dart#L1580-L1729). + +The body always contains `id`, plus any subset of mutable fields. The client uses +this single endpoint for: alias change, note change, tag change, password +(`password`) / hash (`hash`) change, and `username`/`hostname`/`platform` sync +from recent connections. + +```json +{ + "id": "123456789", + "alias": "string", + "tags": ["..."], + "note": "string", + "password": "string", + "hash": "string", + "username": "string", + "hostname": "string", + "platform": "string" +} +``` + +#### `DELETE /api/ab/peer/{guid}` — bulk delete + +Source: [flutter/lib/models/ab_model.dart:1751-1771](../flutter/lib/models/ab_model.dart#L1751-L1771). + +Body is a JSON array of peer IDs: + +```json +["123456789", "987654321"] +``` + +### 4.7 Tag mutations on a shared/personal AB + +#### `POST /api/ab/tag/add/{guid}` + +Source: [flutter/lib/models/ab_model.dart:1775-1802](../flutter/lib/models/ab_model.dart#L1775-L1802). +The client iterates one POST per tag. + +```json +{ "name": "tag", "color": -16776961 } +``` + +#### `PUT /api/ab/tag/rename/{guid}` + +Source: [flutter/lib/models/ab_model.dart:1804-1831](../flutter/lib/models/ab_model.dart#L1804-L1831). + +```json +{ "old": "old-name", "new": "new-name" } +``` + +#### `PUT /api/ab/tag/update/{guid}` — set color + +Source: [flutter/lib/models/ab_model.dart:1833-1855](../flutter/lib/models/ab_model.dart#L1833-L1855). + +```json +{ "name": "tag", "color": -16776961 } +``` + +#### `DELETE /api/ab/tag/{guid}` — bulk delete + +Source: [flutter/lib/models/ab_model.dart:1857-1876](../flutter/lib/models/ab_model.dart#L1857-L1876). + +Body is a JSON array of tag names: `["tagA", "tagB"]`. + +### 4.8 Legacy address book (single-blob mode) + +Used only when `/api/ab/personal` returns 404. + +#### `GET /api/ab` + +Source: [flutter/lib/models/ab_model.dart:1007-1053](../flutter/lib/models/ab_model.dart#L1007-L1053). + +- **Auth:** Bearer +- **Headers:** `Accept-Encoding: gzip` +- **Response:** + +```json +{ + "data": "", + "licensed_devices": 100 +} +``` + +`data` decoded: + +```json +{ + "tags": ["tag1", ...], + "peers": [ { "id": "...", "alias": "...", ... }, ... ], + "tag_colors": "" +} +``` + +A response body of the literal string `null` (or empty) means "empty AB, no +error". + +#### `POST /api/ab` + +Source: [flutter/lib/models/ab_model.dart:1055-1096](../flutter/lib/models/ab_model.dart#L1055-L1096). + +- **Auth:** Bearer +- **Body:** the entire address book replaces what the server stores. + +```json +{ "data": "" } +``` + +Success: HTTP 200 with body empty / `null` / `{}`. Failure: `{"error":"..."}`. + +--- + +## 5. Device groups, users, peers (group view) + +These three endpoints together populate the "Group / Device" tab. All three are +GET, paginated, Bearer-authenticated. + +### 5.1 `GET /api/device-group/accessible` + +Source: [flutter/lib/models/group_model.dart:103-158](../flutter/lib/models/group_model.dart#L103-L158). + +- **Query:** `current`, `pageSize` +- **Behaviour:** the client treats *any* error from this endpoint as + "old hbbs without device-group support" and silently continues. + +**Response page entry — `DeviceGroupPayload`:** + +```json +{ "name": "Engineering" } +``` + +### 5.2 `GET /api/users` + +Source: [flutter/lib/models/group_model.dart:160-222](../flutter/lib/models/group_model.dart#L160-L222). + +- **Query:** `current`, `pageSize`, `accessible=` (empty string), `status=1` +- **Auth:** Bearer + +**Response page entry — `UserPayload`** (same shape as §3.1). + +The client recognises the legacy errors `"Admin required!"` and +`"ambiguous column name: status"` and translates them to a "please upgrade +RustDesk Server Pro" toast. Backends should not produce them. + +### 5.3 `GET /api/peers` + +Source: [flutter/lib/models/group_model.dart:224-282](../flutter/lib/models/group_model.dart#L224-L282). + +- **Query:** `current`, `pageSize`, `accessible=` (empty), `status=1` +- **Auth:** Bearer + +**Response page entry — `PeerPayload`** (from +[flutter/lib/common/hbbs/hbbs.dart:77-131](../flutter/lib/common/hbbs/hbbs.dart#L77-L131)): + +```json +{ + "id": "123456789", + "user": "alice", + "user_name": "Alice Doe", + "device_group_name": "Engineering", + "note": "string", + "status": 1, + "info": { + "username": "alice", + "device_name": "ALICE-PC", + "os": "Windows 10 / x64" // first " / "-separated token used + // to determine platform + } +} +``` + +--- + +## 6. Heartbeat & system info + +These three endpoints are the "agent loop". They are **never** sent if the +configured API server matches the public `rustdesk.com` pattern +([src/hbbs_http/sync.rs:276-285](../src/hbbs_http/sync.rs#L276-L285)). + +The loop wakes every 3 s +([`TIME_CONN` in src/hbbs_http/sync.rs:19](../src/hbbs_http/sync.rs#L19)) +but only sends a heartbeat every **15 s** unless connections changed +([`TIME_HEARTBEAT` in src/hbbs_http/sync.rs:17](../src/hbbs_http/sync.rs#L17)). +Sysinfo is re-uploaded at most every **120 s** +([`UPLOAD_SYSINFO_TIMEOUT` in src/hbbs_http/sync.rs:18](../src/hbbs_http/sync.rs#L18)) +and only if hash/version differ. + +### 6.1 `POST /api/heartbeat` + +Source: [src/hbbs_http/sync.rs:235-271](../src/hbbs_http/sync.rs#L235-L271). + +- **Auth:** none +- **Headers:** `Content-Type: application/json` + +**Request body:** + +```json +{ + "id": "123456789", + "uuid": "", + "ver": 123456, // numeric version (hbb_common::get_version_number) + "conns": [101, 102], // omitted if no active connections + "modified_at": 0 // last strategy timestamp the client knows +} +``` + +**Response** — any subset of: + +```json +{ + "sysinfo": "any-truthy-value", // presence forces sysinfo re-upload + "disconnect": [101], // conn IDs the client should drop + "modified_at": 1700000000, // newer timestamp ⇒ persist locally + "strategy": { + "config_options": { "": "", ... }, + "extra": { ... } + } +} +``` + +Any value the client does not recognise is ignored. `strategy.config_options` +is merged into the client's options; an empty value with no built-in default +removes the option, otherwise it overwrites. + +### 6.2 `POST /api/sysinfo` + +The client first probes versions with `/api/sysinfo_ver` (§6.3); if the version +matches what the server already has, this POST is skipped. + +Source: [src/hbbs_http/sync.rs:131-229](../src/hbbs_http/sync.rs#L131-L229). + +- **Auth:** none +- **Headers:** `Content-Type: application/json` + +**Request body** (top-level fields are merged from +[`get_sysinfo()`](../src/common.rs) plus preset options): + +```json +{ + "version": "1.4.x", + "id": "123456789", + "uuid": "", + "username": "alice", // OS username + "hostname": "ALICE-PC", + "os": "Windows 10 / x64", + "cpu": "...", "memory": "...", // and other fields from get_sysinfo + "preset_address_book_name": "...", // optional, only if configured + "preset_address_book_tag": "...", + "preset_address_book_alias": "...", + "preset_address_book_password": "...", + "preset_address_book_note": "...", + "preset_username": "...", + "preset_strategy_name": "...", + "preset_device_group_name": "..." +} +``` + +**Response** — body is treated as a **bare string** (not JSON): + +| Body | Meaning | +|---------------------|---------| +| `SYSINFO_UPDATED` | Success. Client caches a SHA-256 of (URL+body) and the version returned by `/api/sysinfo_ver`. | +| `ID_NOT_FOUND` | Re-upload at next heartbeat tick (no cache). | +| anything else / err | Treated as success-with-deferral (cache still skipped). | + +### 6.3 `POST /api/sysinfo_ver` + +Source: [src/hbbs_http/sync.rs:192-208](../src/hbbs_http/sync.rs#L192-L208). + +- **Auth:** none +- **Body:** empty +- **Response:** an opaque version string. The client compares against its + cached `sysinfo_ver`; if equal **and** the request hash is unchanged, the + full sysinfo upload is skipped this cycle. + +Backends without versioning may always return an empty string; the client will +then upload sysinfo each cycle. + +--- + +## 7. Audit logging + +Audit endpoints are **only** called when the API server is non-public +([`get_audit_server()` in src/common.rs:1119-1125](../src/common.rs#L1119-L1125)). + +All three are POST with `Content-Type: application/json`, **no Authorization +header**, and fire-and-forget (response is ignored beyond logging). They share a +common envelope: + +```json +{ + "id": "", + "uuid": "", + ...endpoint-specific fields +} +``` + +### 7.1 `POST /api/audit/conn` + +Source: [src/server/connection.rs:1248-1279](../src/server/connection.rs#L1248-L1279). + +```json +{ + "id": "...", + "uuid": "...", + "conn_id": 101, + "session_id": 7, + "ip": "192.0.2.10", + "action": "new" +} +``` + +Currently the only `action` emitted by the client is `"new"` (sent immediately +after the remote IP is verified against the IP whitelist). The server response +is ignored, but the dialog flow in §7.4 implies the server returns or stores +a `guid` per audit row. + +### 7.2 `POST /api/audit/file` + +Source: [src/server/connection.rs:1297-1330](../src/server/connection.rs#L1297-L1330). + +```json +{ + "id": "...", + "uuid": "...", + "peer_id": "", + "type": 0, // 0 = RemoteSend, 1 = RemoteReceive + "path": "C:\\path\\to\\dir", + "is_file": false, + "info": "" +} +``` + +`info`, decoded: + +```json +{ + "ip": "192.0.2.10", + "name": "alice (display name)", + "num": 42, // total files in the operation + "files": [ { "name": "big.iso", "size": 4400000000 }, ... ] // top-10 by size +} +``` + +`type` enum from [src/server/connection.rs:5063-5066](../src/server/connection.rs#L5063-L5066): +`0 = RemoteSend`, `1 = RemoteReceive`. `is_file` is `true` only when the +operation is a single file (`files.len() == 1 && files[0].name == ""`). + +### 7.3 `POST /api/audit/alarm` + +Source: [src/server/connection.rs:1332-1349](../src/server/connection.rs#L1332-L1349). + +```json +{ + "id": "...", + "uuid": "...", + "typ": 0, // see enum below + "info": "" +} +``` + +`typ` enum from [src/server/connection.rs:5053-5061](../src/server/connection.rs#L5053-L5061): + +| Value | Constant | Meaning | +|-------|----------------------------|----------------------------------------| +| 0 | `IpWhitelist` | Connection rejected by IP whitelist. | +| 1 | `ExceedThirtyAttempts` | >30 password attempts. | +| 2 | `SixAttemptsWithinOneMinute` | 6 password attempts in 60 s. | +| 6 | `ExceedIPv6PrefixAttempts` | Per-/64 IPv6 attempt ceiling exceeded. | + +(Values 3–5 are reserved / commented out.) + +### 7.4 `PUT /api/audit` + +Update an existing connection-audit row (used by the "leave a note at end of +session" dialog). + +- **Source:** [flutter/lib/common/widgets/dialog.dart:1656-1687](../flutter/lib/common/widgets/dialog.dart#L1656-L1687) +- **Auth:** Bearer +- **Headers:** `Content-Type: application/json` + +```json +{ + "guid": "", + "note": "free-form text" +} +``` + +A 200 status is treated as success; non-200 is logged and discarded. + +--- + +## 8. Session recording upload + +Used when session-recording-on-the-server is enabled. All requests share the +endpoint `POST /api/record` and disambiguate via the `type` query parameter. + +- **Source:** [src/hbbs_http/record_upload.rs](../src/hbbs_http/record_upload.rs) +- **Auth:** none +- **Content-Type:** the body is raw `application/octet-stream`-style bytes + (the client uses `reqwest`'s default for `Bytes`/`Vec` — no explicit + `Content-Type` header). Servers should accept any. +- **Send cadence:** at most every 1 s, or whenever ≥1 MiB of new data is + available, whichever comes first + ([`SHOULD_SEND_TIME` and `SHOULD_SEND_SIZE` at lines 16-17](../src/hbbs_http/record_upload.rs#L16-L17)). + +**Per-call query parameters:** + +| `type` | `file` | `offset` | `length` | Body | When sent | +|----------|--------|-----------------------|---------------------|--------------------------------------|--------------------------| +| `new` | yes | — | — | empty | New recording starts. | +| `part` | yes | byte offset (decimal) | bytes to follow | raw chunk | Periodic upload. | +| `tail` | yes | `0` | header length (≤1024) | first ≤1024 bytes of file | Recording finished; uploaded after final `part`. | +| `remove` | yes | — | — | empty | Recording aborted. | + +`file` is the basename, e.g. `2025-04-12_14-22-01.mp4`. + +**Response:** any JSON object. If it contains `{"error": "..."}`, the client +aborts the recording session and logs the error. + +--- + +## 9. Generic file download + +Used by the auto-update / installer-fetch path +([src/hbbs_http/downloader.rs](../src/hbbs_http/downloader.rs)). + +- The URL is **arbitrary**; not necessarily on the API server. Path-format + agnostic. +- The client first sends `HEAD` to read `Content-Length`, then `GET` to stream + the body. Both calls go through the same TLS-fallback machinery as the API + client. +- The server **MUST** return `Content-Length` on the HEAD response; without it + the download is aborted with `"Failed to get content length"`. +- Streamed responses (chunked transfer-encoding) are fine for the GET. + +--- + +## 10. Plugin signature service + +For installations that ship signed plugins. + +### `POST /lic/web/api/plugin-sign` + +- **Source:** [src/plugin/callback_msg.rs:282-296](../src/plugin/callback_msg.rs#L282-L296) +- **Auth:** none +- **Headers:** `Content-Type: application/json` (set automatically by `.json()`) +- **Timeout:** 10 s + +**Request body** (`PluginSignReq` at +[src/plugin/callback_msg.rs:82-87](../src/plugin/callback_msg.rs#L82-L87)): + +```json +{ + "plugin_id": "string", + "version": "string", + "msg": [/* byte array; serde will encode Vec as JSON array of u8 */] +} +``` + +**Response** (`PluginSignResp`): + +```json +{ "signed_msg": [/* byte array */] } +``` + +The bytes are passed verbatim into the plugin's +`handle_signature_verification` entry point. + +--- + +## 11. CLI device assignment (`--assign`) + +Triggered from a terminal: `rustdesk --assign --token [...]`. Used by +mass-deploy scripts to register a freshly-installed agent into a tenant. + +### `POST /api/devices/cli` + +- **Source:** [src/core_main.rs:519-616](../src/core_main.rs#L519-L616) +- **Auth:** `Authorization: Bearer ` (passed via `--token`) +- **Headers:** `Content-Type: application/json` + +**Request body** (only `id`/`uuid` are mandatory; at least one of the optional +fields must be present, see CLI help): + +```json +{ + "id": "", + "uuid": "", + "user_name": "...", // optional + "strategy_name": "...", // optional + "address_book_name": "...", // optional + "address_book_tag": "...", // optional + "address_book_alias": "...", // optional + "address_book_password": "...", // optional + "address_book_note": "...", // optional + "device_group_name": "...", // optional + "note": "...", // optional + "device_username": "...", // optional + "device_name": "..." // optional +} +``` + +**Response:** plain text. Empty body ⇒ `Done!` is printed; otherwise the body +is printed verbatim to stdout. No structured error contract. + +--- + +## 12. Endpoint index + +| # | Method | Path | Auth | Notes | +|---|--------|------------------------------------|--------|----------------------------------------------| +| 1 | GET | `/api/login-options` | none | Probe + SSO list | +| 2 | POST | `/api/login` | none | Username/password, 2FA, SSO completion | +| 3 | POST | `/api/currentUser` | Bearer | Refresh profile | +| 4 | POST | `/api/logout` | Bearer | Best-effort | +| 5 | POST | `/api/oidc/auth` | none | Begin device-flow | +| 6 | GET | `/api/oidc/auth-query` | none | Poll device-flow | +| 7 | POST | `/api/ab/settings` | Bearer | `max_peer_one_ab` | +| 8 | POST | `/api/ab/personal` | Bearer | Personal AB GUID; 404 ⇒ legacy mode | +| 9 | POST | `/api/ab/shared/profiles` | Bearer | Paginated AB list | +|10 | POST | `/api/ab/peers?ab=` | Bearer | Paginated peer list | +|11 | POST | `/api/ab/tags/{guid}` | Bearer | All tags | +|12 | POST | `/api/ab/peer/add/{guid}` | Bearer | Add one peer | +|13 | PUT | `/api/ab/peer/update/{guid}` | Bearer | Partial update of one peer | +|14 | DELETE | `/api/ab/peer/{guid}` | Bearer | Bulk delete by ID list | +|15 | POST | `/api/ab/tag/add/{guid}` | Bearer | Add one tag | +|16 | PUT | `/api/ab/tag/rename/{guid}` | Bearer | Rename | +|17 | PUT | `/api/ab/tag/update/{guid}` | Bearer | Set color | +|18 | DELETE | `/api/ab/tag/{guid}` | Bearer | Bulk delete by name list | +|19 | GET | `/api/ab` | Bearer | Legacy AB blob (gzip) | +|20 | POST | `/api/ab` | Bearer | Legacy AB blob save | +|21 | GET | `/api/device-group/accessible` | Bearer | Paginated; errors silently tolerated | +|22 | GET | `/api/users` | Bearer | Paginated, `accessible=&status=1` | +|23 | GET | `/api/peers` | Bearer | Paginated, `accessible=&status=1` | +|24 | POST | `/api/heartbeat` | none | Every 15 s (3 s when active) | +|25 | POST | `/api/sysinfo_ver` | none | Cache probe | +|26 | POST | `/api/sysinfo` | none | Bare-string response | +|27 | POST | `/api/audit/conn` | none | Connection start | +|28 | POST | `/api/audit/file` | none | File transfer summary | +|29 | POST | `/api/audit/alarm` | none | Security alarms | +|30 | PUT | `/api/audit` | Bearer | Update note on a conn audit row | +|31 | POST | `/api/record` | none | `?type=new\|part\|tail\|remove&file=&offset=&length=` | +|32 | POST | `/api/devices/cli` | Bearer | Used by `rustdesk --assign` | +|33 | POST | `/lic/web/api/plugin-sign` | none | Plugin signature | +|34 | HEAD | (configured `api-server`) probe | none | Performed once on startup against `/api/login-options` | +|35 | HEAD+GET | (arbitrary URL) | none | Generic downloader; HEAD must return `Content-Length` | + +--- + +## 13. Minimum viable backend + +To stand up a backend that a stock RustDesk client can use end-to-end, in +priority order: + +1. **Connectivity probe** — answer `HEAD /api/login-options` with `200 OK`. +2. **Auth core** — implement `GET /api/login-options`, `POST /api/login`, + `POST /api/logout`, `POST /api/currentUser`. Return `access_token` on + successful login. +3. **Heartbeat & sysinfo** — `POST /api/heartbeat`, `POST /api/sysinfo`, + `POST /api/sysinfo_ver`. Without these the agent loop logs errors but + continues; with them, the server has live device tracking. +4. **Address book** — pick one mode: + - *Modern*: `POST /api/ab/settings`, `POST /api/ab/personal`, + `POST /api/ab/shared/profiles`, `POST /api/ab/peers`, + `POST /api/ab/tags/{guid}`, plus the per-peer / per-tag mutation set + (§4.6, §4.7). + - *Legacy*: respond `404` to `/api/ab/personal`, then implement + `GET /api/ab` + `POST /api/ab` (§4.8). +5. **Group view** — `GET /api/users`, `GET /api/peers`, + `GET /api/device-group/accessible`. Without these the device-tab is empty. +6. **Audit** — implement the four audit routes (§7) only if you want + server-side logging; the client tolerates 4xx/5xx silently. +7. **Optional**: `/api/record`, `/api/devices/cli`, `/api/oidc/*`, + `/lic/web/api/plugin-sign`. + +A backend that returns `{ "error": "..." }` with a clear message on any +unsupported endpoint will produce reasonable UX in the client.