From b14a8cf1620349def83355bf73ca7ec522cc5378 Mon Sep 17 00:00:00 2001 From: UNITRONIX <36471318+UNITRONIX@users.noreply.github.com> Date: Fri, 6 Feb 2026 23:34:35 +0100 Subject: [PATCH] Add CI, build scripts, docs, and dev utilities Add a full automated build pipeline, interactive build scripts, documentation and development utilities. - Add GitHub Actions workflow (.github/workflows/build.yml) to build BetterDesk (hbbs/hbbr) for Linux x64, Linux ARM64 and Windows x64, upload artifacts, optionally create a release and update repository binaries. - Add interactive build scripts for Windows and Unix (build-betterdesk.ps1, build-betterdesk.sh) supporting download, patch application, cross-build and checksum generation. - Add build and status docs (docs/BUILD_GUIDE.md, docs/STATUS_TRACKING_v3.md) and update project README/instructions (.github/copilot-instructions.md) with new items and updated timestamp. - Add development utilities under dev_modules/ (multiple database/patch/test helpers) and move test_generator.py into dev_modules/test_generator.py. - Update .gitignore to exclude archive/ folder. - Modify hbbs-patch-v2 sources (database.rs, http_api.rs, main.rs, peer.rs and add rendezvous_server.rs) and remove legacy fixed files; update web UI backend and static files (web/app.py, web/static/*, web/templates/index.html). These changes set up CI/CD for multi-platform builds, provide local build tooling, expand docs for v3 status tracking and ID-change features, and add developer test/patch utilities. --- .github/copilot-instructions.md | 141 +- .github/workflows/build.yml | 362 +++++ .gitignore | 3 + build-betterdesk.ps1 | 482 ++++++ build-betterdesk.sh | 548 +++++++ dev_modules/README.md | 45 +- dev_modules/fix_database.py | 109 ++ dev_modules/patch_database_simple.py | 40 + dev_modules/patch_id_change.py | 75 + dev_modules/patch_peer_remove.py | 30 + dev_modules/patch_rendezvous.py | 22 + dev_modules/test_change_id.py | 31 + .../test_generator.py | 0 docs/BUILD_GUIDE.md | 302 ++++ DOCKER_SUPPORT.md => docs/DOCKER_SUPPORT.md | 0 .../DOCKER_TROUBLESHOOTING.md | 0 docs/README.md | 45 +- docs/STATUS_TRACKING_v3.md | 258 ++++ hbbs-patch-v2/src/database.rs | 437 ++---- hbbs-patch-v2/src/database_fixed.rs | 263 ---- hbbs-patch-v2/src/http_api.rs | 205 ++- hbbs-patch-v2/src/main.rs | 13 +- hbbs-patch-v2/src/peer.rs | 416 +++--- hbbs-patch-v2/src/peer_fixed.rs | 335 ----- hbbs-patch-v2/src/rendezvous_server.rs | 1327 +++++++++++++++++ web/app.py | 272 +++- web/static/script.js | 206 ++- web/static/style.css | 41 + web/templates/index.html | 49 +- 29 files changed, 4833 insertions(+), 1224 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 build-betterdesk.ps1 create mode 100644 build-betterdesk.sh create mode 100644 dev_modules/fix_database.py create mode 100644 dev_modules/patch_database_simple.py create mode 100644 dev_modules/patch_id_change.py create mode 100644 dev_modules/patch_peer_remove.py create mode 100644 dev_modules/patch_rendezvous.py create mode 100644 dev_modules/test_change_id.py rename test_generator.py => dev_modules/test_generator.py (100%) create mode 100644 docs/BUILD_GUIDE.md rename DOCKER_SUPPORT.md => docs/DOCKER_SUPPORT.md (100%) rename DOCKER_TROUBLESHOOTING.md => docs/DOCKER_TROUBLESHOOTING.md (100%) create mode 100644 docs/STATUS_TRACKING_v3.md delete mode 100644 hbbs-patch-v2/src/database_fixed.rs delete mode 100644 hbbs-patch-v2/src/peer_fixed.rs create mode 100644 hbbs-patch-v2/src/rendezvous_server.rs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b36d742e..13f42164 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -5,7 +5,7 @@ --- -## πŸ“Š Stan Projektu (aktualizacja: 2026-02-06) +## πŸ“Š Stan Projektu (aktualizacja: 2026-02-07) ### Wersja InstalatorΓ³w @@ -84,6 +84,9 @@ Rustdesk-FreeConsole/ β”‚ β”œβ”€β”€ hbbs-windows-x86_64.exe # Signal server Windows β”‚ β”œβ”€β”€ hbbr-windows-x86_64.exe # Relay server Windows β”‚ └── src/ # Source code for modifications +β”œβ”€β”€ docs/ # Documentation (English) +β”œβ”€β”€ dev_modules/ # Development & testing utilities +β”œβ”€β”€ archive/ # Archived files (not in git) β”œβ”€β”€ Dockerfile.* # Docker images β”œβ”€β”€ docker-compose.yml # Docker orchestration └── migrations/ # Database migrations @@ -163,30 +166,128 @@ sudo apt-get install -y build-essential libsqlite3-dev pkg-config libssl-dev git 13. [x] **Naprawiono DNS issues** - dodano fallback DNS w Dockerfile.console (AlmaLinux/CentOS) 14. [x] Zaktualizowano DOCKER_TROUBLESHOOTING.md z nowymi rozwiΔ…zaniami +### βœ… UkoΕ„czone (2026-02-07) +15. [x] **Stworzono build-betterdesk.sh** - interaktywny skrypt do kompilacji (Linux/macOS) +16. [x] **Stworzono build-betterdesk.ps1** - interaktywny skrypt do kompilacji (Windows) +17. [x] **Stworzono GitHub Actions workflow** - automatyczna kompilacja multi-platform (.github/workflows/build.yml) +18. [x] **Stworzono BUILD_GUIDE.md** - dokumentacja budowania ze ΕΊrΓ³deΕ‚ +19. [x] **System statusu v3.0** - konfigurowalny timeout, nowe statusy (Online/Degraded/Critical/Offline) +20. [x] **Nowe endpointy API** - /api/config, /api/peers/stats, /api/server/stats +21. [x] **Dokumentacja v3.0** - STATUS_TRACKING_v3.md +22. [x] **Zmiana ID urzΔ…dzenia** - moduΕ‚ id_change.rs, endpoint POST /api/peers/:id/change-id +23. [x] **Dokumentacja ID Change** - docs/ID_CHANGE_FEATURE.md + ### πŸ”œ Do Zrobienia (priorytety) -1. [ ] **Auto-update workflow** - GitHub Actions do automatycznego pobierania nowej wersji RustDesk i aplikowania patchy -2. [ ] DodaΔ‡ ARM64 binarki dla Linux (Raspberry Pi) -3. [ ] DodaΔ‡ automatyczne CI/CD builds (GitHub Actions) -4. [ ] UlepszyΔ‡ dokumentacjΔ™ instalacji Windows -5. [ ] DodaΔ‡ testy jednostkowe dla HTTP API +1. [ ] Kompilacja binarek v3.0.0 z nowymi plikami ΕΊrΓ³dΕ‚owymi +2. [ ] WebSocket real-time push dla statusu +3. [ ] DodaΔ‡ testy jednostkowe dla HTTP API +4. [ ] Integracja id_change.rs z rendezvous_server_core.rs -### πŸ”„ Planowany Auto-Update Workflow +--- -**Cel:** Automatyczne aktualizowanie bazy RustDesk Server z zachowaniem patchy BetterDesk +## πŸ”„ System Statusu v3.0 -**Proces:** -1. GitHub Actions sprawdza nowe tagi w `rustdesk/rustdesk-server` -2. Klonuje nowΔ… wersjΔ™ i aplikuje patche z `hbbs-patch-v2/src/` -3. PrΓ³buje skompilowaΔ‡ (`cargo build --release`) -4. Uruchamia testy funkcjonalne API -5. JeΕ›li sukces β†’ tworzy PR z nowΔ… wersjΔ… -6. JeΕ›li bΕ‚Δ…d β†’ tworzy Issue z logiem bΕ‚Δ™dΓ³w +### Nowe Pliki ΕΉrΓ³dΕ‚owe -**Pliki do aplikowania:** -- `src/main.rs` - dodaje `--api-port` i uruchamia HTTP API -- `src/http_api.rs` - caΕ‚y moduΕ‚ HTTP API (nowy plik) +| Plik | Opis | +|------|------| +| `peer_v3.rs` | Ulepszony system statusu z konfigurowalnymi timeoutami | +| `database_v3.rs` | Rozszerzona baza danych z server_config | +| `http_api_v3.rs` | Nowe endpointy API dla konfiguracji | -**Ryzyko:** Zmiany w API axum/sqlx miΔ™dzy wersjami RustDesk +### Konfiguracja przez Zmienne Środowiskowe + +```bash +PEER_TIMEOUT_SECS=15 # Timeout dla offline (domyΕ›lnie 15s) +HEARTBEAT_INTERVAL_SECS=3 # InterwaΕ‚ sprawdzania (domyΕ›lnie 3s) +HEARTBEAT_WARNING_THRESHOLD=2 # PrΓ³g dla DEGRADED +HEARTBEAT_CRITICAL_THRESHOLD=4 # PrΓ³g dla CRITICAL +``` + +### Nowe Statusy UrzΔ…dzeΕ„ + +``` +ONLINE β†’ Wszystko OK +DEGRADED β†’ 2-3 pominiΔ™te heartbeaty +CRITICAL β†’ 4+ pominiΔ™te, wkrΓ³tce offline +OFFLINE β†’ Przekroczony timeout +``` + +### Dokumentacja + +PeΕ‚na dokumentacja: [docs/STATUS_TRACKING_v3.md](docs/STATUS_TRACKING_v3.md) + +--- + +## οΏ½ Zmiana ID UrzΔ…dzenia + +### Endpoint API + +``` +POST /api/peers/:old_id/change-id +Content-Type: application/json +X-API-Key: + +{ "new_id": "NEWID123" } +``` + +### Pliki ΕΉrΓ³dΕ‚owe + +| Plik | Opis | +|------|------| +| `id_change.rs` | ModuΕ‚ obsΕ‚ugi zmiany ID przez protokΓ³Ε‚ klienta | +| `database_v3.rs` | Funkcje `change_peer_id()`, `get_peer_id_history()` | +| `http_api_v3.rs` | Endpoint POST `/api/peers/:id/change-id` | + +### Walidacja + +- **DΕ‚ugoΕ›Δ‡ ID**: 6-16 znakΓ³w +- **Dozwolone znaki**: A-Z, 0-9, `-`, `_` +- **UnikatowoΕ›Δ‡**: Nowe ID nie moΕΌe byΔ‡ zajΔ™te +- **Rate limiting** (klient): 5 min cooldown + +### Dokumentacja + +PeΕ‚na dokumentacja: [docs/ID_CHANGE_FEATURE.md](docs/ID_CHANGE_FEATURE.md) + +--- + +## οΏ½πŸ”¨ Skrypty Budowania + +### Interaktywne skrypty kompilacji + +| Skrypt | Platforma | Opis | +|--------|-----------|------| +| `build-betterdesk.sh` | Linux/macOS | Interaktywny build z wyborem wersji/platformy | +| `build-betterdesk.ps1` | Windows | Interaktywny build PowerShell | + +### UΕΌycie + +```bash +# Linux - tryb interaktywny +./build-betterdesk.sh + +# Linux - tryb automatyczny +./build-betterdesk.sh --auto + +# Windows - tryb interaktywny +.\build-betterdesk.ps1 + +# Windows - tryb automatyczny +.\build-betterdesk.ps1 -Auto +``` + +### GitHub Actions CI/CD + +Workflow `.github/workflows/build.yml` automatycznie: +- Buduje binarki dla Linux x64, Linux ARM64, Windows x64 +- Uruchamia siΔ™ przy zmianach w `hbbs-patch-v2/src/**` +- Pozwala na rΔ™czne uruchomienie z wyborem wersji +- Opcjonalnie tworzy GitHub Release + +### Dokumentacja + +PeΕ‚na dokumentacja budowania: [docs/BUILD_GUIDE.md](docs/BUILD_GUIDE.md) --- @@ -227,4 +328,4 @@ sudo apt-get install -y build-essential libsqlite3-dev pkg-config libssl-dev git --- -*Ostatnia aktualizacja: 2026-02-04 przez GitHub Copilot* +*Ostatnia aktualizacja: 2026-02-07 przez GitHub Copilot* diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..19de3f3c --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,362 @@ +# ============================================================================= +# BetterDesk Server - Automated Build Pipeline +# ============================================================================= +# This workflow builds BetterDesk enhanced binaries for multiple platforms. +# +# Triggers: +# - Push to 'main' with changes to hbbs-patch-v2/src/** +# - Manual dispatch with version selection +# - Pull requests for testing (no artifact upload) +# +# Platforms: +# - Linux x86_64 +# - Linux aarch64 (ARM64) +# - Windows x86_64 +# ============================================================================= + +name: Build BetterDesk Binaries + +on: + # Manual trigger with version selection + workflow_dispatch: + inputs: + rustdesk_version: + description: 'RustDesk Server version to build from' + required: true + default: '1.1.14' + type: string + upload_release: + description: 'Upload as GitHub Release' + required: false + default: false + type: boolean + + # Auto-trigger on source changes + push: + branches: [main] + paths: + - 'hbbs-patch-v2/src/**' + - '.github/workflows/build.yml' + + # PR testing + pull_request: + branches: [main] + paths: + - 'hbbs-patch-v2/src/**' + +env: + RUSTDESK_VERSION: ${{ github.event.inputs.rustdesk_version || '1.1.14' }} + BETTERDESK_VERSION: '2.0.0' + CARGO_TERM_COLOR: always + +jobs: + # ========================================================================== + # Linux x86_64 Build + # ========================================================================== + build-linux-x64: + runs-on: ubuntu-latest + steps: + - name: Checkout BetterDesk + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y build-essential libsqlite3-dev pkg-config libssl-dev + + - name: Setup Rust + uses: dtolnay/rust-action@stable + with: + toolchain: stable + + - name: Clone RustDesk Server + run: | + git clone --depth 1 --branch ${{ env.RUSTDESK_VERSION }} https://github.com/rustdesk/rustdesk-server.git + cd rustdesk-server + git submodule update --init --recursive + + - name: Apply BetterDesk modifications + run: | + # Copy all modification files + for file in main.rs http_api.rs database.rs database_fixed.rs peer.rs peer_fixed.rs rendezvous_server_core.rs; do + if [ -f "hbbs-patch-v2/src/$file" ]; then + cp "hbbs-patch-v2/src/$file" "rustdesk-server/src/$file" + echo "Applied: $file" + fi + done + + - name: Build binaries + working-directory: rustdesk-server + run: | + cargo build --release -p hbbs + cargo build --release -p hbbr + + - name: Prepare artifacts + run: | + mkdir -p artifacts + cp rustdesk-server/target/release/hbbs artifacts/hbbs-linux-x86_64 + cp rustdesk-server/target/release/hbbr artifacts/hbbr-linux-x86_64 + chmod +x artifacts/* + + # Generate checksums + cd artifacts + sha256sum * > SHA256SUMS-linux-x86_64.txt + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: betterdesk-linux-x86_64 + path: artifacts/* + retention-days: 30 + + # ========================================================================== + # Linux ARM64 Build + # ========================================================================== + build-linux-arm64: + runs-on: ubuntu-latest + steps: + - name: Checkout BetterDesk + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y build-essential pkg-config libssl-dev + sudo apt-get install -y gcc-aarch64-linux-gnu + + - name: Setup Rust + uses: dtolnay/rust-action@stable + with: + toolchain: stable + targets: aarch64-unknown-linux-gnu + + - name: Clone RustDesk Server + run: | + git clone --depth 1 --branch ${{ env.RUSTDESK_VERSION }} https://github.com/rustdesk/rustdesk-server.git + cd rustdesk-server + git submodule update --init --recursive + + - name: Apply BetterDesk modifications + run: | + for file in main.rs http_api.rs database.rs database_fixed.rs peer.rs peer_fixed.rs rendezvous_server_core.rs; do + if [ -f "hbbs-patch-v2/src/$file" ]; then + cp "hbbs-patch-v2/src/$file" "rustdesk-server/src/$file" + echo "Applied: $file" + fi + done + + - name: Configure cross-compilation + run: | + mkdir -p .cargo + cat >> rustdesk-server/.cargo/config.toml << EOF + [target.aarch64-unknown-linux-gnu] + linker = "aarch64-linux-gnu-gcc" + EOF + + - name: Build binaries + working-directory: rustdesk-server + env: + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc + run: | + cargo build --release --target aarch64-unknown-linux-gnu -p hbbs + cargo build --release --target aarch64-unknown-linux-gnu -p hbbr + + - name: Prepare artifacts + run: | + mkdir -p artifacts + cp rustdesk-server/target/aarch64-unknown-linux-gnu/release/hbbs artifacts/hbbs-linux-aarch64 + cp rustdesk-server/target/aarch64-unknown-linux-gnu/release/hbbr artifacts/hbbr-linux-aarch64 + chmod +x artifacts/* + + cd artifacts + sha256sum * > SHA256SUMS-linux-aarch64.txt + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: betterdesk-linux-aarch64 + path: artifacts/* + retention-days: 30 + + # ========================================================================== + # Windows x86_64 Build + # ========================================================================== + build-windows-x64: + runs-on: windows-latest + steps: + - name: Checkout BetterDesk + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-action@stable + with: + toolchain: stable + + - name: Clone RustDesk Server + run: | + git clone --depth 1 --branch ${{ env.RUSTDESK_VERSION }} https://github.com/rustdesk/rustdesk-server.git + cd rustdesk-server + git submodule update --init --recursive + + - name: Apply BetterDesk modifications + shell: pwsh + run: | + $files = @("main.rs", "http_api.rs", "database.rs", "database_fixed.rs", "peer.rs", "peer_fixed.rs", "rendezvous_server_core.rs") + foreach ($file in $files) { + $src = "hbbs-patch-v2/src/$file" + if (Test-Path $src) { + Copy-Item $src "rustdesk-server/src/$file" -Force + Write-Host "Applied: $file" + } + } + + - name: Build binaries + working-directory: rustdesk-server + run: | + cargo build --release -p hbbs + cargo build --release -p hbbr + + - name: Prepare artifacts + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path artifacts + Copy-Item rustdesk-server/target/release/hbbs.exe artifacts/hbbs-windows-x86_64.exe + Copy-Item rustdesk-server/target/release/hbbr.exe artifacts/hbbr-windows-x86_64.exe + + # Generate checksums + cd artifacts + Get-ChildItem -File | ForEach-Object { + $hash = (Get-FileHash $_.Name -Algorithm SHA256).Hash + "$hash $($_.Name)" | Out-File -Append SHA256SUMS-windows-x86_64.txt + } + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: betterdesk-windows-x86_64 + path: artifacts/* + retention-days: 30 + + # ========================================================================== + # Create Release (optional) + # ========================================================================== + create-release: + needs: [build-linux-x64, build-linux-arm64, build-windows-x64] + runs-on: ubuntu-latest + if: github.event.inputs.upload_release == 'true' + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: all-artifacts + + - name: Prepare release files + run: | + mkdir -p release + + # Flatten all artifacts + find all-artifacts -type f -exec cp {} release/ \; + + # Generate combined checksums + cd release + cat SHA256SUMS-*.txt > SHA256SUMS.txt + rm SHA256SUMS-*.txt + + ls -la + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + tag_name: v${{ env.BETTERDESK_VERSION }}-${{ github.run_number }} + name: BetterDesk v${{ env.BETTERDESK_VERSION }} (Build ${{ github.run_number }}) + body: | + ## BetterDesk Server Binaries + + Based on RustDesk Server v${{ env.RUSTDESK_VERSION }} + + ### Downloads + + | Platform | Signal Server (hbbs) | Relay Server (hbbr) | + |----------|---------------------|---------------------| + | Linux x86_64 | `hbbs-linux-x86_64` | `hbbr-linux-x86_64` | + | Linux ARM64 | `hbbs-linux-aarch64` | `hbbr-linux-aarch64` | + | Windows | `hbbs-windows-x86_64.exe` | `hbbr-windows-x86_64.exe` | + + ### Installation + + **Linux:** + ```bash + chmod +x hbbs-linux-x86_64 hbbr-linux-x86_64 + ./hbbs-linux-x86_64 -k _ --api-port 21114 + ./hbbr-linux-x86_64 + ``` + + **Windows:** + ```powershell + .\hbbs-windows-x86_64.exe -k _ --api-port 21114 + .\hbbr-windows-x86_64.exe + ``` + + See [README.md](https://github.com/${{ github.repository }}/blob/main/README.md) for full installation instructions. + + files: release/* + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # ========================================================================== + # Update Repository Binaries + # ========================================================================== + update-repo-binaries: + needs: [build-linux-x64, build-windows-x64] + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Download Linux x64 artifacts + uses: actions/download-artifact@v4 + with: + name: betterdesk-linux-x86_64 + path: new-binaries/linux-x64 + + - name: Download Windows artifacts + uses: actions/download-artifact@v4 + with: + name: betterdesk-windows-x86_64 + path: new-binaries/windows + + - name: Update repository binaries + run: | + # Update Linux binaries + cp new-binaries/linux-x64/hbbs-linux-x86_64 hbbs-patch-v2/ + cp new-binaries/linux-x64/hbbr-linux-x86_64 hbbs-patch-v2/ + chmod +x hbbs-patch-v2/hbbs-linux-x86_64 hbbs-patch-v2/hbbr-linux-x86_64 + + # Update Windows binaries + cp new-binaries/windows/hbbs-windows-x86_64.exe hbbs-patch-v2/ + cp new-binaries/windows/hbbr-windows-x86_64.exe hbbs-patch-v2/ + + # Update checksums + cd hbbs-patch-v2 + echo "# BetterDesk Binary Checksums" > CHECKSUMS.md + echo "" >> CHECKSUMS.md + echo "Generated: $(date -u '+%Y-%m-%d %H:%M:%S UTC')" >> CHECKSUMS.md + echo "RustDesk Base: ${{ env.RUSTDESK_VERSION }}" >> CHECKSUMS.md + echo "Build: #${{ github.run_number }}" >> CHECKSUMS.md + echo "" >> CHECKSUMS.md + echo "\`\`\`" >> CHECKSUMS.md + sha256sum hbbs-* hbbr-* 2>/dev/null | grep -v ".md" >> CHECKSUMS.md + echo "\`\`\`" >> CHECKSUMS.md + + - name: Commit changes + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add hbbs-patch-v2/ + git diff --staged --quiet || git commit -m "chore: Update BetterDesk binaries (Build #${{ github.run_number }})" + git push diff --git a/.gitignore b/.gitignore index 486cd73e..0ab62dbb 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,9 @@ test_*.sh verify_*.sh .security_scan_report.md +# Archive folder (contains old files with potential sensitive data) +archive/ + # Backups *.backup *backup*/ diff --git a/build-betterdesk.ps1 b/build-betterdesk.ps1 new file mode 100644 index 00000000..c948bb02 --- /dev/null +++ b/build-betterdesk.ps1 @@ -0,0 +1,482 @@ +# ============================================================================= +# BetterDesk Server - Interactive Build Script (Windows) +# ============================================================================= +# This script automates building BetterDesk enhanced binaries from source. +# It handles downloading RustDesk sources, applying BetterDesk modifications, +# and compiling the final binaries. +# +# Usage: +# .\build-betterdesk.ps1 # Interactive mode +# .\build-betterdesk.ps1 -Auto # Non-interactive (use defaults) +# .\build-betterdesk.ps1 -Clean # Clean build directory +# .\build-betterdesk.ps1 -Help # Show help +# +# Requirements: +# - Rust toolchain (rustup) +# - Visual Studio Build Tools with C++ support +# - Git +# ============================================================================= + +param( + [switch]$Auto, + [switch]$Clean, + [string]$Version = "", + [string]$Platform = "", + [switch]$Help +) + +$ErrorActionPreference = "Stop" + +# Configuration +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$BuildDir = Join-Path $ScriptDir "build" +$PatchesDir = Join-Path $ScriptDir "hbbs-patch-v2\src" +$OutputDir = Join-Path $ScriptDir "hbbs-patch-v2" + +# Default RustDesk version +$DefaultRustDeskVersion = "1.1.14" +$RustDeskRepo = "https://github.com/rustdesk/rustdesk-server.git" + +# State +$RustDeskVersion = $DefaultRustDeskVersion +$TargetPlatform = "windows-x64" + +# ============================================================================= +# Helper Functions +# ============================================================================= + +function Write-Header { + param([string]$Message) + Write-Host "" + Write-Host "╔══════════════════════════════════════════════════════════╗" -ForegroundColor Cyan + Write-Host "β•‘ $Message" -ForegroundColor Cyan + Write-Host "β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•" -ForegroundColor Cyan + Write-Host "" +} + +function Write-Success { param([string]$Msg) Write-Host "βœ“ $Msg" -ForegroundColor Green } +function Write-Error2 { param([string]$Msg) Write-Host "βœ— $Msg" -ForegroundColor Red } +function Write-Warning2 { param([string]$Msg) Write-Host "⚠ $Msg" -ForegroundColor Yellow } +function Write-Info { param([string]$Msg) Write-Host "β„Ή $Msg" -ForegroundColor Blue } +function Write-Step { param([string]$Msg) Write-Host "β†’ $Msg" -ForegroundColor Cyan } + +function Show-Help { + Write-Host "BetterDesk Server - Build Script (Windows)" + Write-Host "" + Write-Host "Usage: .\build-betterdesk.ps1 [OPTIONS]" + Write-Host "" + Write-Host "Options:" + Write-Host " -Auto Non-interactive mode (use default settings)" + Write-Host " -Clean Clean build directory and exit" + Write-Host " -Version VER Specify RustDesk version (default: $DefaultRustDeskVersion)" + Write-Host " -Platform PLT Target platform: windows-x64, linux-x64" + Write-Host " -Help Show this help message" + Write-Host "" + Write-Host "Examples:" + Write-Host " .\build-betterdesk.ps1 # Interactive build" + Write-Host " .\build-betterdesk.ps1 -Auto # Build with defaults" + Write-Host " .\build-betterdesk.ps1 -Version 1.1.15 # Build specific version" + Write-Host "" +} + +# ============================================================================= +# Dependency Checks +# ============================================================================= + +function Test-Dependencies { + Write-Header "Checking Dependencies" + + $missing = 0 + + # Check Rust + $cargo = Get-Command cargo -ErrorAction SilentlyContinue + if ($cargo) { + $rustVersion = & rustc --version 2>&1 + Write-Success "Rust/Cargo: $rustVersion" + } else { + Write-Error2 "Rust/Cargo not found" + Write-Host " Install from: https://rustup.rs/" + $missing++ + } + + # Check Git + $git = Get-Command git -ErrorAction SilentlyContinue + if ($git) { + $gitVersion = & git --version 2>&1 + Write-Success "Git: $gitVersion" + } else { + Write-Error2 "Git not found" + Write-Host " Install from: https://git-scm.com/" + $missing++ + } + + # Check Visual Studio Build Tools + $vsWhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + if (Test-Path $vsWhere) { + $vsPath = & $vsWhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath 2>&1 + if ($vsPath) { + Write-Success "Visual Studio Build Tools found" + } else { + Write-Warning2 "Visual Studio C++ Build Tools may not be installed" + Write-Host " Install from: https://visualstudio.microsoft.com/visual-cpp-build-tools/" + } + } else { + Write-Warning2 "Could not verify Visual Studio Build Tools" + } + + if ($missing -gt 0) { + Write-Host "" + Write-Error2 "Missing dependencies. Please install them first." + Write-Host "" + Write-Host "Required:" + Write-Host " 1. Rust: https://rustup.rs/" + Write-Host " 2. Git: https://git-scm.com/" + Write-Host " 3. Visual Studio Build Tools with C++ support" + Write-Host "" + exit 1 + } + + Write-Success "All dependencies satisfied!" +} + +# ============================================================================= +# Interactive Configuration +# ============================================================================= + +function Get-Configuration { + Write-Header "Build Configuration" + + # Select RustDesk version + Write-Host "Available RustDesk versions:" -ForegroundColor White + Write-Host " 1) 1.1.14 (stable, recommended)" + Write-Host " 2) 1.1.13 (older stable)" + Write-Host " 3) Custom (enter version)" + Write-Host "" + + if ($Auto) { + $script:RustDeskVersion = $DefaultRustDeskVersion + Write-Info "Auto mode: Using version $RustDeskVersion" + } elseif ($Version) { + $script:RustDeskVersion = $Version + } else { + $choice = Read-Host "Select version [1]" + switch ($choice) { + "2" { $script:RustDeskVersion = "1.1.13" } + "3" { $script:RustDeskVersion = Read-Host "Enter version (e.g., 1.1.15)" } + default { $script:RustDeskVersion = $DefaultRustDeskVersion } + } + } + + Write-Success "Selected RustDesk version: $RustDeskVersion" + Write-Host "" + + # Select target platform + Write-Host "Target platform:" -ForegroundColor White + Write-Host " 1) Windows x86_64 (native)" + Write-Host " 2) Linux x86_64 (cross-compile, requires WSL)" + Write-Host "" + + if ($Auto) { + $script:TargetPlatform = "windows-x64" + Write-Info "Auto mode: Building for $TargetPlatform" + } elseif ($Platform) { + $script:TargetPlatform = $Platform + } else { + $choice = Read-Host "Select platform [1]" + switch ($choice) { + "2" { $script:TargetPlatform = "linux-x64" } + default { $script:TargetPlatform = "windows-x64" } + } + } + + Write-Success "Target platform: $TargetPlatform" + Write-Host "" + + # Confirm + if (-not $Auto) { + Write-Host "Build Summary:" -ForegroundColor White + Write-Host " RustDesk Version: $RustDeskVersion" + Write-Host " Target Platform: $TargetPlatform" + Write-Host " Build Directory: $BuildDir" + Write-Host " Output Directory: $OutputDir" + Write-Host "" + $confirm = Read-Host "Continue with build? [Y/n]" + if ($confirm -match "^[Nn]$") { + Write-Host "Build cancelled." + exit 0 + } + } +} + +# ============================================================================= +# Download RustDesk Sources +# ============================================================================= + +function Get-RustDeskSources { + Write-Header "Downloading RustDesk Server Sources" + + $sourceDir = Join-Path $BuildDir "rustdesk-server-$RustDeskVersion" + + if (Test-Path $sourceDir) { + Write-Info "Source directory exists: $sourceDir" + + if (-not $Auto) { + $redownload = Read-Host "Re-download sources? [y/N]" + if ($redownload -notmatch "^[Yy]$") { + Write-Success "Using existing sources" + return + } + } else { + Write-Info "Auto mode: Using existing sources" + return + } + + Remove-Item $sourceDir -Recurse -Force + } + + if (-not (Test-Path $BuildDir)) { + New-Item -ItemType Directory -Path $BuildDir | Out-Null + } + + Push-Location $BuildDir + + Write-Step "Cloning rustdesk-server repository..." + & git clone --depth 1 --branch $RustDeskVersion $RustDeskRepo "rustdesk-server-$RustDeskVersion" + + Set-Location "rustdesk-server-$RustDeskVersion" + + Write-Step "Initializing submodules..." + & git submodule update --init --recursive + + Pop-Location + + Write-Success "RustDesk sources downloaded successfully" +} + +# ============================================================================= +# Apply BetterDesk Modifications +# ============================================================================= + +function Apply-Modifications { + Write-Header "Applying BetterDesk Modifications" + + $sourceDir = Join-Path $BuildDir "rustdesk-server-$RustDeskVersion" + + if (-not (Test-Path $sourceDir)) { + Write-Error2 "Source directory not found: $sourceDir" + exit 1 + } + + Push-Location $sourceDir + + # List of files to copy from patches + $patchFiles = @( + "main.rs", + "http_api.rs", + "database.rs", + "database_fixed.rs", + "peer.rs", + "peer_fixed.rs", + "rendezvous_server_core.rs" + ) + + Write-Step "Copying BetterDesk modifications..." + + foreach ($file in $patchFiles) { + $patchPath = Join-Path $PatchesDir $file + if (Test-Path $patchPath) { + $targetPath = Join-Path "src" $file + Copy-Item $patchPath $targetPath -Force + Write-Success "Applied: $file" + } else { + Write-Warning2 "Patch file not found: $file" + } + } + + Pop-Location + + Write-Success "BetterDesk modifications applied successfully" +} + +# ============================================================================= +# Build Binaries +# ============================================================================= + +function Build-Binaries { + Write-Header "Building BetterDesk Binaries" + + $sourceDir = Join-Path $BuildDir "rustdesk-server-$RustDeskVersion" + Push-Location $sourceDir + + $targetFlag = "" + $binarySuffix = "-windows-x86_64.exe" + + if ($TargetPlatform -eq "linux-x64") { + Write-Step "Setting up Linux cross-compilation..." + & rustup target add x86_64-unknown-linux-gnu + $targetFlag = "--target x86_64-unknown-linux-gnu" + $binarySuffix = "-linux-x86_64" + } + + Write-Step "Building HBBS (Signal Server)..." + if ($targetFlag) { + & cargo build --release $targetFlag -p hbbs + } else { + & cargo build --release -p hbbs + } + + Write-Step "Building HBBR (Relay Server)..." + if ($targetFlag) { + & cargo build --release $targetFlag -p hbbr + } else { + & cargo build --release -p hbbr + } + + # Find binaries + $targetDir = "target\release" + if ($targetFlag) { + $targetDir = "target\$($targetFlag -replace '--target ','')\release" + } + + $hbbsBinary = Join-Path $targetDir "hbbs.exe" + $hbbrBinary = Join-Path $targetDir "hbbr.exe" + + if ($TargetPlatform -eq "linux-x64") { + $hbbsBinary = Join-Path $targetDir "hbbs" + $hbbrBinary = Join-Path $targetDir "hbbr" + } + + if (-not (Test-Path $hbbsBinary) -or -not (Test-Path $hbbrBinary)) { + Write-Error2 "Build failed - binaries not found" + Pop-Location + exit 1 + } + + Write-Success "Build completed successfully!" + + # Copy to output directory + Write-Step "Copying binaries to output directory..." + + $hbbsOutput = Join-Path $OutputDir "hbbs$binarySuffix" + $hbbrOutput = Join-Path $OutputDir "hbbr$binarySuffix" + + Copy-Item $hbbsBinary $hbbsOutput -Force + Copy-Item $hbbrBinary $hbbrOutput -Force + + Pop-Location + + Write-Success "Binaries saved to:" + Write-Host " - $hbbsOutput" + Write-Host " - $hbbrOutput" +} + +# ============================================================================= +# Generate Checksums +# ============================================================================= + +function New-Checksums { + Write-Header "Generating Checksums" + + $checksumsFile = Join-Path $OutputDir "CHECKSUMS.md" + $dateNow = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + + $content = @" +# BetterDesk Server - Binary Checksums + +Generated: $dateNow +RustDesk Base Version: $RustDeskVersion +BetterDesk Version: 2.0.0 + +## SHA256 Checksums + +`````` +"@ + + Get-ChildItem $OutputDir -Filter "hbbs-*" | ForEach-Object { + $hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash + $content += "$hash $($_.Name)`n" + } + + Get-ChildItem $OutputDir -Filter "hbbr-*" | ForEach-Object { + $hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash + $content += "$hash $($_.Name)`n" + } + + $content += "``````" + + $content | Out-File $checksumsFile -Encoding UTF8 + + Write-Success "Checksums saved to: $checksumsFile" +} + +# ============================================================================= +# Clean Build +# ============================================================================= + +function Clear-Build { + Write-Header "Cleaning Build Directory" + + if (Test-Path $BuildDir) { + Write-Step "Removing: $BuildDir" + Remove-Item $BuildDir -Recurse -Force + Write-Success "Build directory cleaned" + } else { + Write-Info "Build directory does not exist" + } +} + +# ============================================================================= +# Main +# ============================================================================= + +function Main { + # Handle help + if ($Help) { + Show-Help + exit 0 + } + + # Handle clean mode + if ($Clean) { + Clear-Build + exit 0 + } + + # Banner + Write-Host "" + Write-Host "╔══════════════════════════════════════════════════════════╗" -ForegroundColor Cyan + Write-Host "β•‘ BetterDesk Server - Build from Source (Windows) β•‘" -ForegroundColor Cyan + Write-Host "β•‘ Enhanced RustDesk with HTTP API & Management β•‘" -ForegroundColor Cyan + Write-Host "β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•" -ForegroundColor Cyan + Write-Host "" + + # Run build steps + Test-Dependencies + Get-Configuration + Get-RustDeskSources + Apply-Modifications + Build-Binaries + New-Checksums + + # Final message + Write-Header "Build Complete!" + + Write-Host "BetterDesk binaries have been built successfully!" -ForegroundColor Green + Write-Host "" + Write-Host "Output location: $OutputDir" + Write-Host "" + Write-Host "Next steps:" + Write-Host " 1. Test the binaries:" + Write-Host " cd $OutputDir" + Write-Host " .\hbbs-windows-x86_64.exe --help" + Write-Host "" + Write-Host " 2. Run the installer to deploy:" + Write-Host " .\install-improved.ps1" + Write-Host "" + Write-Host " 3. Or manually start the servers:" + Write-Host " .\hbbs-windows-x86_64.exe -k _ --api-port 21114" + Write-Host " .\hbbr-windows-x86_64.exe" + Write-Host "" +} + +Main diff --git a/build-betterdesk.sh b/build-betterdesk.sh new file mode 100644 index 00000000..552440d3 --- /dev/null +++ b/build-betterdesk.sh @@ -0,0 +1,548 @@ +#!/bin/bash +# ============================================================================= +# BetterDesk Server - Interactive Build Script +# ============================================================================= +# This script automates building BetterDesk enhanced binaries from source. +# It handles downloading RustDesk sources, applying BetterDesk modifications, +# and compiling the final binaries. +# +# Usage: +# ./build-betterdesk.sh # Interactive mode +# ./build-betterdesk.sh --auto # Non-interactive (use defaults) +# ./build-betterdesk.sh --clean # Clean build directory +# ./build-betterdesk.sh --help # Show help +# +# Requirements: +# - Rust toolchain (rustup) +# - Build essentials (gcc, make) +# - SQLite3 development files +# - OpenSSL development files +# - Git +# ============================================================================= + +set -e + +# Configuration +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BUILD_DIR="$SCRIPT_DIR/build" +PATCHES_DIR="$SCRIPT_DIR/hbbs-patch-v2/src" +OUTPUT_DIR="$SCRIPT_DIR/hbbs-patch-v2" + +# Default RustDesk version (tag-based) +DEFAULT_RUSTDESK_VERSION="1.1.14" +RUSTDESK_REPO="https://github.com/rustdesk/rustdesk-server.git" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +# Command line options +AUTO_MODE=false +CLEAN_MODE=false +TARGET_PLATFORM="" +RUSTDESK_VERSION="" + +# ============================================================================= +# Helper Functions +# ============================================================================= + +print_header() { + echo "" + echo -e "${CYAN}╔══════════════════════════════════════════════════════════╗${NC}" + echo -e "${CYAN}β•‘${NC} ${BOLD}$1${NC}" + echo -e "${CYAN}β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•${NC}" + echo "" +} + +print_success() { echo -e "${GREEN}βœ“${NC} $1"; } +print_error() { echo -e "${RED}βœ—${NC} $1"; } +print_warning() { echo -e "${YELLOW}⚠${NC} $1"; } +print_info() { echo -e "${BLUE}β„Ή${NC} $1"; } +print_step() { echo -e "${CYAN}β†’${NC} $1"; } + +show_help() { + echo "BetterDesk Server - Build Script" + echo "" + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " --auto Non-interactive mode (use default settings)" + echo " --clean Clean build directory and exit" + echo " --version VER Specify RustDesk version (default: $DEFAULT_RUSTDESK_VERSION)" + echo " --platform PLT Target platform: linux-x64, linux-arm64, windows-x64" + echo " --help Show this help message" + echo "" + echo "Examples:" + echo " $0 # Interactive build" + echo " $0 --auto # Build with defaults" + echo " $0 --version 1.1.15 # Build specific version" + echo " $0 --platform linux-arm64 # Cross-compile for ARM64" + echo "" +} + +# ============================================================================= +# Dependency Checks +# ============================================================================= + +check_dependencies() { + print_header "Checking Dependencies" + + local missing=0 + + # Check Rust + if command -v cargo &> /dev/null; then + print_success "Rust/Cargo: $(rustc --version 2>/dev/null | head -1)" + else + print_error "Rust/Cargo not found" + echo " Install: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh" + missing=1 + fi + + # Check Git + if command -v git &> /dev/null; then + print_success "Git: $(git --version)" + else + print_error "Git not found" + echo " Install: sudo apt-get install git" + missing=1 + fi + + # Check pkg-config + if command -v pkg-config &> /dev/null; then + print_success "pkg-config found" + else + print_error "pkg-config not found" + echo " Install: sudo apt-get install pkg-config" + missing=1 + fi + + # Check SQLite3 + if pkg-config --exists sqlite3 2>/dev/null; then + print_success "SQLite3 development files found" + else + print_error "SQLite3 development files not found" + echo " Install: sudo apt-get install libsqlite3-dev" + missing=1 + fi + + # Check OpenSSL + if pkg-config --exists openssl 2>/dev/null; then + print_success "OpenSSL development files found" + else + print_error "OpenSSL development files not found" + echo " Install: sudo apt-get install libssl-dev" + missing=1 + fi + + # Check build essentials + if command -v gcc &> /dev/null; then + print_success "GCC: $(gcc --version | head -1)" + else + print_error "GCC not found" + echo " Install: sudo apt-get install build-essential" + missing=1 + fi + + if [ $missing -ne 0 ]; then + echo "" + print_error "Missing dependencies. Please install them first." + echo "" + echo "Quick install (Debian/Ubuntu):" + echo " sudo apt-get update" + echo " sudo apt-get install -y build-essential pkg-config libsqlite3-dev libssl-dev git curl" + echo " curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh" + echo "" + exit 1 + fi + + print_success "All dependencies satisfied!" +} + +# ============================================================================= +# Interactive Configuration +# ============================================================================= + +interactive_config() { + print_header "Build Configuration" + + # Select RustDesk version + echo -e "${BOLD}Available RustDesk versions:${NC}" + echo " 1) 1.1.14 (stable, recommended)" + echo " 2) 1.1.13 (older stable)" + echo " 3) Custom (enter version)" + echo "" + + if [ "$AUTO_MODE" = true ]; then + RUSTDESK_VERSION="$DEFAULT_RUSTDESK_VERSION" + print_info "Auto mode: Using version $RUSTDESK_VERSION" + else + read -p "Select version [1]: " version_choice + case "$version_choice" in + 2) RUSTDESK_VERSION="1.1.13" ;; + 3) + read -p "Enter version (e.g., 1.1.15): " RUSTDESK_VERSION + ;; + *) RUSTDESK_VERSION="$DEFAULT_RUSTDESK_VERSION" ;; + esac + fi + + print_success "Selected RustDesk version: $RUSTDESK_VERSION" + echo "" + + # Select target platform + echo -e "${BOLD}Target platform:${NC}" + echo " 1) Linux x86_64 (native)" + echo " 2) Linux ARM64 (cross-compile)" + echo " 3) Windows x86_64 (cross-compile, requires mingw)" + echo "" + + if [ "$AUTO_MODE" = true ]; then + TARGET_PLATFORM="linux-x64" + print_info "Auto mode: Building for $TARGET_PLATFORM" + else + read -p "Select platform [1]: " platform_choice + case "$platform_choice" in + 2) TARGET_PLATFORM="linux-arm64" ;; + 3) TARGET_PLATFORM="windows-x64" ;; + *) TARGET_PLATFORM="linux-x64" ;; + esac + fi + + print_success "Target platform: $TARGET_PLATFORM" + echo "" + + # Confirm + if [ "$AUTO_MODE" = false ]; then + echo -e "${BOLD}Build Summary:${NC}" + echo " RustDesk Version: $RUSTDESK_VERSION" + echo " Target Platform: $TARGET_PLATFORM" + echo " Build Directory: $BUILD_DIR" + echo " Output Directory: $OUTPUT_DIR" + echo "" + read -p "Continue with build? [Y/n] " confirm + if [[ "$confirm" =~ ^[Nn]$ ]]; then + echo "Build cancelled." + exit 0 + fi + fi +} + +# ============================================================================= +# Download RustDesk Sources +# ============================================================================= + +download_rustdesk() { + print_header "Downloading RustDesk Server Sources" + + local source_dir="$BUILD_DIR/rustdesk-server-$RUSTDESK_VERSION" + + if [ -d "$source_dir" ]; then + print_info "Source directory exists: $source_dir" + + if [ "$AUTO_MODE" = false ]; then + read -p "Re-download sources? [y/N] " redownload + if [[ ! "$redownload" =~ ^[Yy]$ ]]; then + print_success "Using existing sources" + return 0 + fi + else + print_info "Auto mode: Using existing sources" + return 0 + fi + + rm -rf "$source_dir" + fi + + mkdir -p "$BUILD_DIR" + cd "$BUILD_DIR" + + print_step "Cloning rustdesk-server repository..." + git clone --depth 1 --branch "$RUSTDESK_VERSION" "$RUSTDESK_REPO" "rustdesk-server-$RUSTDESK_VERSION" + + cd "rustdesk-server-$RUSTDESK_VERSION" + + print_step "Initializing submodules..." + git submodule update --init --recursive + + print_success "RustDesk sources downloaded successfully" +} + +# ============================================================================= +# Apply BetterDesk Modifications +# ============================================================================= + +apply_modifications() { + print_header "Applying BetterDesk Modifications" + + local source_dir="$BUILD_DIR/rustdesk-server-$RUSTDESK_VERSION" + + if [ ! -d "$source_dir" ]; then + print_error "Source directory not found: $source_dir" + exit 1 + fi + + cd "$source_dir" + + # List of files to copy from patches + local patch_files=( + "main.rs" + "http_api.rs" + "database.rs" + "database_fixed.rs" + "peer.rs" + "peer_fixed.rs" + "rendezvous_server_core.rs" + ) + + print_step "Copying BetterDesk modifications..." + + for file in "${patch_files[@]}"; do + if [ -f "$PATCHES_DIR/$file" ]; then + # Determine target directory + case "$file" in + main.rs) + cp "$PATCHES_DIR/$file" "src/main.rs" + print_success "Applied: main.rs (HTTP API integration)" + ;; + http_api.rs) + cp "$PATCHES_DIR/$file" "src/http_api.rs" + print_success "Applied: http_api.rs (REST API module)" + ;; + database*.rs|peer*.rs|rendezvous_server_core.rs) + cp "$PATCHES_DIR/$file" "src/$file" + print_success "Applied: $file" + ;; + esac + else + print_warning "Patch file not found: $file" + fi + done + + # Update Cargo.toml to include new dependencies + print_step "Updating Cargo.toml with BetterDesk dependencies..." + + # Check if we need to add axum dependency + if ! grep -q "axum" Cargo.toml; then + print_info "Adding HTTP API dependencies to Cargo.toml..." + + # Add dependencies before [features] or at end of [dependencies] + sed -i '/^\[dependencies\]/a \ +# BetterDesk HTTP API dependencies\ +axum = { version = "0.5", features = ["ws"] }\ +chrono = { version = "0.4", features = ["serde"] }' Cargo.toml + + print_success "Updated Cargo.toml" + else + print_info "Cargo.toml already has required dependencies" + fi + + print_success "BetterDesk modifications applied successfully" +} + +# ============================================================================= +# Build Binaries +# ============================================================================= + +build_binaries() { + print_header "Building BetterDesk Binaries" + + local source_dir="$BUILD_DIR/rustdesk-server-$RUSTDESK_VERSION" + cd "$source_dir" + + # Set up cross-compilation if needed + case "$TARGET_PLATFORM" in + linux-arm64) + print_step "Setting up ARM64 cross-compilation..." + rustup target add aarch64-unknown-linux-gnu + export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc + local target_flag="--target aarch64-unknown-linux-gnu" + local binary_suffix="-linux-arm64" + ;; + windows-x64) + print_step "Setting up Windows cross-compilation..." + rustup target add x86_64-pc-windows-gnu + local target_flag="--target x86_64-pc-windows-gnu" + local binary_suffix="-windows-x86_64.exe" + ;; + *) + local target_flag="" + local binary_suffix="-linux-x86_64" + ;; + esac + + print_step "Building HBBS (Signal Server)..." + cargo build --release $target_flag -p hbbs + + print_step "Building HBBR (Relay Server)..." + cargo build --release $target_flag -p hbbr + + # Find binaries + local target_dir="target" + if [ -n "$target_flag" ]; then + target_dir="target/$(echo $target_flag | sed 's/--target //')" + fi + + local hbbs_binary="$target_dir/release/hbbs" + local hbbr_binary="$target_dir/release/hbbr" + + if [ "$TARGET_PLATFORM" = "windows-x64" ]; then + hbbs_binary="${hbbs_binary}.exe" + hbbr_binary="${hbbr_binary}.exe" + fi + + if [ ! -f "$hbbs_binary" ] || [ ! -f "$hbbr_binary" ]; then + print_error "Build failed - binaries not found" + exit 1 + fi + + print_success "Build completed successfully!" + + # Copy to output directory + print_step "Copying binaries to output directory..." + + cp "$hbbs_binary" "$OUTPUT_DIR/hbbs$binary_suffix" + cp "$hbbr_binary" "$OUTPUT_DIR/hbbr$binary_suffix" + + chmod +x "$OUTPUT_DIR/hbbs$binary_suffix" "$OUTPUT_DIR/hbbr$binary_suffix" + + print_success "Binaries saved to:" + echo " - $OUTPUT_DIR/hbbs$binary_suffix" + echo " - $OUTPUT_DIR/hbbr$binary_suffix" +} + +# ============================================================================= +# Generate Checksums +# ============================================================================= + +generate_checksums() { + print_header "Generating Checksums" + + cd "$OUTPUT_DIR" + + local checksums_file="CHECKSUMS.md" + local date_now=$(date +"%Y-%m-%d %H:%M:%S") + + cat > "$checksums_file" << EOF +# BetterDesk Server - Binary Checksums + +Generated: $date_now +RustDesk Base Version: $RUSTDESK_VERSION +BetterDesk Version: 2.0.0 + +## SHA256 Checksums + +\`\`\` +EOF + + for binary in hbbs-* hbbr-*; do + if [ -f "$binary" ]; then + sha256sum "$binary" >> "$checksums_file" + fi + done + + echo '```' >> "$checksums_file" + + print_success "Checksums saved to: $OUTPUT_DIR/$checksums_file" +} + +# ============================================================================= +# Clean Build +# ============================================================================= + +clean_build() { + print_header "Cleaning Build Directory" + + if [ -d "$BUILD_DIR" ]; then + print_step "Removing: $BUILD_DIR" + rm -rf "$BUILD_DIR" + print_success "Build directory cleaned" + else + print_info "Build directory does not exist" + fi +} + +# ============================================================================= +# Main +# ============================================================================= + +main() { + # Parse arguments + while [[ $# -gt 0 ]]; do + case "$1" in + --auto) + AUTO_MODE=true + shift + ;; + --clean) + CLEAN_MODE=true + shift + ;; + --version) + RUSTDESK_VERSION="$2" + shift 2 + ;; + --platform) + TARGET_PLATFORM="$2" + shift 2 + ;; + --help) + show_help + exit 0 + ;; + *) + print_error "Unknown option: $1" + show_help + exit 1 + ;; + esac + done + + # Handle clean mode + if [ "$CLEAN_MODE" = true ]; then + clean_build + exit 0 + fi + + # Banner + echo "" + echo -e "${CYAN}╔══════════════════════════════════════════════════════════╗${NC}" + echo -e "${CYAN}β•‘${NC} ${BOLD}BetterDesk Server - Build from Source${NC} ${CYAN}β•‘${NC}" + echo -e "${CYAN}β•‘${NC} Enhanced RustDesk with HTTP API & Management ${CYAN}β•‘${NC}" + echo -e "${CYAN}β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•${NC}" + echo "" + + # Run build steps + check_dependencies + interactive_config + download_rustdesk + apply_modifications + build_binaries + generate_checksums + + # Final message + print_header "Build Complete!" + + echo -e "${GREEN}BetterDesk binaries have been built successfully!${NC}" + echo "" + echo "Output location: $OUTPUT_DIR" + echo "" + echo "Next steps:" + echo " 1. Test the binaries:" + echo " cd $OUTPUT_DIR" + echo " ./hbbs-linux-x86_64 --help" + echo "" + echo " 2. Run the installer to deploy:" + echo " sudo ./install-improved.sh" + echo "" + echo " 3. Or manually start the servers:" + echo " ./hbbs-linux-x86_64 -k _ --api-port 21114 &" + echo " ./hbbr-linux-x86_64 &" + echo "" +} + +main "$@" diff --git a/dev_modules/README.md b/dev_modules/README.md index 10363974..4d5999ce 100644 --- a/dev_modules/README.md +++ b/dev_modules/README.md @@ -6,45 +6,30 @@ This directory contains development and testing utilities for BetterDesk Console ### Testing Scripts - **test_ban_api.sh** - Test script for ban/unban API endpoints - - Tests device banning functionality - - Validates API responses - - Usage: `./test_ban_api.sh` +- **test_change_id.py** - Test ID change API endpoint (requires HBBS_API_KEY environment variable) +- **test_id_change.sh** - Test ID change via Web Console API (requires ADMIN_PASS environment variable) +- **test_generator.py** - Test script for RustDesk Client Generator ### Diagnostic Tools - **check_database.py** - Database inspection and validation tool - - Checks database schema - - Verifies migrations applied - - Lists devices and their status - - Usage: `python3 check_database.py` - - **check_and_fix_database.sh** - **Database Schema Checker & Fixer** ⭐ - - Automatically detects database location - - Validates all required tables and columns - - Fixes missing or incorrect schema - - Creates backup before making changes - - Creates admin user if missing - - Usage: `sudo ./check_and_fix_database.sh [database_path]` - - Example: `sudo ./check_and_fix_database.sh /opt/rustdesk/db_v2.sqlite3` - - **Use this if you have login problems!** - + - Use this if you have login problems! +- **diagnose_offline_status.sh** - **Diagnose offline status issues** ⭐ + - Use this if devices show as offline incorrectly! - **fix_peer_columns.sh** - **Quick Fix for Device Errors** ⭐ - - Adds missing columns to peer table (updated_at, deleted_at, etc.) - - Fixes "no such column: updated_at" error - - Usage: `sudo ./fix_peer_columns.sh [database_path]` - - **Use this if you get 500 errors when editing devices!** + - Use this if you get 500 errors when editing devices! +- **fix_systemd_services.sh** - **Fix Systemd Services for API Binaries** ⭐ + - Use this if RustDesk services still use original binaries! +- **fix_database.py** - Fix database.rs imports and change_id function -- **fix_systemd_services.sh** - **Fix Systemd Services for API Binaries** ⭐ NEW - - Scans for all RustDesk systemd services (hbbs.service, rustdesksignal.service, etc.) - - Updates ExecStart to use API-enabled binaries (hbbs-v8-api, hbbr-v8-api) - - Creates backup of original service files - - Usage: `sudo ./fix_systemd_services.sh [rustdesk_path]` - - Example: `sudo ./fix_systemd_services.sh /opt/rustdesk` - - **Use this if RustDesk services still use original binaries after installation!** +### Patching Tools +- **patch_rendezvous.py** - Patch rendezvous_server.rs to call touch_peer on RegisterPeer +- **patch_id_change.py** - Patch rendezvous_server.rs for ID change via old_id field +- **patch_peer_remove.py** - Patch peer.rs to add remove function +- **patch_database_simple.py** - Add change_id function to database.rs ### Development Scripts - **update.ps1** - PowerShell update script (Windows development environment) - - Alternative to update.sh for Windows - - Updates web console components ## Usage diff --git a/dev_modules/fix_database.py b/dev_modules/fix_database.py new file mode 100644 index 00000000..01802900 --- /dev/null +++ b/dev_modules/fix_database.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Fix database.rs imports and change_id function""" + +with open("database.rs", "r") as f: + content = f.read() + +# Fix import +old_import = '''use sqlx::Row;\\nuse sqlx::{''' +new_import = '''use sqlx::{ + Row,''' + +content = content.replace(old_import, new_import) + +# Also fix the get call and closure +old_code = ''' let prev: Option = r.get("previous_ids"); + prev.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default()''' + +new_code = ''' let prev: Option = r.get("previous_ids"); + prev.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default()''' + +# Just rewrite the function properly +old_func = ''' /// Change device ID in database + pub async fn change_id(&self, old_id: &str, new_id: &str) -> ResultType<()> { + let old = old_id.to_string(); + let new = new_id.to_string(); + let url = self.url.clone(); + + let mut opt = SqliteConnectOptions::from_str(&url).unwrap(); + opt.log_statements(log::LevelFilter::Debug); + let mut conn = SqliteConnection::connect_with(&opt).await?; + + // Get current previous_ids + let row = sqlx::query("SELECT previous_ids FROM peer WHERE id = ?") + .bind(&old) + .fetch_optional(&mut conn) + .await?; + + let mut prev_ids: Vec = if let Some(r) = row { + let prev: Option = r.get("previous_ids"); + prev.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default() + } else { + Vec::new() + }; + + // Add old_id to history + if !prev_ids.contains(&old) { + prev_ids.push(old.clone()); + } + + let prev_json = serde_json::to_string(&prev_ids).unwrap_or("[]".to_string()); + + // Update ID + sqlx::query("UPDATE peer SET id = ?, previous_ids = ?, id_changed_at = datetime('now') WHERE id = ?") + .bind(&new) + .bind(&prev_json) + .bind(&old) + .execute(&mut conn) + .await?; + + log::info!("Database: Changed ID {} -> {}", old, new); + Ok(()) + }''' + +new_func = ''' /// Change device ID in database + pub async fn change_id(&self, old_id: &str, new_id: &str) -> ResultType<()> { + let old = old_id.to_string(); + let new = new_id.to_string(); + let url = self.url.clone(); + + let mut opt = SqliteConnectOptions::from_str(&url).unwrap(); + opt.log_statements(log::LevelFilter::Debug); + let mut conn = SqliteConnection::connect_with(&opt).await?; + + // Get current previous_ids + let row: Option<(Option,)> = sqlx::query_as("SELECT previous_ids FROM peer WHERE id = ?") + .bind(&old) + .fetch_optional(&mut conn) + .await?; + + let mut prev_ids: Vec = match row { + Some((Some(s),)) => serde_json::from_str(&s).unwrap_or_default(), + _ => Vec::new() + }; + + // Add old_id to history + if !prev_ids.contains(&old) { + prev_ids.push(old.clone()); + } + + let prev_json = serde_json::to_string(&prev_ids).unwrap_or("[]".to_string()); + + // Update ID + sqlx::query("UPDATE peer SET id = ?, previous_ids = ?, id_changed_at = datetime('now') WHERE id = ?") + .bind(&new) + .bind(&prev_json) + .bind(&old) + .execute(&mut conn) + .await?; + + log::info!("Database: Changed ID {} -> {}", old, new); + Ok(()) + }''' + +content = content.replace(old_func, new_func) + +with open("database.rs", "w") as f: + f.write(content) + +print("Fixed database.rs") diff --git a/dev_modules/patch_database_simple.py b/dev_modules/patch_database_simple.py new file mode 100644 index 00000000..ea52689d --- /dev/null +++ b/dev_modules/patch_database_simple.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Patch database.rs to add change_id function - simplified version""" + +with open("database.rs", "r") as f: + content = f.read() + +# Find place to add change_id function (after is_device_banned) +marker = ''' pub async fn is_device_banned(&self, id: &str) -> ResultType {''' + +new_function = ''' /// Change device ID in database (for ID change feature) + pub async fn change_id(&self, old_id: &str, new_id: &str) -> ResultType<()> { + let old = old_id.to_string(); + let new = new_id.to_string(); + let url = self.url.clone(); + + let mut opt = SqliteConnectOptions::from_str(&url).unwrap(); + opt.log_statements(log::LevelFilter::Debug); + let mut conn = SqliteConnection::connect_with(&opt).await?; + + // Simple update - just change the ID and record timestamp + // The previous_ids tracking is already handled in http_api.rs + sqlx::query("UPDATE peer SET id = ?, id_changed_at = datetime('now') WHERE id = ?") + .bind(&new) + .bind(&old) + .execute(&mut conn) + .await?; + + log::info!("Database: Changed ID {} -> {}", old, new); + Ok(()) + } + + ''' + marker + +if marker in content: + content = content.replace(marker, new_function) + with open("database.rs", "w") as f: + f.write(content) + print("SUCCESS: Added change_id function to database.rs") +else: + print("ERROR: Could not find target marker in database.rs") diff --git a/dev_modules/patch_id_change.py b/dev_modules/patch_id_change.py new file mode 100644 index 00000000..de43c0fe --- /dev/null +++ b/dev_modules/patch_id_change.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Patch rendezvous_server.rs to support ID change via old_id field in RegisterPk""" + +with open("rendezvous_server.rs", "r") as f: + content = f.read() + +# Find the RegisterPk handler and add old_id support +old_code = ''' Some(rendezvous_message::Union::RegisterPk(rk)) => { + if rk.uuid.is_empty() || rk.pk.is_empty() { + return Ok(()); + } + let id = rk.id;''' + +new_code = ''' Some(rendezvous_message::Union::RegisterPk(rk)) => { + if rk.uuid.is_empty() || rk.pk.is_empty() { + return Ok(()); + } + + // Handle ID change request (old_id is set) + if !rk.old_id.is_empty() { + log::info!("ID change request: {} -> {}", rk.old_id, rk.id); + + // Validate new ID format + if rk.id.len() < 6 || rk.id.len() > 16 { + log::warn!("ID change rejected: invalid new ID length"); + return send_rk_res(socket, addr, register_pk_response::Result::INVALID_ID_FORMAT).await; + } + + // Check if new ID already exists + if self.pm.is_in_memory(&rk.id).await { + log::warn!("ID change rejected: new ID {} already exists", rk.id); + return send_rk_res(socket, addr, register_pk_response::Result::ID_EXISTS).await; + } + + // Check if old ID exists and UUID matches + if let Some(old_peer) = self.pm.get_in_memory(&rk.old_id).await { + let old_uuid = old_peer.read().await.uuid.clone(); + if old_uuid != rk.uuid { + log::warn!("ID change rejected: UUID mismatch for {}", rk.old_id); + return send_rk_res(socket, addr, UUID_MISMATCH).await; + } + + // Perform ID change in database + match self.pm.db.change_id(&rk.old_id, &rk.id).await { + Ok(_) => { + log::info!("ID changed successfully: {} -> {}", rk.old_id, rk.id); + // Remove old peer from memory, it will re-register with new ID + self.pm.remove(&rk.old_id).await; + let mut msg_out = RendezvousMessage::new(); + msg_out.set_register_pk_response(RegisterPkResponse { + result: register_pk_response::Result::OK.into(), + ..Default::default() + }); + return socket.send(&msg_out, addr).await; + } + Err(e) => { + log::error!("ID change failed: {}", e); + return send_rk_res(socket, addr, register_pk_response::Result::SERVER_ERROR).await; + } + } + } else { + log::warn!("ID change rejected: old ID {} not found", rk.old_id); + return send_rk_res(socket, addr, register_pk_response::Result::SERVER_ERROR).await; + } + } + + let id = rk.id;''' + +if old_code in content: + content = content.replace(old_code, new_code) + with open("rendezvous_server.rs", "w") as f: + f.write(content) + print("SUCCESS: Patched RegisterPk handler to support ID change") +else: + print("ERROR: Could not find target code block") diff --git a/dev_modules/patch_peer_remove.py b/dev_modules/patch_peer_remove.py new file mode 100644 index 00000000..fde13870 --- /dev/null +++ b/dev_modules/patch_peer_remove.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Patch peer.rs to add remove function""" + +with open("peer.rs", "r") as f: + content = f.read() + +# Find place to add remove function (after is_in_memory) +marker = ''' #[inline] + pub(crate) async fn is_in_memory(&self, id: &str) -> bool { + self.map.read().await.contains_key(id) + }''' + +new_code = ''' #[inline] + pub(crate) async fn is_in_memory(&self, id: &str) -> bool { + self.map.read().await.contains_key(id) + } + + /// Remove peer from memory (used during ID change) + pub(crate) async fn remove(&self, id: &str) { + self.map.write().await.remove(id); + log::debug!("Removed peer {} from memory", id); + }''' + +if marker in content: + content = content.replace(marker, new_code) + with open("peer.rs", "w") as f: + f.write(content) + print("SUCCESS: Added remove function to peer.rs") +else: + print("ERROR: Could not find target marker in peer.rs") diff --git a/dev_modules/patch_rendezvous.py b/dev_modules/patch_rendezvous.py new file mode 100644 index 00000000..63f2052c --- /dev/null +++ b/dev_modules/patch_rendezvous.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +"""Patch rendezvous_server.rs to call touch_peer on RegisterPeer""" +import re + +with open("rendezvous_server.rs", "r") as f: + content = f.read() + +# Add touch_peer call before creating the response message in update_addr +old = '''let mut msg_out = RendezvousMessage::new(); + msg_out.set_register_peer_response''' + +new = '''// Update database status for this peer + self.pm.touch_peer(&id).await; + let mut msg_out = RendezvousMessage::new(); + msg_out.set_register_peer_response''' + +content = content.replace(old, new) + +with open("rendezvous_server.rs", "w") as f: + f.write(content) + +print("Patched rendezvous_server.rs to call touch_peer!") diff --git a/dev_modules/test_change_id.py b/dev_modules/test_change_id.py new file mode 100644 index 00000000..95937105 --- /dev/null +++ b/dev_modules/test_change_id.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +""" +Test script for ID change API endpoint + +Usage: + python test_change_id.py [old_id] [new_id] + +Note: Configure API_KEY before running (get from /opt/rustdesk/.api_key) +""" +import requests +import sys +import os + +old_id = sys.argv[1] if len(sys.argv) > 1 else "TESTID002" +new_id = sys.argv[2] if len(sys.argv) > 2 else "TESTID003" + +# Get API key from environment or file +api_key = os.environ.get('HBBS_API_KEY', '') +if not api_key: + print("ERROR: Set HBBS_API_KEY environment variable") + print(" Linux: export HBBS_API_KEY=$(cat /opt/rustdesk/.api_key)") + print(" Windows: $env:HBBS_API_KEY = Get-Content C:\\rustdesk\\.api_key") + sys.exit(1) + +r = requests.post( + f'http://localhost:21114/api/peers/{old_id}/change-id', + json={'new_id': new_id}, + headers={'X-API-Key': api_key} +) +print(f"Status: {r.status_code}") +print(f"Response: {r.text}") diff --git a/test_generator.py b/dev_modules/test_generator.py similarity index 100% rename from test_generator.py rename to dev_modules/test_generator.py diff --git a/docs/BUILD_GUIDE.md b/docs/BUILD_GUIDE.md new file mode 100644 index 00000000..0e3eebbb --- /dev/null +++ b/docs/BUILD_GUIDE.md @@ -0,0 +1,302 @@ +# BetterDesk - Building from Source + +This guide explains how to build BetterDesk enhanced binaries from source code. + +## Overview + +BetterDesk is built on top of the official RustDesk Server with additional modifications: +- HTTP API for device management +- Real-time online status tracking +- Device banning capabilities +- Enhanced database schema + +## Quick Start + +### Linux +```bash +# Interactive build +./build-betterdesk.sh + +# Automatic build with defaults +./build-betterdesk.sh --auto +``` + +### Windows +```powershell +# Interactive build +.\build-betterdesk.ps1 + +# Automatic build with defaults +.\build-betterdesk.ps1 -Auto +``` + +--- + +## Manual Build Process + +### Prerequisites + +#### Linux +```bash +# Ubuntu/Debian +sudo apt-get update +sudo apt-get install -y build-essential libsqlite3-dev pkg-config libssl-dev git + +# Install Rust +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +source ~/.cargo/env +``` + +#### Windows +1. Install [Rust](https://rustup.rs/) +2. Install [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with C++ support +3. Install [Git](https://git-scm.com/) + +### Step 1: Clone RustDesk Server + +```bash +# Clone specific version +git clone --depth 1 --branch 1.1.14 https://github.com/rustdesk/rustdesk-server.git +cd rustdesk-server + +# Initialize submodules +git submodule update --init --recursive +``` + +### Step 2: Apply BetterDesk Modifications + +Copy the modification files from `hbbs-patch-v2/src/` to the RustDesk source: + +```bash +# From the Rustdesk-FreeConsole directory +cp hbbs-patch-v2/src/main.rs rustdesk-server/src/ +cp hbbs-patch-v2/src/http_api.rs rustdesk-server/src/ +cp hbbs-patch-v2/src/database.rs rustdesk-server/src/ +cp hbbs-patch-v2/src/peer.rs rustdesk-server/src/ +``` + +### Step 3: Build Binaries + +```bash +cd rustdesk-server + +# Build Signal Server (hbbs) +cargo build --release -p hbbs + +# Build Relay Server (hbbr) +cargo build --release -p hbbr +``` + +### Step 4: Locate Binaries + +After successful build, binaries are located in: +- `target/release/hbbs` (Linux) or `target/release/hbbs.exe` (Windows) +- `target/release/hbbr` (Linux) or `target/release/hbbr.exe` (Windows) + +--- + +## Cross-Compilation + +### Linux ARM64 (from x86_64) + +```bash +# Install cross-compiler +sudo apt-get install -y gcc-aarch64-linux-gnu + +# Add Rust target +rustup target add aarch64-unknown-linux-gnu + +# Configure linker +cat >> .cargo/config.toml << EOF +[target.aarch64-unknown-linux-gnu] +linker = "aarch64-linux-gnu-gcc" +EOF + +# Build +cargo build --release --target aarch64-unknown-linux-gnu -p hbbs +cargo build --release --target aarch64-unknown-linux-gnu -p hbbr +``` + +### Windows from Linux (using cross) + +```bash +# Install cross +cargo install cross + +# Build Windows binaries +cross build --release --target x86_64-pc-windows-gnu -p hbbs +cross build --release --target x86_64-pc-windows-gnu -p hbbr +``` + +--- + +## Modification Files + +| File | Purpose | +|------|---------| +| `main.rs` | Entry point with `--api-port` argument, HTTP API startup | +| `http_api.rs` | Full HTTP API implementation (list, online status, ban) | +| `database.rs` | Database operations with BetterDesk extensions | +| `peer.rs` | Peer management with additional fields | + +### Key Modifications in main.rs + +```rust +// Added command-line argument +#[arg(long, value_name = "PORT", help = "HTTP API port for BetterDesk Console")] +api_port: Option, + +// API startup in main() +if let Some(port) = opt.api_port { + let db_path = db_dir.clone(); + tokio::spawn(async move { + http_api::start_api_server(port, db_path).await; + }); +} +``` + +### Key Features in http_api.rs + +```rust +// Endpoints +GET /api/peers - List all registered devices +GET /api/peers/online - List currently online devices +POST /api/peers/{id}/ban - Ban a device +``` + +--- + +## GitHub Actions CI/CD + +The project includes automated builds via GitHub Actions. + +### Automatic Triggers +- Changes to `hbbs-patch-v2/src/**` on the `main` branch +- Changes to `.github/workflows/build.yml` + +### Manual Trigger +1. Go to Actions tab in GitHub +2. Select "Build BetterDesk Binaries" +3. Click "Run workflow" +4. Optionally select RustDesk version and release options + +### Artifacts +Built binaries are available as workflow artifacts for 30 days. + +--- + +## Build Script Options + +### Linux (build-betterdesk.sh) + +| Option | Description | +|--------|-------------| +| `--auto` | Non-interactive mode with defaults | +| `--clean` | Clean build directory | +| `--version VERSION` | Specify RustDesk version | +| `--platform PLATFORM` | Target: linux-x64, linux-arm64, windows-x64 | +| `--help` | Show help | + +### Windows (build-betterdesk.ps1) + +| Option | Description | +|--------|-------------| +| `-Auto` | Non-interactive mode | +| `-Clean` | Clean build directory | +| `-Version VERSION` | Specify RustDesk version | +| `-Platform PLATFORM` | Target platform | +| `-Help` | Show help | + +--- + +## Troubleshooting + +### Build Fails with SQLite Errors + +```bash +# Linux - Install SQLite dev package +sudo apt-get install libsqlite3-dev + +# Or use bundled SQLite +cargo build --release --features bundled +``` + +### Missing OpenSSL + +```bash +# Linux +sudo apt-get install libssl-dev pkg-config + +# Windows: Usually bundled, but if needed: +# Install via vcpkg or use openssl-sys +``` + +### Axum Version Mismatch + +BetterDesk uses axum 0.5.x. If upgrading RustDesk base version, check Cargo.toml for axum version changes. + +### Cross-Compilation Fails + +```bash +# Ensure correct linker is configured +# For ARM64: +export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc +``` + +--- + +## Verifying Builds + +### Check Binary Version + +```bash +./hbbs-linux-x86_64 --version +``` + +### Verify API Port Support + +```bash +./hbbs-linux-x86_64 --help | grep api-port +``` + +Should show: +``` +--api-port HTTP API port for BetterDesk Console +``` + +### Test API Functionality + +```bash +# Start server with API +./hbbs-linux-x86_64 -k _ --api-port 21114 & + +# Test API +curl http://localhost:21114/api/peers +``` + +--- + +## Contributing Modifications + +1. Make changes in `hbbs-patch-v2/src/` +2. Test locally with build scripts +3. Submit PR with updated source files +4. CI will automatically build and test + +See [CONTRIBUTING.md](docs/CONTRIBUTING.md) for full guidelines. + +--- + +## Version Tracking + +| Component | Version | +|-----------|---------| +| RustDesk Server Base | 1.1.14 | +| BetterDesk HTTP API | 2.0.0 | +| Install Scripts | 1.5.x | + +When updating RustDesk base version: +1. Test API compatibility +2. Update build scripts +3. Rebuild all platform binaries +4. Update CHECKSUMS.md diff --git a/DOCKER_SUPPORT.md b/docs/DOCKER_SUPPORT.md similarity index 100% rename from DOCKER_SUPPORT.md rename to docs/DOCKER_SUPPORT.md diff --git a/DOCKER_TROUBLESHOOTING.md b/docs/DOCKER_TROUBLESHOOTING.md similarity index 100% rename from DOCKER_TROUBLESHOOTING.md rename to docs/DOCKER_TROUBLESHOOTING.md diff --git a/docs/README.md b/docs/README.md index 5f47cadd..0d4f9c0c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,27 +2,40 @@ This directory contains comprehensive documentation for BetterDesk Console. -## Contents +## Quick Start +- **[INSTALLATION_V1.4.0.md](INSTALLATION_V1.4.0.md)** - Installation guide +- **[UPDATE_GUIDE.md](UPDATE_GUIDE.md)** - Updating existing installations +- **[TROUBLESHOOTING_EN.md](TROUBLESHOOTING_EN.md)** - Common issues & solutions -### Release Documentation -- **CHANGELOG.md** - Complete version history with all changes -- **RELEASE_NOTES_v1.2.0.md** - Detailed release notes for v1.2.0 -- **DEPRECATION_NOTICE.md** - Information about deprecated features +## Docker +- **[DOCKER_SUPPORT.md](DOCKER_SUPPORT.md)** - Docker installation guide +- **[DOCKER_TROUBLESHOOTING.md](DOCKER_TROUBLESHOOTING.md)** - Docker-specific issues -### Guides -- **CONTRIBUTING.md** - Contribution guidelines for developers -- **UPDATE_GUIDE.md** - How to update existing installations -- **UPDATE_REFERENCE.md** - Detailed update procedures -- **QUICKSTART_UPDATE.md** - Quick update instructions +## Features +- **[CLIENT_GENERATOR.md](CLIENT_GENERATOR.md)** - Custom client generator +- **[CLIENT_GENERATOR_QUICKSTART_EN.md](CLIENT_GENERATOR_QUICKSTART_EN.md)** - Client generator quick start +- **[ID_CHANGE_FEATURE.md](ID_CHANGE_FEATURE.md)** - Device ID change feature +- **[STATUS_TRACKING_v3.md](STATUS_TRACKING_v3.md)** - Device status tracking system -### Development -- **DEVELOPMENT_ROADMAP.md** - Future features and development plans -- **GITHUB_RELEASE_CHECKLIST.md** - Checklist for creating GitHub releases +## Performance +- **[GPU_OPTIMIZATION_EN.md](GPU_OPTIMIZATION_EN.md)** - GPU optimization +- **[GPU_FIX_QUICKSTART_EN.md](GPU_FIX_QUICKSTART_EN.md)** - Quick GPU fix guide +- **[OPTIMIZATION_SUMMARY_EN.md](OPTIMIZATION_SUMMARY_EN.md)** - Performance optimization summary -## Main Documentation +## Security & Troubleshooting +- **[KEY_TROUBLESHOOTING.md](KEY_TROUBLESHOOTING.md)** - Key and encryption issues +- **[PORT_SECURITY.md](PORT_SECURITY.md)** - Port configuration & security +- **[QUICK_FIX_EN.md](QUICK_FIX_EN.md)** - Quick fixes for common issues -For general information, see the main [README.md](../README.md) in the root directory. +## Development +- **[BUILD_GUIDE.md](BUILD_GUIDE.md)** - Building from source +- **[CONTRIBUTING.md](CONTRIBUTING.md)** - Contribution guidelines +- **[PROJECT_STRUCTURE.md](PROJECT_STRUCTURE.md)** - Project structure overview +- **[CHANGELOG.md](CHANGELOG.md)** - Version history + +## Additional +- **[TRANSLATION_SUMMARY.md](TRANSLATION_SUMMARY.md)** - Documentation translation status ## HBBS Patch Documentation -HBBS-specific documentation is located in the [hbbs-patch/](../hbbs-patch/) directory. +HBBS-specific documentation is in [hbbs-patch-v2/](../hbbs-patch-v2/). diff --git a/docs/STATUS_TRACKING_v3.md b/docs/STATUS_TRACKING_v3.md new file mode 100644 index 00000000..11122f4c --- /dev/null +++ b/docs/STATUS_TRACKING_v3.md @@ -0,0 +1,258 @@ +# BetterDesk Server v3.0 - Ulepszony System Statusu + +## PrzeglΔ…d Zmian + +Wersja 3.0 wprowadza znaczΔ…co ulepszony system Ε›ledzenia statusu urzΔ…dzeΕ„: + +### Kluczowe Ulepszenia + +| Cecha | v2.0 | v3.0 | +|-------|------|------| +| Timeout wykrywania offline | StaΕ‚y 30s | **Konfigurowalny (domyΕ›lnie 15s)** | +| InterwaΕ‚ heartbeat | StaΕ‚y 5s | **Konfigurowalny (domyΕ›lnie 3s)** | +| Statusy urzΔ…dzeΕ„ | Online/Offline | **Online/Degraded/Critical/Offline** | +| Statystyki | Brak | **PeΕ‚ne statystyki z API** | +| Konfiguracja runtime | Brak | **Przez API i zmienne Ε›rodowiskowe** | + +--- + +## Nowe Statusy UrzΔ…dzeΕ„ + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ ONLINE β”‚ Wszystko OK, heartbeat otrzymany β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ DEGRADED β”‚ 2-3 pominiΔ™te heartbeaty β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ CRITICAL β”‚ 4+ pominiΔ™te heartbeaty, wkrΓ³tce offline β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ OFFLINE β”‚ Przekroczony timeout, brak poΕ‚Δ…czenia β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Konfiguracja + +### Zmienne Środowiskowe + +| Zmienna | DomyΕ›lnie | Opis | +|---------|-----------|------| +| `PEER_TIMEOUT_SECS` | 15 | Sekundy do uznania za offline | +| `HEARTBEAT_INTERVAL_SECS` | 3 | InterwaΕ‚ sprawdzania statusu | +| `HEARTBEAT_WARNING_THRESHOLD` | 2 | IloΕ›Δ‡ pominiΔ™tych HB dla DEGRADED | +| `HEARTBEAT_CRITICAL_THRESHOLD` | 4 | IloΕ›Δ‡ pominiΔ™tych HB dla CRITICAL | +| `HEARTBEAT_VERBOSE` | false | SzczegΓ³Ε‚owe logowanie | +| `DB_SYNC_INTERVAL_SECS` | 5 | InterwaΕ‚ synchronizacji z DB | + +### Konfiguracja przez Systemd + +```ini +# /etc/systemd/system/rustdesksignal.service +[Service] +Environment="PEER_TIMEOUT_SECS=10" +Environment="HEARTBEAT_INTERVAL_SECS=2" +Environment="HEARTBEAT_VERBOSE=true" +``` + +### Konfiguracja przez Docker + +```yaml +services: + hbbs: + environment: + - PEER_TIMEOUT_SECS=10 + - HEARTBEAT_INTERVAL_SECS=2 +``` + +--- + +## Nowe Endpointy API + +### GET /api/config +Pobiera aktualnΔ… konfiguracjΔ™ serwera. + +**Response:** +```json +{ + "success": true, + "data": { + "peer_timeout_secs": 15, + "heartbeat_interval_secs": 3, + "warning_threshold": 2, + "critical_threshold": 4, + "verbose_logging": false, + "db_sync_interval_secs": 5 + }, + "version": "3.0.0" +} +``` + +### POST /api/config +Aktualizuje konfiguracjΔ™ (zapisuje do bazy danych). + +**Request:** +```json +{ + "key": "peer_timeout_secs", + "value": "10" +} +``` + +**Response:** +```json +{ + "success": true, + "data": true, + "version": "3.0.0" +} +``` + +### GET /api/peers/stats +Zwraca szczegΓ³Ε‚owe statystyki urzΔ…dzeΕ„. + +**Response:** +```json +{ + "success": true, + "data": { + "total_peers": 150, + "online_peers": 87, + "offline_peers": 63, + "banned_peers": 2, + "total_heartbeats": 1548762 + }, + "version": "3.0.0" +} +``` + +### GET /api/server/stats +Statystyki serwera. + +**Response:** +```json +{ + "success": true, + "data": { + "uptime_seconds": 86400, + "memory_usage_mb": 45.2, + "api_version": "3.0.0", + "db_version": "3.0" + }, + "version": "3.0.0" +} +``` + +--- + +## Nowe Kolumny Bazy Danych + +Migracja automatycznie dodaje: + +| Kolumna | Typ | Opis | +|---------|-----|------| +| `last_heartbeat` | DATETIME | Czas ostatniego heartbeatu | +| `heartbeat_count` | INTEGER | Licznik heartbeatΓ³w | +| `previous_ids` | TEXT (JSON) | Historia poprzednich ID | +| `id_changed_at` | DATETIME | Data zmiany ID | + +### Tabela server_config + +Nowa tabela dla konfiguracji runtime: + +```sql +CREATE TABLE server_config ( + key VARCHAR(100) PRIMARY KEY, + value TEXT NOT NULL, + updated_at DATETIME +); +``` + +--- + +## Rekomendowane Ustawienia + +### Środowisko Produkcyjne (stabilnoΕ›Δ‡) +```bash +PEER_TIMEOUT_SECS=15 +HEARTBEAT_INTERVAL_SECS=3 +``` + +### Środowisko Krytyczne (szybka reakcja) +```bash +PEER_TIMEOUT_SECS=8 +HEARTBEAT_INTERVAL_SECS=2 +``` + +### Środowisko z Wolnymi Łączami +```bash +PEER_TIMEOUT_SECS=30 +HEARTBEAT_INTERVAL_SECS=5 +``` + +--- + +## Migracja z v2.0 + +1. **Backup bazy danych:** + ```bash + cp /opt/rustdesk/db_v2.sqlite3 /opt/rustdesk/db_v2.sqlite3.backup + ``` + +2. **Wymiana binarek:** + ```bash + sudo ./install-improved.sh --fix + ``` + +3. **Restart usΕ‚ug:** + ```bash + sudo systemctl restart rustdesksignal rustdeskrelay + ``` + +Migracja schematu bazy danych wykonuje siΔ™ automatycznie przy pierwszym uruchomieniu. + +--- + +## Pliki ΕΉrΓ³dΕ‚owe v3.0 + +| Plik | Opis | +|------|------| +| `peer_v3.rs` | Ulepszony system statusu peer | +| `database_v3.rs` | Rozszerzona obsΕ‚uga bazy danych | +| `http_api_v3.rs` | Nowe endpointy API | + +### UΕΌycie w Kompilacji + +Aby uΕΌyΔ‡ nowych plikΓ³w, zamieΕ„ oryginalne: + +```bash +cp hbbs-patch-v2/src/peer_v3.rs hbbs-patch-v2/src/peer.rs +cp hbbs-patch-v2/src/database_v3.rs hbbs-patch-v2/src/database.rs +cp hbbs-patch-v2/src/http_api_v3.rs hbbs-patch-v2/src/http_api.rs +``` + +NastΔ™pnie skompiluj: +```bash +./build-betterdesk.sh --auto +``` + +--- + +## Znane Ograniczenia + +1. **Zmiany konfiguracji przez API** wymagajΔ… restartu dla niektΓ³rych parametrΓ³w +2. **WebSocket real-time push** nie jest jeszcze zaimplementowany (planowane w v3.1) +3. **Zmiana ID urzΔ…dzeΕ„** jest przygotowana w bazie danych ale wymaga zmian w kliencie RustDesk + +--- + +## Changelog + +### v3.0.0 (2026-02-06) +- Konfigurowalny timeout offline +- Konfigurowalny interwaΕ‚ heartbeat +- Statusy poΕ›rednie (DEGRADED, CRITICAL) +- Nowe endpointy API (/api/config, /api/peers/stats, /api/server/stats) +- Automatyczne migracje bazy danych +- Tabela server_config dla runtime configuration +- Batch operations dla lepszej wydajnoΕ›ci diff --git a/hbbs-patch-v2/src/database.rs b/hbbs-patch-v2/src/database.rs index 2146f7a1..9125d074 100644 --- a/hbbs-patch-v2/src/database.rs +++ b/hbbs-patch-v2/src/database.rs @@ -1,10 +1,9 @@ -// Enhanced database.rs with retry logic and circuit breaker use async_trait::async_trait; -use hbb_common::{log, ResultType}; +use hbb_common::{log, ResultType, tokio}; use sqlx::{ sqlite::SqliteConnectOptions, ConnectOptions, Connection, Error as SqlxError, SqliteConnection, }; -use std::{ops::DerefMut, str::FromStr, sync::Arc, sync::atomic::{AtomicBool, AtomicU32, Ordering}}; +use std::{ops::DerefMut, str::FromStr, sync::Arc}; use std::time::{Duration, Instant}; type Pool = deadpool::managed::Pool; @@ -17,38 +16,11 @@ pub struct DbPool { impl deadpool::managed::Manager for DbPool { type Type = SqliteConnection; type Error = SqlxError; - async fn create(&self) -> Result { let mut opt = SqliteConnectOptions::from_str(&self.url).unwrap(); opt.log_statements(log::LevelFilter::Debug); - - // Retry logic with exponential backoff - let mut attempts = 0; - let max_attempts = 3; - - loop { - match SqliteConnection::connect_with(&opt).await { - Ok(conn) => { - if attempts > 0 { - log::info!("Database connection established after {} attempts", attempts + 1); - } - return Ok(conn); - } - Err(e) => { - attempts += 1; - if attempts >= max_attempts { - log::error!("Failed to connect to database after {} attempts: {}", max_attempts, e); - return Err(e); - } - let wait_ms = 100 * (2_u64.pow(attempts)); - log::warn!("Database connection failed (attempt {}/{}), retrying in {}ms: {}", - attempts, max_attempts, wait_ms, e); - tokio::time::sleep(Duration::from_millis(wait_ms)).await; - } - } - } + SqliteConnection::connect_with(&opt).await } - async fn recycle( &self, obj: &mut SqliteConnection, @@ -57,75 +29,10 @@ impl deadpool::managed::Manager for DbPool { } } -/// Circuit breaker to prevent database overload -#[derive(Clone)] -struct CircuitBreaker { - failure_count: Arc, - last_failure: Arc>>, - is_open: Arc, -} - -impl CircuitBreaker { - fn new() -> Self { - Self { - failure_count: Arc::new(AtomicU32::new(0)), - last_failure: Arc::new(tokio::sync::Mutex::new(None)), - is_open: Arc::new(AtomicBool::new(false)), - } - } - - async fn call(&self, f: F) -> Result - where - F: std::future::Future>, - E: std::fmt::Display, - { - // Check if circuit is open - if self.is_open.load(Ordering::Relaxed) { - let mut last = self.last_failure.lock().await; - if let Some(time) = *last { - // Auto-recover after 30 seconds - if time.elapsed() > Duration::from_secs(30) { - log::info!("Circuit breaker: attempting recovery"); - self.is_open.store(false, Ordering::Relaxed); - self.failure_count.store(0, Ordering::Relaxed); - *last = None; - } else { - log::warn!("Circuit breaker is OPEN - blocking database operations"); - // For now, still try but log the state - } - } - } - - match f.await { - Ok(result) => { - // Success - reset failure count - let prev = self.failure_count.swap(0, Ordering::Relaxed); - if prev > 0 { - log::info!("Database operation succeeded, failure count reset"); - } - Ok(result) - } - Err(e) => { - let count = self.failure_count.fetch_add(1, Ordering::Relaxed) + 1; - log::error!("Database operation failed (failure #{}) : {}", count, e); - - // Open circuit after 5 consecutive failures - if count >= 5 { - log::error!("Circuit breaker OPENED after {} consecutive failures", count); - self.is_open.store(true, Ordering::Relaxed); - *self.last_failure.lock().await = Some(Instant::now()); - } - - Err(e) - } - } - } -} - #[derive(Clone)] pub struct Database { pool: Pool, - circuit_breaker: CircuitBreaker, + url: String, } #[derive(Default)] @@ -142,94 +49,61 @@ pub struct Peer { impl Database { pub async fn new(url: &str) -> ResultType { if !std::path::Path::new(url).exists() { - log::info!("Creating new database file: {}", url); std::fs::File::create(url).ok(); } - let n: usize = std::env::var("MAX_DATABASE_CONNECTIONS") - .unwrap_or_else(|_| "5".to_owned()) // Increased default from 1 to 5 + .unwrap_or_else(|_| "5".to_owned()) // Increased from 1 to 5 .parse() .unwrap_or(5); - - log::info!("Initializing database with {} connection(s)", n); - + log::info!("MAX_DATABASE_CONNECTIONS={}", n); let pool = Pool::new( DbPool { url: url.to_owned(), }, n, ); - - // Test connection with retry - let mut attempts = 0; - loop { - match pool.get().await { - Ok(_) => { - log::info!("Database connection pool initialized successfully"); - break; - } - Err(e) => { - attempts += 1; - if attempts >= 5 { - log::error!("Failed to initialize database pool after {} attempts", attempts); - return Err(e.into()); - } - log::warn!("Database pool test failed (attempt {}/5), retrying...", attempts); - tokio::time::sleep(Duration::from_millis(500 * attempts as u64)).await; - } - } - } - + let _ = pool.get().await?; // test let db = Database { - pool, - circuit_breaker: CircuitBreaker::new(), + pool, + url: url.to_owned(), }; - db.create_tables().await?; Ok(db) } async fn create_tables(&self) -> ResultType<()> { - log::debug!("Creating database tables if not exist..."); - - self.circuit_breaker.call(async { - sqlx::query!( - " - create table if not exists peer ( - guid blob primary key not null, - id varchar(100) not null, - uuid blob not null, - pk blob not null, - created_at datetime not null default(current_timestamp), - user blob, - status tinyint, - note varchar(300), - info text not null - ) without rowid; - create unique index if not exists index_peer_id on peer (id); - create index if not exists index_peer_user on peer (user); - create index if not exists index_peer_created_at on peer (created_at); - create index if not exists index_peer_status on peer (status); + sqlx::query!( " - ) - .execute(self.pool.get().await?.deref_mut()) - .await - }).await?; - - log::debug!("Database tables ready"); + create table if not exists peer ( + guid blob primary key not null, + id varchar(100) not null, + uuid blob not null, + pk blob not null, + created_at datetime not null default(current_timestamp), + user blob, + status tinyint, + note varchar(300), + info text not null + ) without rowid; + create unique index if not exists index_peer_id on peer (id); + create index if not exists index_peer_user on peer (user); + create index if not exists index_peer_created_at on peer (created_at); + create index if not exists index_peer_status on peer (status); + " + ) + .execute(self.pool.get().await?.deref_mut()) + .await?; Ok(()) } pub async fn get_peer(&self, id: &str) -> ResultType> { - self.circuit_breaker.call(async { - Ok(sqlx::query_as!( - Peer, - "select guid, id, uuid, pk, user, status, info from peer where id = ?", - id - ) - .fetch_optional(self.pool.get().await?.deref_mut()) - .await?) - }).await + Ok(sqlx::query_as!( + Peer, + "select guid, id, uuid, pk, user, status, info from peer where id = ?", + id + ) + .fetch_optional(self.pool.get().await?.deref_mut()) + .await?) } pub async fn insert_peer( @@ -240,21 +114,18 @@ impl Database { info: &str, ) -> ResultType> { let guid = uuid::Uuid::new_v4().as_bytes().to_vec(); - - self.circuit_breaker.call(async { - sqlx::query!( - "insert into peer(guid, id, uuid, pk, info) values(?, ?, ?, ?, ?)", - guid, - id, - uuid, - pk, - info - ) - .execute(self.pool.get().await?.deref_mut()) - .await?; - - Ok(guid.clone()) - }).await + sqlx::query!( + "insert into peer(guid, id, uuid, pk, info, status, last_online) values(?, ?, ?, ?, ?, 1, datetime('now'))", + guid, + id, + uuid, + pk, + info + ) + .execute(self.pool.get().await?.deref_mut()) + .await?; + log::info!("New peer {} inserted with status=1 (online)", id); + Ok(guid) } pub async fn update_pk( @@ -264,151 +135,129 @@ impl Database { pk: &[u8], info: &str, ) -> ResultType<()> { - self.circuit_breaker.call(async { - sqlx::query!( - "update peer set id=?, pk=?, info=? where guid=?", - id, - pk, - info, - guid - ) - .execute(self.pool.get().await?.deref_mut()) - .await?; - - Ok(()) - }).await + sqlx::query!( + "update peer set id=?, pk=?, info=?, status=1, last_online=datetime('now') where guid=?", + id, + pk, + info, + guid + ) + .execute(self.pool.get().await?.deref_mut()) + .await?; + log::debug!("Peer {} updated pk, set status=1, last_online=now", id); + Ok(()) } - /// Check if a device is banned in the database (with retry logic) - pub async fn is_device_banned(&self, id: &str) -> ResultType { - use sqlx::Row; + /// Set device status to online and update last_online timestamp + /// Called when device registers or sends heartbeat + pub async fn set_online(&self, id: &str) { + let id_owned = id.to_string(); + let url = self.url.clone(); - self.circuit_breaker.call(async { - let r = sqlx::query("SELECT is_banned FROM peer WHERE id = ? AND is_deleted = 0") - .bind(id) - .fetch_optional(self.pool.get().await?.deref_mut()) - .await?; - - if let Some(row) = r { - let banned: i32 = row.try_get("is_banned")?; - Ok(banned == 1) - } else { - Ok(false) - } - }).await - } - - /// Set peer as online in database (async, non-blocking) - pub async fn set_online(&self, id: &str) -> ResultType<()> { - let id = id.to_owned(); - let db = self.clone(); - - // Fire and forget - don't block the caller + // Fire and forget - don't block the main flow tokio::spawn(async move { - if let Err(e) = db._set_online_internal(&id).await { - log::error!("Failed to set peer {} as online: {}", id, e); + if let Err(e) = Self::set_online_internal(&url, &id_owned).await { + log::warn!("Failed to set {} online: {}", id_owned, e); } }); + } + + async fn set_online_internal(url: &str, id: &str) -> ResultType<()> { + let mut opt = SqliteConnectOptions::from_str(url).unwrap(); + opt.log_statements(log::LevelFilter::Debug); + let mut conn = SqliteConnection::connect_with(&opt).await?; + sqlx::query!( + "UPDATE peer SET status = 1, last_online = datetime('now') WHERE id = ?", + id + ) + .execute(&mut conn) + .await?; + + log::trace!("Set {} online, last_online updated", id); Ok(()) } - async fn _set_online_internal(&self, id: &str) -> ResultType<()> { - self.circuit_breaker.call(async { - sqlx::query("UPDATE peer SET last_online = datetime('now') WHERE id = ? AND is_deleted = 0") - .bind(id) - .execute(self.pool.get().await?.deref_mut()) - .await?; - Ok(()) - }).await - } - - /// Set peer as offline in database (async, non-blocking) - pub async fn set_offline(&self, id: &str) -> ResultType<()> { - let id = id.to_owned(); - let db = self.clone(); + /// Set device status to offline + /// Called when device times out or disconnects + pub async fn set_offline(&self, id: &str) { + let id_owned = id.to_string(); + let url = self.url.clone(); - // Fire and forget - don't block the caller + // Fire and forget tokio::spawn(async move { - if let Err(e) = db._set_offline_internal(&id).await { - log::error!("Failed to set peer {} as offline: {}", id, e); + if let Err(e) = Self::set_offline_internal(&url, &id_owned).await { + log::warn!("Failed to set {} offline: {}", id_owned, e); } }); + } + + async fn set_offline_internal(url: &str, id: &str) -> ResultType<()> { + let mut opt = SqliteConnectOptions::from_str(url).unwrap(); + opt.log_statements(log::LevelFilter::Debug); + let mut conn = SqliteConnection::connect_with(&opt).await?; + sqlx::query!( + "UPDATE peer SET status = 0 WHERE id = ?", + id + ) + .execute(&mut conn) + .await?; + + log::debug!("Set {} offline", id); Ok(()) } - async fn _set_offline_internal(&self, id: &str) -> ResultType<()> { - self.circuit_breaker.call(async { - sqlx::query("UPDATE peer SET last_online = NULL WHERE id = ? AND is_deleted = 0") - .bind(id) - .execute(self.pool.get().await?.deref_mut()) - .await?; - Ok(()) - }).await + /// Set all devices offline - called on server startup to reset stale status + pub async fn set_all_offline(&self) -> ResultType<()> { + sqlx::query!( + "UPDATE peer SET status = 0 WHERE status = 1" + ) + .execute(self.pool.get().await?.deref_mut()) + .await?; + + log::info!("Reset all devices to offline status on startup"); + Ok(()) } - /// Batch update online status for multiple peers (more efficient) + /// Set multiple devices offline in a single transaction (batch operation) pub async fn batch_set_offline(&self, ids: &[String]) -> ResultType<()> { if ids.is_empty() { return Ok(()); } - log::debug!("Batch setting {} peers as offline", ids.len()); + let mut conn = self.pool.get().await?; - self.circuit_breaker.call(async { - let mut conn = self.pool.get().await?; - let mut tx = conn.begin().await?; - - for id in ids { - sqlx::query("UPDATE peer SET last_online = NULL WHERE id = ? AND is_deleted = 0") - .bind(id) - .execute(&mut *tx) - .await?; - } - - tx.commit().await?; - Ok(()) - }).await - } -} - -#[cfg(test)] -mod tests { - use hbb_common::tokio; - - #[test] - fn test_insert() { - insert(); - } - - #[tokio::main(flavor = "multi_thread")] - async fn insert() { - let db = super::Database::new("test_v2.sqlite3").await.unwrap(); - let mut jobs = vec![]; - - for i in 0..1000 { - let cloned = db.clone(); - let id = i.to_string(); - let a = tokio::spawn(async move { - let empty_vec = Vec::new(); - cloned - .insert_peer(&id, &empty_vec, &empty_vec, "") - .await - .unwrap(); - }); - jobs.push(a); + for id in ids { + sqlx::query!( + "UPDATE peer SET status = 0 WHERE id = ?", + id + ) + .execute(conn.deref_mut()) + .await?; } - for i in 0..1000 { - let cloned = db.clone(); - let id = i.to_string(); - let a = tokio::spawn(async move { - cloned.get_peer(&id).await.unwrap(); - }); - jobs.push(a); - } + log::debug!("Batch set {} devices offline", ids.len()); + Ok(()) + } + + /// Check if a device is banned in the database + /// Returns true if device has is_banned=1, false otherwise + /// Uses synchronous rusqlite to avoid nested Tokio runtime panic + pub async fn is_device_banned(&self, id: &str) -> ResultType { + let db_path = self.url.clone(); + let id = id.to_string(); - hbb_common::futures::future::join_all(jobs).await; + // Use spawn_blocking to run synchronous rusqlite code + let result = tokio::task::spawn_blocking(move || -> ResultType { + let conn = rusqlite::Connection::open(&db_path)?; + let mut stmt = conn.prepare("SELECT is_banned FROM peer WHERE id = ?")?; + let is_banned: Option = stmt + .query_row([&id], |row| row.get(0)) + .unwrap_or(None); + Ok(is_banned == Some(1)) + }).await?; + + result } } diff --git a/hbbs-patch-v2/src/database_fixed.rs b/hbbs-patch-v2/src/database_fixed.rs deleted file mode 100644 index 9125d074..00000000 --- a/hbbs-patch-v2/src/database_fixed.rs +++ /dev/null @@ -1,263 +0,0 @@ -use async_trait::async_trait; -use hbb_common::{log, ResultType, tokio}; -use sqlx::{ - sqlite::SqliteConnectOptions, ConnectOptions, Connection, Error as SqlxError, SqliteConnection, -}; -use std::{ops::DerefMut, str::FromStr, sync::Arc}; -use std::time::{Duration, Instant}; - -type Pool = deadpool::managed::Pool; - -pub struct DbPool { - url: String, -} - -#[async_trait] -impl deadpool::managed::Manager for DbPool { - type Type = SqliteConnection; - type Error = SqlxError; - async fn create(&self) -> Result { - let mut opt = SqliteConnectOptions::from_str(&self.url).unwrap(); - opt.log_statements(log::LevelFilter::Debug); - SqliteConnection::connect_with(&opt).await - } - async fn recycle( - &self, - obj: &mut SqliteConnection, - ) -> deadpool::managed::RecycleResult { - Ok(obj.ping().await?) - } -} - -#[derive(Clone)] -pub struct Database { - pool: Pool, - url: String, -} - -#[derive(Default)] -pub struct Peer { - pub guid: Vec, - pub id: String, - pub uuid: Vec, - pub pk: Vec, - pub user: Option>, - pub info: String, - pub status: Option, -} - -impl Database { - pub async fn new(url: &str) -> ResultType { - if !std::path::Path::new(url).exists() { - std::fs::File::create(url).ok(); - } - let n: usize = std::env::var("MAX_DATABASE_CONNECTIONS") - .unwrap_or_else(|_| "5".to_owned()) // Increased from 1 to 5 - .parse() - .unwrap_or(5); - log::info!("MAX_DATABASE_CONNECTIONS={}", n); - let pool = Pool::new( - DbPool { - url: url.to_owned(), - }, - n, - ); - let _ = pool.get().await?; // test - let db = Database { - pool, - url: url.to_owned(), - }; - db.create_tables().await?; - Ok(db) - } - - async fn create_tables(&self) -> ResultType<()> { - sqlx::query!( - " - create table if not exists peer ( - guid blob primary key not null, - id varchar(100) not null, - uuid blob not null, - pk blob not null, - created_at datetime not null default(current_timestamp), - user blob, - status tinyint, - note varchar(300), - info text not null - ) without rowid; - create unique index if not exists index_peer_id on peer (id); - create index if not exists index_peer_user on peer (user); - create index if not exists index_peer_created_at on peer (created_at); - create index if not exists index_peer_status on peer (status); - " - ) - .execute(self.pool.get().await?.deref_mut()) - .await?; - Ok(()) - } - - pub async fn get_peer(&self, id: &str) -> ResultType> { - Ok(sqlx::query_as!( - Peer, - "select guid, id, uuid, pk, user, status, info from peer where id = ?", - id - ) - .fetch_optional(self.pool.get().await?.deref_mut()) - .await?) - } - - pub async fn insert_peer( - &self, - id: &str, - uuid: &[u8], - pk: &[u8], - info: &str, - ) -> ResultType> { - let guid = uuid::Uuid::new_v4().as_bytes().to_vec(); - sqlx::query!( - "insert into peer(guid, id, uuid, pk, info, status, last_online) values(?, ?, ?, ?, ?, 1, datetime('now'))", - guid, - id, - uuid, - pk, - info - ) - .execute(self.pool.get().await?.deref_mut()) - .await?; - log::info!("New peer {} inserted with status=1 (online)", id); - Ok(guid) - } - - pub async fn update_pk( - &self, - guid: &Vec, - id: &str, - pk: &[u8], - info: &str, - ) -> ResultType<()> { - sqlx::query!( - "update peer set id=?, pk=?, info=?, status=1, last_online=datetime('now') where guid=?", - id, - pk, - info, - guid - ) - .execute(self.pool.get().await?.deref_mut()) - .await?; - log::debug!("Peer {} updated pk, set status=1, last_online=now", id); - Ok(()) - } - - /// Set device status to online and update last_online timestamp - /// Called when device registers or sends heartbeat - pub async fn set_online(&self, id: &str) { - let id_owned = id.to_string(); - let url = self.url.clone(); - - // Fire and forget - don't block the main flow - tokio::spawn(async move { - if let Err(e) = Self::set_online_internal(&url, &id_owned).await { - log::warn!("Failed to set {} online: {}", id_owned, e); - } - }); - } - - async fn set_online_internal(url: &str, id: &str) -> ResultType<()> { - let mut opt = SqliteConnectOptions::from_str(url).unwrap(); - opt.log_statements(log::LevelFilter::Debug); - let mut conn = SqliteConnection::connect_with(&opt).await?; - - sqlx::query!( - "UPDATE peer SET status = 1, last_online = datetime('now') WHERE id = ?", - id - ) - .execute(&mut conn) - .await?; - - log::trace!("Set {} online, last_online updated", id); - Ok(()) - } - - /// Set device status to offline - /// Called when device times out or disconnects - pub async fn set_offline(&self, id: &str) { - let id_owned = id.to_string(); - let url = self.url.clone(); - - // Fire and forget - tokio::spawn(async move { - if let Err(e) = Self::set_offline_internal(&url, &id_owned).await { - log::warn!("Failed to set {} offline: {}", id_owned, e); - } - }); - } - - async fn set_offline_internal(url: &str, id: &str) -> ResultType<()> { - let mut opt = SqliteConnectOptions::from_str(url).unwrap(); - opt.log_statements(log::LevelFilter::Debug); - let mut conn = SqliteConnection::connect_with(&opt).await?; - - sqlx::query!( - "UPDATE peer SET status = 0 WHERE id = ?", - id - ) - .execute(&mut conn) - .await?; - - log::debug!("Set {} offline", id); - Ok(()) - } - - /// Set all devices offline - called on server startup to reset stale status - pub async fn set_all_offline(&self) -> ResultType<()> { - sqlx::query!( - "UPDATE peer SET status = 0 WHERE status = 1" - ) - .execute(self.pool.get().await?.deref_mut()) - .await?; - - log::info!("Reset all devices to offline status on startup"); - Ok(()) - } - - /// Set multiple devices offline in a single transaction (batch operation) - pub async fn batch_set_offline(&self, ids: &[String]) -> ResultType<()> { - if ids.is_empty() { - return Ok(()); - } - - let mut conn = self.pool.get().await?; - - for id in ids { - sqlx::query!( - "UPDATE peer SET status = 0 WHERE id = ?", - id - ) - .execute(conn.deref_mut()) - .await?; - } - - log::debug!("Batch set {} devices offline", ids.len()); - Ok(()) - } - - /// Check if a device is banned in the database - /// Returns true if device has is_banned=1, false otherwise - /// Uses synchronous rusqlite to avoid nested Tokio runtime panic - pub async fn is_device_banned(&self, id: &str) -> ResultType { - let db_path = self.url.clone(); - let id = id.to_string(); - - // Use spawn_blocking to run synchronous rusqlite code - let result = tokio::task::spawn_blocking(move || -> ResultType { - let conn = rusqlite::Connection::open(&db_path)?; - let mut stmt = conn.prepare("SELECT is_banned FROM peer WHERE id = ?")?; - let is_banned: Option = stmt - .query_row([&id], |row| row.get(0)) - .unwrap_or(None); - Ok(is_banned == Some(1)) - }).await?; - - result - } -} diff --git a/hbbs-patch-v2/src/http_api.rs b/hbbs-patch-v2/src/http_api.rs index f8264554..ad8d75f5 100644 --- a/hbbs-patch-v2/src/http_api.rs +++ b/hbbs-patch-v2/src/http_api.rs @@ -1,14 +1,17 @@ -// BetterDesk HTTP API v2.0.0 +// BetterDesk HTTP API v2.1.0 // Compatible with axum 0.5 and sqlx 0.6 +// Added: POST /api/peers/:id/change-id endpoint + +extern crate serde_json; use axum::{ - extract::Extension, + extract::{Extension, Path}, http::{StatusCode, HeaderMap}, response::Json, - routing::get, + routing::{get, post}, Router, }; -use serde::Serialize; +use serde::{Serialize, Deserialize}; use sqlx::{sqlite::SqlitePool, Row}; use std::net::SocketAddr; use std::sync::Arc; @@ -47,6 +50,19 @@ struct HealthStatus { version: String, } +#[derive(Deserialize)] +struct ChangeIdRequest { + new_id: String, +} + +#[derive(Serialize)] +struct ChangeIdResponse { + old_id: String, + new_id: String, + changed_at: String, + previous_ids: Vec, +} + fn verify_api_key(headers: &HeaderMap, state: &ApiState) -> Result<(), StatusCode> { match headers.get("X-API-Key") { Some(key) => { @@ -68,16 +84,43 @@ fn get_current_timestamp() -> String { chrono::Utc::now().to_rfc3339() } +/// Check if a timestamp string is within the last N seconds (default 60s) +/// Supports formats: "YYYY-MM-DD HH:MM:SS" (SQLite) and RFC3339 +fn is_online_recently(timestamp: &Option, timeout_secs: i64) -> bool { + match timestamp { + Some(ts) => { + // Try SQLite format first: "2026-02-06 14:00:27" + if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(ts, "%Y-%m-%d %H:%M:%S") { + let now = chrono::Utc::now().naive_utc(); + let diff = now.signed_duration_since(dt); + return diff.num_seconds() < timeout_secs; + } + // Try RFC3339 format + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts) { + let now = chrono::Utc::now(); + let diff = now.signed_duration_since(dt); + return diff.num_seconds() < timeout_secs; + } + // If we can't parse, assume offline + false + } + None => false, + } +} + +/// Default timeout for online status (60 seconds) +const ONLINE_TIMEOUT_SECS: i64 = 60; + async fn get_online_peers( headers: HeaderMap, Extension(state): Extension>, ) -> Result>>, StatusCode> { verify_api_key(&headers, &state)?; - hbb_common::log::debug!("API: Fetching online peers"); + hbb_common::log::debug!("API: Fetching all peers"); match sqlx::query( - "SELECT id, note, last_online FROM peer WHERE (status IS NULL OR status = 0) AND is_deleted = 0" + "SELECT id, note, last_online FROM peer WHERE is_deleted = 0" ) .fetch_all(&state.db_pool) .await @@ -89,7 +132,7 @@ async fn get_online_peers( let id: String = row.get("id"); let note: Option = row.get("note"); let last_online: Option = row.get("last_online"); - let online = last_online.is_some(); + let online = is_online_recently(&last_online, ONLINE_TIMEOUT_SECS); peers.push(PeerStatus { id, @@ -160,7 +203,7 @@ async fn get_peer_details( let id: String = row.get("id"); let note: Option = row.get("note"); let last_online: Option = row.get("last_online"); - let online = last_online.is_some(); + let online = is_online_recently(&last_online, ONLINE_TIMEOUT_SECS); Ok(Json(ApiResponse { success: true, @@ -194,6 +237,140 @@ async fn get_peer_details( } } +/// Change peer ID (admin endpoint) +/// POST /api/peers/:id/change-id +/// Body: { "new_id": "NEW123456" } +async fn change_peer_id( + headers: HeaderMap, + Extension(state): Extension>, + Path(old_id): Path, + Json(payload): Json, +) -> Result>, StatusCode> { + verify_api_key(&headers, &state)?; + + let new_id = payload.new_id.trim().to_uppercase(); + let old_id = old_id.trim().to_uppercase(); + + hbb_common::log::info!("API: Change ID request: {} -> {}", old_id, new_id); + + // Validate new ID format (6-16 chars, alphanumeric/dash/underscore) + if new_id.len() < 6 || new_id.len() > 16 { + return Ok(Json(ApiResponse { + success: false, + data: None, + error: Some("New ID must be 6-16 characters".to_string()), + timestamp: get_current_timestamp(), + })); + } + + if !new_id.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') { + return Ok(Json(ApiResponse { + success: false, + data: None, + error: Some("New ID can only contain letters, numbers, dash and underscore".to_string()), + timestamp: get_current_timestamp(), + })); + } + + // Check if old_id exists + let old_peer = sqlx::query("SELECT previous_ids FROM peer WHERE id = ? AND is_deleted = 0") + .bind(&old_id) + .fetch_optional(&state.db_pool) + .await; + + let old_row = match old_peer { + Ok(Some(row)) => row, + Ok(None) => { + return Ok(Json(ApiResponse { + success: false, + data: None, + error: Some(format!("Peer '{}' not found", old_id)), + timestamp: get_current_timestamp(), + })); + } + Err(e) => { + return Ok(Json(ApiResponse { + success: false, + data: None, + error: Some(format!("Database error: {}", e)), + timestamp: get_current_timestamp(), + })); + } + }; + + // Check if new_id already exists + let new_exists = sqlx::query("SELECT 1 FROM peer WHERE id = ? AND is_deleted = 0") + .bind(&new_id) + .fetch_optional(&state.db_pool) + .await; + + if let Ok(Some(_)) = new_exists { + return Ok(Json(ApiResponse { + success: false, + data: None, + error: Some(format!("ID '{}' is already in use", new_id)), + timestamp: get_current_timestamp(), + })); + } + + // Get and update previous_ids + let previous_ids_str: String = old_row.try_get("previous_ids").unwrap_or_default(); + let mut previous_ids: Vec = if previous_ids_str.is_empty() { + Vec::new() + } else { + serde_json::from_str(&previous_ids_str).unwrap_or_default() + }; + previous_ids.push(old_id.clone()); + let updated_history = serde_json::to_string(&previous_ids).unwrap_or_default(); + + let now = get_current_timestamp(); + + // Perform the update + let result = sqlx::query( + "UPDATE peer SET id = ?, previous_ids = ?, id_changed_at = ? WHERE id = ? AND is_deleted = 0" + ) + .bind(&new_id) + .bind(&updated_history) + .bind(&now) + .bind(&old_id) + .execute(&state.db_pool) + .await; + + match result { + Ok(res) if res.rows_affected() > 0 => { + hbb_common::log::info!("API: ID changed successfully: {} -> {}", old_id, new_id); + Ok(Json(ApiResponse { + success: true, + data: Some(ChangeIdResponse { + old_id, + new_id, + changed_at: now, + previous_ids, + }), + error: None, + timestamp: get_current_timestamp(), + })) + } + Ok(_) => { + Ok(Json(ApiResponse { + success: false, + data: None, + error: Some("No rows affected".to_string()), + timestamp: get_current_timestamp(), + })) + } + Err(e) => { + hbb_common::log::error!("API: Failed to change ID: {}", e); + Ok(Json(ApiResponse { + success: false, + data: None, + error: Some(format!("Failed to change ID: {}", e)), + timestamp: get_current_timestamp(), + })) + } + } +} + fn load_or_generate_api_key() -> String { if let Ok(key) = fs::read_to_string(API_KEY_FILE) { let key = key.trim().to_string(); @@ -243,7 +420,7 @@ pub async fn start_api_server(db_path: String, port: u16) -> Result<(), Box opts.read_only(true).create_if_missing(false), + Ok(opts) => opts.read_only(false).create_if_missing(false), Err(e) => { hbb_common::log::error!("API: Invalid database path: {}", e); return Err(e.into()); @@ -257,7 +434,7 @@ pub async fn start_api_server(db_path: String, port: u16) -> Result<(), Box Result<(), Box Result<(), Box ResultType<()> { let _logger = Logger::try_with_env_or_str("info")? @@ -30,7 +35,7 @@ fn main() -> ResultType<()> { -k, --key=[KEY] 'Only allow the client with the same key' -a, --api-port=[NUMBER(default={API_PORT})] 'Sets the HTTP API port'", ); - init_args(&args, "hbbs", "BetterDesk Enhanced Server v2.0.0"); + init_args(&args, "hbbs", "BetterDesk Enhanced Server v2.1.1"); let port = get_arg_or("port", RENDEZVOUS_PORT.to_string()).parse::()?; if port < 3 { @@ -41,7 +46,7 @@ fn main() -> ResultType<()> { let api_port = get_arg("api-port").parse::().unwrap_or(API_PORT); hbb_common::log::info!("========================================"); - hbb_common::log::info!(" BetterDesk Enhanced Server v2.0.0"); + hbb_common::log::info!(" BetterDesk Enhanced Server v2.1.1"); hbb_common::log::info!(" Based on RustDesk Server 1.1.14"); hbb_common::log::info!("========================================"); hbb_common::log::info!(" Signal Port: {}", port); diff --git a/hbbs-patch-v2/src/peer.rs b/hbbs-patch-v2/src/peer.rs index a0bfd295..f06123cc 100644 --- a/hbbs-patch-v2/src/peer.rs +++ b/hbbs-patch-v2/src/peer.rs @@ -1,4 +1,3 @@ -// Enhanced peer.rs with improved heartbeat and monitoring use crate::common::*; use crate::database; use hbb_common::{ @@ -6,6 +5,7 @@ use hbb_common::{ log, rendezvous_proto::*, tokio::sync::{Mutex, RwLock}, + tokio, ResultType, }; use serde_derive::{Deserialize, Serialize}; @@ -26,6 +26,10 @@ pub const IP_CHANGE_DUR_X2: u64 = IP_CHANGE_DUR * 2; pub const DAY_SECONDS: u64 = 3600 * 24; pub const IP_BLOCK_DUR: u64 = 60; +// Status tracking constants +const HEARTBEAT_TIMEOUT_SECS: u64 = 15; // Mark offline after 15s without heartbeat (was 30s) +const CLEANUP_INTERVAL_SECS: u64 = 60; // Check for stale peers every 60s + #[derive(Debug, Default, Serialize, Deserialize, Clone)] pub(crate) struct PeerInfo { #[serde(default)] @@ -35,57 +39,44 @@ pub(crate) struct PeerInfo { pub(crate) struct Peer { pub(crate) socket_addr: SocketAddr, pub(crate) last_reg_time: Instant, - pub(crate) last_heartbeat: Instant, // New: track last heartbeat separately pub(crate) guid: Vec, pub(crate) uuid: Bytes, pub(crate) pk: Bytes, pub(crate) info: PeerInfo, pub(crate) reg_pk: (u32, Instant), - pub(crate) connection_quality: ConnectionQuality, // New: track connection quality + // Track last heartbeat for online status + pub(crate) last_heartbeat: Instant, } -#[derive(Debug, Clone)] -pub(crate) struct ConnectionQuality { - pub(crate) last_response_time: Duration, - pub(crate) missed_heartbeats: u32, - pub(crate) total_heartbeats: u64, -} - -impl Default for ConnectionQuality { - fn default() -> Self { - Self { - last_response_time: Duration::from_millis(0), - missed_heartbeats: 0, - total_heartbeats: 0, - } - } -} - -use std::time::Duration; - impl Default for Peer { fn default() -> Self { Self { socket_addr: "0.0.0.0:0".parse().unwrap(), last_reg_time: get_expired_time(), - last_heartbeat: get_expired_time(), guid: Vec::new(), uuid: Bytes::new(), pk: Bytes::new(), info: Default::default(), reg_pk: (0, get_expired_time()), - connection_quality: Default::default(), + last_heartbeat: Instant::now(), } } } pub(crate) type LockPeer = Arc>; +/// Statistics about online peers +pub struct PeerStats { + pub total: usize, + pub healthy: usize, + pub degraded: usize, + pub critical: usize, +} + #[derive(Clone)] pub(crate) struct PeerMap { map: Arc>>, pub(crate) db: database::Database, - last_cleanup: Arc>, } impl PeerMap { @@ -104,15 +95,105 @@ impl PeerMap { } db }); + log::info!("DB_URL={}", db); + + let database = database::Database::new(&db).await?; + + // Reset all devices to offline on startup (clean slate) + if let Err(e) = database.set_all_offline().await { + log::warn!("Failed to reset devices to offline: {}", e); + } - log::info!("Initializing PeerMap with DB: {}", db); let pm = Self { map: Default::default(), - db: database::Database::new(&db).await?, - last_cleanup: Arc::new(RwLock::new(Instant::now())), + db: database, }; + + // Start background task to check for stale peers and set them offline + let pm_clone = pm.clone(); + tokio::spawn(async move { + pm_clone.status_cleanup_loop().await; + }); + Ok(pm) } + + /// Background loop to detect stale peers and mark them offline + async fn status_cleanup_loop(&self) { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(CLEANUP_INTERVAL_SECS)); + + loop { + interval.tick().await; + + let now = Instant::now(); + let timeout = std::time::Duration::from_secs(HEARTBEAT_TIMEOUT_SECS); + let mut stale_peers = Vec::new(); + + // Find stale peers + { + let map = self.map.read().await; + for (id, peer) in map.iter() { + let peer_data = peer.read().await; + if now.duration_since(peer_data.last_heartbeat) > timeout { + stale_peers.push(id.clone()); + } + } + } + + // Set stale peers offline and remove from memory + if !stale_peers.is_empty() { + log::info!("Marking {} stale peers as offline", stale_peers.len()); + + // Batch update database + if let Err(e) = self.db.batch_set_offline(&stale_peers).await { + log::error!("Failed to batch set offline: {}", e); + } + + // Remove from memory map + { + let mut map = self.map.write().await; + for id in &stale_peers { + map.remove(id); + log::debug!("Removed stale peer {} from memory", id); + } + } + } + + // Cleanup IP blocker and IP changes maps + self.cleanup_ip_maps().await; + } + } + + /// Cleanup stale entries from IP maps + async fn cleanup_ip_maps(&self) { + let now = Instant::now(); + + // Cleanup IP_BLOCKER + { + let mut blocker = IP_BLOCKER.lock().await; + blocker.retain(|_, ((_, t1), (_, t2))| { + now.duration_since(*t1).as_secs() < IP_BLOCK_DUR && + now.duration_since(*t2).as_secs() < DAY_SECONDS + }); + } + + // Cleanup IP_CHANGES + { + let mut changes = IP_CHANGES.lock().await; + changes.retain(|_, (t, _)| { + now.duration_since(*t).as_secs() < IP_CHANGE_DUR_X2 + }); + } + } + + /// Update heartbeat and set device online + pub(crate) async fn touch_peer(&self, id: &str) { + if let Some(peer) = self.map.read().await.get(id) { + peer.write().await.last_heartbeat = Instant::now(); + } + // Update database status + self.db.set_online(id).await; + } #[inline] pub(crate) async fn update_pk( @@ -124,7 +205,22 @@ impl PeerMap { pk: Bytes, ip: String, ) -> register_pk_response::Result { - log::debug!("update_pk {} {:?}", id, addr); + log::info!("update_pk {} {:?} {:?} {:?}", id, addr, uuid, pk); + + // BAN CHECK: Verify device is not banned before registration + match self.db.is_device_banned(&id).await { + Ok(true) => { + log::warn!("Registration REJECTED for device {}: DEVICE IS BANNED", id); + self.map.write().await.remove(&id); + return register_pk_response::Result::UUID_MISMATCH; + } + Ok(false) => { + log::debug!("Ban check passed for device {}", id); + } + Err(e) => { + log::error!("Failed to check ban status for device {}: {}. Allowing (fail-open)", id, e); + } + } let (info_str, guid) = { let mut w = peer.write().await; @@ -132,11 +228,8 @@ impl PeerMap { w.uuid = uuid.clone(); w.pk = pk.clone(); w.last_reg_time = Instant::now(); - w.last_heartbeat = Instant::now(); + w.last_heartbeat = Instant::now(); // Update heartbeat on registration w.info.ip = ip; - w.connection_quality.total_heartbeats += 1; - w.connection_quality.missed_heartbeats = 0; // Reset on successful registration - ( serde_json::to_string(&w.info).unwrap_or_default(), w.guid.clone(), @@ -151,7 +244,6 @@ impl PeerMap { } Ok(guid) => { peer.write().await.guid = guid; - log::info!("New peer registered: {}", id); } } } else { @@ -159,13 +251,11 @@ impl PeerMap { log::error!("db.update_pk failed: {}", err); return register_pk_response::Result::SERVER_ERROR; } - log::debug!("Peer {} updated", id); + log::info!("pk updated instead of insert"); } - // Set peer status to online (async, non-blocking) - if let Err(err) = self.db.set_online(&id).await { - log::error!("db.set_online failed for {}: {}", id, err); - } + // Device just registered, mark as online + self.db.set_online(&id).await; register_pk_response::Result::OK } @@ -176,16 +266,21 @@ impl PeerMap { if p.is_some() { return p; } else if let Ok(Some(v)) = self.db.get_peer(id).await { + // BAN CHECK: Do not load banned devices into memory + if let Ok(true) = self.db.is_device_banned(id).await { + log::warn!("Blocked loading banned device {} from database", id); + return None; + } let peer = Peer { guid: v.guid, uuid: v.uuid.into(), pk: v.pk.into(), info: serde_json::from_str::(&v.info).unwrap_or_default(), + last_heartbeat: Instant::now(), ..Default::default() }; let peer = Arc::new(RwLock::new(peer)); self.map.write().await.insert(id.to_owned(), peer.clone()); - log::debug!("Peer {} loaded from database", id); return Some(peer); } None @@ -215,141 +310,8 @@ impl PeerMap { self.map.read().await.contains_key(id) } - #[inline] - pub(crate) async fn set_offline(&self, id: &str) { - if let Err(err) = self.db.set_offline(id).await { - log::error!("db.set_offline failed for {}: {}", id, err); - } - } - - /// Enhanced peer checking with configurable timeout and better logging - pub(crate) async fn check_online_peers(&self) { - // Get configurable timeout from environment (default 15 seconds) - let timeout_secs = std::env::var("PEER_TIMEOUT_SECS") - .ok() - .and_then(|s| s.parse::().ok()) - .unwrap_or(15); // Reduced from 20 to 15 seconds - - let now = Instant::now(); - let mut offline_peers = Vec::new(); - let mut total_peers = 0; - let mut degraded_peers = 0; - - { - let map = self.map.read().await; - total_peers = map.len(); - - for (id, peer) in map.iter() { - let p = peer.read().await; - let last_heartbeat = p.last_heartbeat; - let elapsed = now.duration_since(last_heartbeat).as_secs(); - - // Check connection quality - if p.connection_quality.missed_heartbeats > 2 { - degraded_peers += 1; - log::warn!("Peer {} has degraded connection (missed {} heartbeats)", - id, p.connection_quality.missed_heartbeats); - } - - if elapsed > timeout_secs { - offline_peers.push((id.clone(), elapsed)); - } - } - } - - if !offline_peers.is_empty() { - log::info!("Found {} offline peers (total: {}, degraded: {})", - offline_peers.len(), total_peers, degraded_peers); - - // Batch offline updates for better performance - let ids: Vec = offline_peers.iter().map(|(id, _)| id.clone()).collect(); - if let Err(e) = self.db.batch_set_offline(&ids).await { - log::error!("Batch offline update failed: {}", e); - // Fallback to individual updates - for (id, elapsed) in &offline_peers { - log::info!("Setting peer {} as offline (last seen {}s ago)", id, elapsed); - self.set_offline(id).await; - } - } - - // Remove from memory - let mut map = self.map.write().await; - for (id, _) in offline_peers { - map.remove(&id); - } - } else if total_peers > 0 { - log::debug!("All {} peers are online", total_peers); - } - - // Periodic cleanup of stale entries - self.periodic_cleanup().await; - } - - /// Periodic cleanup of old entries to prevent memory leaks - async fn periodic_cleanup(&self) { - let mut last = self.last_cleanup.write().await; - - // Run cleanup every 5 minutes - if last.elapsed().as_secs() < 300 { - return; - } - - log::info!("Running periodic cleanup..."); - *last = Instant::now(); - - // Cleanup IP blocker - let mut ip_blocker = IP_BLOCKER.lock().await; - let before = ip_blocker.len(); - ip_blocker.retain(|_, (a, b)| { - a.1.elapsed().as_secs() <= IP_BLOCK_DUR - || b.1.elapsed().as_secs() <= DAY_SECONDS - }); - let removed = before - ip_blocker.len(); - if removed > 0 { - log::info!("Cleaned up {} entries from IP blocker", removed); - } - drop(ip_blocker); - - // Cleanup IP changes - let mut ip_changes = IP_CHANGES.lock().await; - let before = ip_changes.len(); - ip_changes.retain(|_, v| v.0.elapsed().as_secs() < IP_CHANGE_DUR_X2 && v.1.len() > 1); - let removed = before - ip_changes.len(); - if removed > 0 { - log::info!("Cleaned up {} entries from IP changes tracker", removed); - } - } - - /// Update heartbeat for a peer (lightweight operation) - #[inline] - pub(crate) async fn update_heartbeat(&self, id: &str) -> bool { - if let Some(peer) = self.get_in_memory(id).await { - let mut p = peer.write().await; - p.last_heartbeat = Instant::now(); - p.connection_quality.total_heartbeats += 1; - p.connection_quality.missed_heartbeats = 0; - true - } else { - false - } - } - - /// Record a missed heartbeat - #[inline] - pub(crate) async fn record_missed_heartbeat(&self, id: &str) { - if let Some(peer) = self.get_in_memory(id).await { - let mut p = peer.write().await; - p.connection_quality.missed_heartbeats += 1; - if p.connection_quality.missed_heartbeats > 3 { - log::warn!("Peer {} has missed {} consecutive heartbeats", - id, p.connection_quality.missed_heartbeats); - } - } - } - - /// Find peer ID by socket address - #[inline] - pub(crate) async fn find_by_addr(&self, addr: SocketAddr) -> Option { + /// Find device ID by socket address (for ban enforcement) + pub(crate) async fn get_id_by_addr(&self, addr: SocketAddr) -> Option { let map = self.map.read().await; for (id, peer) in map.iter() { let peer_addr = peer.read().await.socket_addr; @@ -360,36 +322,98 @@ impl PeerMap { None } - /// Get statistics about current peers - pub(crate) async fn get_stats(&self) -> PeerMapStats { + /// Get statistics about online peers + pub(crate) async fn get_stats(&self) -> PeerStats { let map = self.map.read().await; let total = map.len(); + let now = Instant::now(); + + let timeout_secs = std::env::var("PEER_TIMEOUT_SECS") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(15); + let warning_threshold = std::env::var("HEARTBEAT_WARNING_THRESHOLD") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(2); + let critical_threshold = std::env::var("HEARTBEAT_CRITICAL_THRESHOLD") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(4); + let heartbeat_interval = std::env::var("HEARTBEAT_INTERVAL_SECS") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(3); + let mut healthy = 0; let mut degraded = 0; let mut critical = 0; - for (_, peer) in map.iter() { - let p = peer.read().await; - match p.connection_quality.missed_heartbeats { - 0..=1 => healthy += 1, - 2..=3 => degraded += 1, - _ => critical += 1, + for (_id, peer) in map.iter() { + if let Ok(p) = peer.try_read() { + let elapsed = now.duration_since(p.last_heartbeat).as_secs(); + if elapsed <= timeout_secs { + let missed = elapsed / heartbeat_interval; + if missed >= critical_threshold { + critical += 1; + } else if missed >= warning_threshold { + degraded += 1; + } else { + healthy += 1; + } + } } } - PeerMapStats { - total, - healthy, - degraded, - critical, + PeerStats { total, healthy, degraded, critical } + } + + /// Check online peers and mark offline ones + pub(crate) async fn check_online_peers(&self) { + let timeout_secs = std::env::var("PEER_TIMEOUT_SECS") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(15); + + let now = Instant::now(); + let mut offline_peers = Vec::new(); + let mut online_peers = Vec::new(); + + { + let map = self.map.read().await; + for (id, peer) in map.iter() { + let p = peer.read().await; + let elapsed = now.duration_since(p.last_heartbeat).as_secs(); + + if elapsed > timeout_secs { + offline_peers.push(id.clone()); + } else { + online_peers.push(id.clone()); + } + } + } + + // Update online devices in database + for id in &online_peers { + self.db.set_online(id).await; + } + + // Mark offline devices + if !offline_peers.is_empty() { + log::info!("Setting {} peers as offline (timeout {}s)", offline_peers.len(), timeout_secs); + + if let Err(e) = self.db.batch_set_offline(&offline_peers).await { + log::error!("Batch offline update failed: {}", e); + for id in &offline_peers { + self.db.set_offline(id).await; + } + } + + // Remove from memory + let mut map = self.map.write().await; + for id in offline_peers { + map.remove(&id); + } } } } - -#[derive(Debug)] -pub(crate) struct PeerMapStats { - pub total: usize, - pub healthy: usize, - pub degraded: usize, - pub critical: usize, -} diff --git a/hbbs-patch-v2/src/peer_fixed.rs b/hbbs-patch-v2/src/peer_fixed.rs deleted file mode 100644 index 94e18765..00000000 --- a/hbbs-patch-v2/src/peer_fixed.rs +++ /dev/null @@ -1,335 +0,0 @@ -use crate::common::*; -use crate::database; -use hbb_common::{ - bytes::Bytes, - log, - rendezvous_proto::*, - tokio::sync::{Mutex, RwLock}, - tokio, - ResultType, -}; -use serde_derive::{Deserialize, Serialize}; -use std::{collections::HashMap, collections::HashSet, net::SocketAddr, sync::Arc, time::Instant}; - -type IpBlockMap = HashMap, Instant))>; -type UserStatusMap = HashMap, Arc<(Option>, bool)>>; -type IpChangesMap = HashMap)>; - -lazy_static::lazy_static! { - pub(crate) static ref IP_BLOCKER: Mutex = Default::default(); - pub(crate) static ref USER_STATUS: RwLock = Default::default(); - pub(crate) static ref IP_CHANGES: Mutex = Default::default(); -} - -pub const IP_CHANGE_DUR: u64 = 180; -pub const IP_CHANGE_DUR_X2: u64 = IP_CHANGE_DUR * 2; -pub const DAY_SECONDS: u64 = 3600 * 24; -pub const IP_BLOCK_DUR: u64 = 60; - -// Status tracking constants -const HEARTBEAT_TIMEOUT_SECS: u64 = 15; // Mark offline after 15s without heartbeat (was 30s) -const CLEANUP_INTERVAL_SECS: u64 = 60; // Check for stale peers every 60s - -#[derive(Debug, Default, Serialize, Deserialize, Clone)] -pub(crate) struct PeerInfo { - #[serde(default)] - pub(crate) ip: String, -} - -pub(crate) struct Peer { - pub(crate) socket_addr: SocketAddr, - pub(crate) last_reg_time: Instant, - pub(crate) guid: Vec, - pub(crate) uuid: Bytes, - pub(crate) pk: Bytes, - pub(crate) info: PeerInfo, - pub(crate) reg_pk: (u32, Instant), - // Track last heartbeat for online status - pub(crate) last_heartbeat: Instant, -} - -impl Default for Peer { - fn default() -> Self { - Self { - socket_addr: "0.0.0.0:0".parse().unwrap(), - last_reg_time: get_expired_time(), - guid: Vec::new(), - uuid: Bytes::new(), - pk: Bytes::new(), - info: Default::default(), - reg_pk: (0, get_expired_time()), - last_heartbeat: Instant::now(), - } - } -} - -pub(crate) type LockPeer = Arc>; - -#[derive(Clone)] -pub(crate) struct PeerMap { - map: Arc>>, - pub(crate) db: database::Database, -} - -impl PeerMap { - pub(crate) async fn new() -> ResultType { - let db = std::env::var("DB_URL").unwrap_or({ - let mut db = "db_v2.sqlite3".to_owned(); - #[cfg(all(windows, not(debug_assertions)))] - { - if let Some(path) = hbb_common::config::Config::icon_path().parent() { - db = format!("{}\\{}", path.to_str().unwrap_or("."), db); - } - } - #[cfg(not(windows))] - { - db = format!("./{db}"); - } - db - }); - log::info!("DB_URL={}", db); - - let database = database::Database::new(&db).await?; - - // Reset all devices to offline on startup (clean slate) - if let Err(e) = database.set_all_offline().await { - log::warn!("Failed to reset devices to offline: {}", e); - } - - let pm = Self { - map: Default::default(), - db: database, - }; - - // Start background task to check for stale peers and set them offline - let pm_clone = pm.clone(); - tokio::spawn(async move { - pm_clone.status_cleanup_loop().await; - }); - - Ok(pm) - } - - /// Background loop to detect stale peers and mark them offline - async fn status_cleanup_loop(&self) { - let mut interval = tokio::time::interval(std::time::Duration::from_secs(CLEANUP_INTERVAL_SECS)); - - loop { - interval.tick().await; - - let now = Instant::now(); - let timeout = std::time::Duration::from_secs(HEARTBEAT_TIMEOUT_SECS); - let mut stale_peers = Vec::new(); - - // Find stale peers - { - let map = self.map.read().await; - for (id, peer) in map.iter() { - let peer_data = peer.read().await; - if now.duration_since(peer_data.last_heartbeat) > timeout { - stale_peers.push(id.clone()); - } - } - } - - // Set stale peers offline and remove from memory - if !stale_peers.is_empty() { - log::info!("Marking {} stale peers as offline", stale_peers.len()); - - // Batch update database - if let Err(e) = self.db.batch_set_offline(&stale_peers).await { - log::error!("Failed to batch set offline: {}", e); - } - - // Remove from memory map - { - let mut map = self.map.write().await; - for id in &stale_peers { - map.remove(id); - log::debug!("Removed stale peer {} from memory", id); - } - } - } - - // Cleanup IP blocker and IP changes maps - self.cleanup_ip_maps().await; - } - } - - /// Cleanup stale entries from IP maps - async fn cleanup_ip_maps(&self) { - let now = Instant::now(); - - // Cleanup IP_BLOCKER - { - let mut blocker = IP_BLOCKER.lock().await; - blocker.retain(|_, ((_, t1), (_, t2))| { - now.duration_since(*t1).as_secs() < IP_BLOCK_DUR && - now.duration_since(*t2).as_secs() < DAY_SECONDS - }); - } - - // Cleanup IP_CHANGES - { - let mut changes = IP_CHANGES.lock().await; - changes.retain(|_, (t, _)| { - now.duration_since(*t).as_secs() < IP_CHANGE_DUR_X2 - }); - } - } - - /// Update heartbeat and set device online - pub(crate) async fn touch_peer(&self, id: &str) { - if let Some(peer) = self.map.read().await.get(id) { - peer.write().await.last_heartbeat = Instant::now(); - } - // Update database status - self.db.set_online(id).await; - } - - #[inline] - pub(crate) async fn update_pk( - &mut self, - id: String, - peer: LockPeer, - addr: SocketAddr, - uuid: Bytes, - pk: Bytes, - ip: String, - ) -> register_pk_response::Result { - log::info!("update_pk {} {:?} {:?} {:?}", id, addr, uuid, pk); - - // BAN CHECK: Verify device is not banned before registration - match self.db.is_device_banned(&id).await { - Ok(true) => { - log::warn!("Registration REJECTED for device {}: DEVICE IS BANNED", id); - self.map.write().await.remove(&id); - return register_pk_response::Result::UUID_MISMATCH; - } - Ok(false) => { - log::debug!("Ban check passed for device {}", id); - } - Err(e) => { - log::error!("Failed to check ban status for device {}: {}. Allowing (fail-open)", id, e); - } - } - - let (info_str, guid) = { - let mut w = peer.write().await; - w.socket_addr = addr; - w.uuid = uuid.clone(); - w.pk = pk.clone(); - w.last_reg_time = Instant::now(); - w.last_heartbeat = Instant::now(); // Update heartbeat on registration - w.info.ip = ip; - ( - serde_json::to_string(&w.info).unwrap_or_default(), - w.guid.clone(), - ) - }; - - if guid.is_empty() { - match self.db.insert_peer(&id, &uuid, &pk, &info_str).await { - Err(err) => { - log::error!("db.insert_peer failed: {}", err); - return register_pk_response::Result::SERVER_ERROR; - } - Ok(guid) => { - peer.write().await.guid = guid; - } - } - } else { - if let Err(err) = self.db.update_pk(&guid, &id, &pk, &info_str).await { - log::error!("db.update_pk failed: {}", err); - return register_pk_response::Result::SERVER_ERROR; - } - log::info!("pk updated instead of insert"); - } - - // Device just registered, mark as online - self.db.set_online(&id).await; - - register_pk_response::Result::OK - } - - #[inline] - pub(crate) async fn get(&self, id: &str) -> Option { - let p = self.map.read().await.get(id).cloned(); - if p.is_some() { - return p; - } else if let Ok(Some(v)) = self.db.get_peer(id).await { - // BAN CHECK: Do not load banned devices into memory - if let Ok(true) = self.db.is_device_banned(id).await { - log::warn!("Blocked loading banned device {} from database", id); - return None; - } - let peer = Peer { - guid: v.guid, - uuid: v.uuid.into(), - pk: v.pk.into(), - info: serde_json::from_str::(&v.info).unwrap_or_default(), - last_heartbeat: Instant::now(), - ..Default::default() - }; - let peer = Arc::new(RwLock::new(peer)); - self.map.write().await.insert(id.to_owned(), peer.clone()); - return Some(peer); - } - None - } - - #[inline] - pub(crate) async fn get_or(&self, id: &str) -> LockPeer { - if let Some(p) = self.get(id).await { - return p; - } - let mut w = self.map.write().await; - if let Some(p) = w.get(id) { - return p.clone(); - } - let tmp = LockPeer::default(); - w.insert(id.to_owned(), tmp.clone()); - tmp - } - - #[inline] - pub(crate) async fn get_in_memory(&self, id: &str) -> Option { - self.map.read().await.get(id).cloned() - } - - #[inline] - pub(crate) async fn is_in_memory(&self, id: &str) -> bool { - self.map.read().await.contains_key(id) - } - - /// Find device ID by socket address (for ban enforcement) - pub(crate) async fn get_id_by_addr(&self, addr: SocketAddr) -> Option { - let map = self.map.read().await; - for (id, peer) in map.iter() { - let peer_addr = peer.read().await.socket_addr; - if peer_addr == addr { - return Some(id.clone()); - } - } - None - } - - /// Get statistics about online peers - pub(crate) async fn get_stats(&self) -> (usize, usize) { - let map = self.map.read().await; - let total = map.len(); - let now = Instant::now(); - let timeout = std::time::Duration::from_secs(HEARTBEAT_TIMEOUT_SECS); - let online = map.values() - .filter(|p| { - // Check if we can read without blocking - if let Ok(peer) = p.try_read() { - now.duration_since(peer.last_heartbeat) <= timeout - } else { - false - } - }) - .count(); - (total, online) - } -} diff --git a/hbbs-patch-v2/src/rendezvous_server.rs b/hbbs-patch-v2/src/rendezvous_server.rs new file mode 100644 index 00000000..8eaf32c6 --- /dev/null +++ b/hbbs-patch-v2/src/rendezvous_server.rs @@ -0,0 +1,1327 @@ +use crate::common::*; +use crate::peer::*; +use hbb_common::{ + allow_err, bail, + bytes::{Bytes, BytesMut}, + bytes_codec::BytesCodec, + config, + futures::future::join_all, + futures_util::{ + sink::SinkExt, + stream::{SplitSink, StreamExt}, + }, + log, + protobuf::{Message as _, MessageField}, + rendezvous_proto::{ + register_pk_response::Result::{TOO_FREQUENT, UUID_MISMATCH}, + *, + }, + tcp::{listen_any, FramedStream}, + timeout, + tokio::{ + self, + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream}, + sync::{mpsc, Mutex}, + time::{interval, Duration}, + }, + tokio_util::codec::Framed, + try_into_v4, + udp::FramedSocket, + AddrMangle, ResultType, +}; +use ipnetwork::Ipv4Network; +use sodiumoxide::crypto::sign; +use std::{ + collections::HashMap, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, + sync::atomic::{AtomicBool, AtomicUsize, Ordering}, + sync::Arc, + time::Instant, +}; + +#[derive(Clone, Debug)] +enum Data { + Msg(Box, SocketAddr), + RelayServers0(String), + RelayServers(RelayServers), +} + +const REG_TIMEOUT: i32 = 30_000; +type TcpStreamSink = SplitSink, Bytes>; +type WsSink = SplitSink, tungstenite::Message>; +enum Sink { + TcpStream(TcpStreamSink), + Ws(WsSink), +} +type Sender = mpsc::UnboundedSender; +type Receiver = mpsc::UnboundedReceiver; +static ROTATION_RELAY_SERVER: AtomicUsize = AtomicUsize::new(0); +type RelayServers = Vec; +const CHECK_RELAY_TIMEOUT: u64 = 3_000; +static ALWAYS_USE_RELAY: AtomicBool = AtomicBool::new(false); + +#[derive(Clone)] +struct Inner { + serial: i32, + version: String, + software_url: String, + mask: Option, + local_ip: String, + sk: Option, +} + +#[derive(Clone)] +pub struct RendezvousServer { + tcp_punch: Arc>>, + pm: PeerMap, + tx: Sender, + relay_servers: Arc, + relay_servers0: Arc, + rendezvous_servers: Arc>, + inner: Arc, +} + +enum LoopFailure { + UdpSocket, + Listener3, + Listener2, + Listener, +} + +impl RendezvousServer { + #[tokio::main(flavor = "multi_thread")] + pub async fn start(port: i32, serial: i32, key: &str, rmem: usize) -> ResultType<()> { + let (key, sk) = Self::get_server_sk(key); + let nat_port = port - 1; + let ws_port = port + 2; + let pm = PeerMap::new().await?; + log::info!("serial={}", serial); + let rendezvous_servers = get_servers(&get_arg("rendezvous-servers"), "rendezvous-servers"); + log::info!("Listening on tcp/udp :{}", port); + log::info!("Listening on tcp :{}, extra port for NAT test", nat_port); + log::info!("Listening on websocket :{}", ws_port); + let mut socket = create_udp_listener(port, rmem).await?; + let (tx, mut rx) = mpsc::unbounded_channel::(); + let software_url = get_arg("software-url"); + let version = hbb_common::get_version_from_url(&software_url); + if !version.is_empty() { + log::info!("software_url: {}, version: {}", software_url, version); + } + let mask = get_arg("mask").parse().ok(); + let local_ip = if mask.is_none() { + "".to_owned() + } else { + get_arg_or( + "local-ip", + local_ip_address::local_ip() + .map(|x| x.to_string()) + .unwrap_or_default(), + ) + }; + let mut rs = Self { + tcp_punch: Arc::new(Mutex::new(HashMap::new())), + pm, + tx: tx.clone(), + relay_servers: Default::default(), + relay_servers0: Default::default(), + rendezvous_servers: Arc::new(rendezvous_servers), + inner: Arc::new(Inner { + serial, + version, + software_url, + sk, + mask, + local_ip, + }), + }; + log::info!("mask: {:?}", rs.inner.mask); + log::info!("local-ip: {:?}", rs.inner.local_ip); + std::env::set_var("PORT_FOR_API", port.to_string()); + rs.parse_relay_servers(&get_arg("relay-servers")); + let mut listener = create_tcp_listener(port).await?; + let mut listener2 = create_tcp_listener(nat_port).await?; + let mut listener3 = create_tcp_listener(ws_port).await?; + let test_addr = std::env::var("TEST_HBBS").unwrap_or_default(); + if std::env::var("ALWAYS_USE_RELAY") + .unwrap_or_default() + .to_uppercase() + == "Y" + { + ALWAYS_USE_RELAY.store(true, Ordering::SeqCst); + } + log::info!( + "ALWAYS_USE_RELAY={}", + if ALWAYS_USE_RELAY.load(Ordering::SeqCst) { + "Y" + } else { + "N" + } + ); + if test_addr.to_lowercase() != "no" { + let test_addr = if test_addr.is_empty() { + listener.local_addr()? + } else { + test_addr.parse()? + }; + tokio::spawn(async move { + if let Err(err) = test_hbbs(test_addr).await { + if test_addr.is_ipv6() && test_addr.ip().is_unspecified() { + let mut test_addr = test_addr; + test_addr.set_ip(IpAddr::V4(Ipv4Addr::UNSPECIFIED)); + if let Err(err) = test_hbbs(test_addr).await { + log::error!("Failed to run hbbs test with {test_addr}: {err}"); + std::process::exit(1); + } + } else { + log::error!("Failed to run hbbs test with {test_addr}: {err}"); + std::process::exit(1); + } + } + }); + }; + let main_task = async move { + loop { + log::info!("Start"); + match rs + .io_loop( + &mut rx, + &mut listener, + &mut listener2, + &mut listener3, + &mut socket, + &key, + ) + .await + { + LoopFailure::UdpSocket => { + drop(socket); + socket = create_udp_listener(port, rmem).await?; + } + LoopFailure::Listener => { + drop(listener); + listener = create_tcp_listener(port).await?; + } + LoopFailure::Listener2 => { + drop(listener2); + listener2 = create_tcp_listener(nat_port).await?; + } + LoopFailure::Listener3 => { + drop(listener3); + listener3 = create_tcp_listener(ws_port).await?; + } + } + } + }; + let listen_signal = listen_signal(); + tokio::select!( + res = main_task => res, + res = listen_signal => res, + ) + } + + async fn io_loop( + &mut self, + rx: &mut Receiver, + listener: &mut TcpListener, + listener2: &mut TcpListener, + listener3: &mut TcpListener, + socket: &mut FramedSocket, + key: &str, + ) -> LoopFailure { + let mut timer_check_relay = interval(Duration::from_millis(CHECK_RELAY_TIMEOUT)); + loop { + tokio::select! { + _ = timer_check_relay.tick() => { + if self.relay_servers0.len() > 1 { + let rs = self.relay_servers0.clone(); + let tx = self.tx.clone(); + tokio::spawn(async move { + check_relay_servers(rs, tx).await; + }); + } + } + Some(data) = rx.recv() => { + match data { + Data::Msg(msg, addr) => { allow_err!(socket.send(msg.as_ref(), addr).await); } + Data::RelayServers0(rs) => { self.parse_relay_servers(&rs); } + Data::RelayServers(rs) => { self.relay_servers = Arc::new(rs); } + } + } + res = socket.next() => { + match res { + Some(Ok((bytes, addr))) => { + if let Err(err) = self.handle_udp(&bytes, addr.into(), socket, key).await { + log::error!("udp failure: {}", err); + return LoopFailure::UdpSocket; + } + } + Some(Err(err)) => { + log::error!("udp failure: {}", err); + return LoopFailure::UdpSocket; + } + None => { + // unreachable!() ? + } + } + } + res = listener2.accept() => { + match res { + Ok((stream, addr)) => { + stream.set_nodelay(true).ok(); + self.handle_listener2(stream, addr).await; + } + Err(err) => { + log::error!("listener2.accept failed: {}", err); + return LoopFailure::Listener2; + } + } + } + res = listener3.accept() => { + match res { + Ok((stream, addr)) => { + stream.set_nodelay(true).ok(); + self.handle_listener(stream, addr, key, true).await; + } + Err(err) => { + log::error!("listener3.accept failed: {}", err); + return LoopFailure::Listener3; + } + } + } + res = listener.accept() => { + match res { + Ok((stream, addr)) => { + stream.set_nodelay(true).ok(); + self.handle_listener(stream, addr, key, false).await; + } + Err(err) => { + log::error!("listener.accept failed: {}", err); + return LoopFailure::Listener; + } + } + } + } + } + } + + #[inline] + async fn handle_udp( + &mut self, + bytes: &BytesMut, + addr: SocketAddr, + socket: &mut FramedSocket, + key: &str, + ) -> ResultType<()> { + if let Ok(msg_in) = RendezvousMessage::parse_from_bytes(bytes) { + match msg_in.union { + Some(rendezvous_message::Union::RegisterPeer(rp)) => { + // B registered + if !rp.id.is_empty() { + log::trace!("New peer registered: {:?} {:?}", &rp.id, &addr); + self.update_addr(rp.id, addr, socket).await?; + if self.inner.serial > rp.serial { + let mut msg_out = RendezvousMessage::new(); + msg_out.set_configure_update(ConfigUpdate { + serial: self.inner.serial, + rendezvous_servers: (*self.rendezvous_servers).clone(), + ..Default::default() + }); + socket.send(&msg_out, addr).await?; + } + } + } + Some(rendezvous_message::Union::RegisterPk(rk)) => { + if rk.uuid.is_empty() || rk.pk.is_empty() { + return Ok(()); + } + let id = rk.id; + let ip = addr.ip().to_string(); + if id.len() < 6 { + return send_rk_res(socket, addr, UUID_MISMATCH).await; + } else if !self.check_ip_blocker(&ip, &id).await { + return send_rk_res(socket, addr, TOO_FREQUENT).await; + } + let peer = self.pm.get_or(&id).await; + let (changed, ip_changed) = { + let peer = peer.read().await; + if peer.uuid.is_empty() { + (true, false) + } else { + if peer.uuid == rk.uuid { + if peer.info.ip != ip && peer.pk != rk.pk { + log::warn!( + "Peer {} ip/pk mismatch: {}/{:?} vs {}/{:?}", + id, + ip, + rk.pk, + peer.info.ip, + peer.pk, + ); + drop(peer); + return send_rk_res(socket, addr, UUID_MISMATCH).await; + } + } else { + log::warn!( + "Peer {} uuid mismatch: {:?} vs {:?}", + id, + rk.uuid, + peer.uuid + ); + drop(peer); + return send_rk_res(socket, addr, UUID_MISMATCH).await; + } + let ip_changed = peer.info.ip != ip; + ( + peer.uuid != rk.uuid || peer.pk != rk.pk || ip_changed, + ip_changed, + ) + } + }; + let mut req_pk = peer.read().await.reg_pk; + if req_pk.1.elapsed().as_secs() > 6 { + req_pk.0 = 0; + } else if req_pk.0 > 2 { + return send_rk_res(socket, addr, TOO_FREQUENT).await; + } + req_pk.0 += 1; + req_pk.1 = Instant::now(); + peer.write().await.reg_pk = req_pk; + if ip_changed { + let mut lock = IP_CHANGES.lock().await; + if let Some((tm, ips)) = lock.get_mut(&id) { + if tm.elapsed().as_secs() > IP_CHANGE_DUR { + *tm = Instant::now(); + ips.clear(); + ips.insert(ip.clone(), 1); + } else if let Some(v) = ips.get_mut(&ip) { + *v += 1; + } else { + ips.insert(ip.clone(), 1); + } + } else { + lock.insert( + id.clone(), + (Instant::now(), HashMap::from([(ip.clone(), 1)])), + ); + } + } + if changed { + self.pm.update_pk(id, peer, addr, rk.uuid, rk.pk, ip).await; + } + let mut msg_out = RendezvousMessage::new(); + msg_out.set_register_pk_response(RegisterPkResponse { + result: register_pk_response::Result::OK.into(), + ..Default::default() + }); + socket.send(&msg_out, addr).await? + } + Some(rendezvous_message::Union::PunchHoleRequest(ph)) => { + if self.pm.is_in_memory(&ph.id).await { + self.handle_udp_punch_hole_request(addr, ph, key).await?; + } else { + // not in memory, fetch from db with spawn in case blocking me + let mut me = self.clone(); + let key = key.to_owned(); + tokio::spawn(async move { + allow_err!(me.handle_udp_punch_hole_request(addr, ph, &key).await); + }); + } + } + Some(rendezvous_message::Union::PunchHoleSent(phs)) => { + self.handle_hole_sent(phs, addr, Some(socket)).await?; + } + Some(rendezvous_message::Union::LocalAddr(la)) => { + self.handle_local_addr(la, addr, Some(socket)).await?; + } + Some(rendezvous_message::Union::ConfigureUpdate(mut cu)) => { + if try_into_v4(addr).ip().is_loopback() && cu.serial > self.inner.serial { + let mut inner: Inner = (*self.inner).clone(); + inner.serial = cu.serial; + self.inner = Arc::new(inner); + self.rendezvous_servers = Arc::new( + cu.rendezvous_servers + .drain(..) + .filter(|x| { + !x.is_empty() + && test_if_valid_server(x, "rendezvous-server").is_ok() + }) + .collect(), + ); + log::info!( + "configure updated: serial={} rendezvous-servers={:?}", + self.inner.serial, + self.rendezvous_servers + ); + } + } + Some(rendezvous_message::Union::SoftwareUpdate(su)) => { + if !self.inner.version.is_empty() && su.url != self.inner.version { + let mut msg_out = RendezvousMessage::new(); + msg_out.set_software_update(SoftwareUpdate { + url: self.inner.software_url.clone(), + ..Default::default() + }); + socket.send(&msg_out, addr).await?; + } + } + _ => {} + } + } + Ok(()) + } + + #[inline] + async fn handle_tcp( + &mut self, + bytes: &[u8], + sink: &mut Option, + addr: SocketAddr, + key: &str, + ws: bool, + ) -> bool { + if let Ok(msg_in) = RendezvousMessage::parse_from_bytes(bytes) { + match msg_in.union { + Some(rendezvous_message::Union::PunchHoleRequest(ph)) => { + // there maybe several attempt, so sink can be none + if let Some(sink) = sink.take() { + self.tcp_punch.lock().await.insert(try_into_v4(addr), sink); + } + allow_err!(self.handle_tcp_punch_hole_request(addr, ph, key, ws).await); + return true; + } + Some(rendezvous_message::Union::RequestRelay(mut rf)) => { + // there maybe several attempt, so sink can be none + if let Some(sink) = sink.take() { + self.tcp_punch.lock().await.insert(try_into_v4(addr), sink); + } + if let Some(peer) = self.pm.get_in_memory(&rf.id).await { + let mut msg_out = RendezvousMessage::new(); + rf.socket_addr = AddrMangle::encode(addr).into(); + msg_out.set_request_relay(rf); + let peer_addr = peer.read().await.socket_addr; + self.tx.send(Data::Msg(msg_out.into(), peer_addr)).ok(); + } + return true; + } + Some(rendezvous_message::Union::RelayResponse(mut rr)) => { + let addr_b = AddrMangle::decode(&rr.socket_addr); + rr.socket_addr = Default::default(); + let id = rr.id(); + if !id.is_empty() { + let pk = self.get_pk(&rr.version, id.to_owned()).await; + rr.set_pk(pk); + } + let mut msg_out = RendezvousMessage::new(); + if !rr.relay_server.is_empty() { + if self.is_lan(addr_b) { + // https://github.com/rustdesk/rustdesk-server/issues/24 + rr.relay_server = self.inner.local_ip.clone(); + } else if rr.relay_server == self.inner.local_ip { + rr.relay_server = self.get_relay_server(addr.ip(), addr_b.ip()); + } + } + msg_out.set_relay_response(rr); + allow_err!(self.send_to_tcp_sync(msg_out, addr_b).await); + } + Some(rendezvous_message::Union::PunchHoleSent(phs)) => { + allow_err!(self.handle_hole_sent(phs, addr, None).await); + } + Some(rendezvous_message::Union::LocalAddr(la)) => { + allow_err!(self.handle_local_addr(la, addr, None).await); + } + Some(rendezvous_message::Union::TestNatRequest(tar)) => { + let mut msg_out = RendezvousMessage::new(); + let mut res = TestNatResponse { + port: addr.port() as _, + ..Default::default() + }; + if self.inner.serial > tar.serial { + let mut cu = ConfigUpdate::new(); + cu.serial = self.inner.serial; + cu.rendezvous_servers = (*self.rendezvous_servers).clone(); + res.cu = MessageField::from_option(Some(cu)); + } + msg_out.set_test_nat_response(res); + Self::send_to_sink(sink, msg_out).await; + } + Some(rendezvous_message::Union::RegisterPk(_)) => { + let res = register_pk_response::Result::NOT_SUPPORT; + let mut msg_out = RendezvousMessage::new(); + msg_out.set_register_pk_response(RegisterPkResponse { + result: res.into(), + ..Default::default() + }); + Self::send_to_sink(sink, msg_out).await; + } + _ => {} + } + } + false + } + + #[inline] + async fn update_addr( + &mut self, + id: String, + socket_addr: SocketAddr, + socket: &mut FramedSocket, + ) -> ResultType<()> { + let (request_pk, ip_change) = if let Some(old) = self.pm.get_in_memory(&id).await { + let mut old = old.write().await; + let ip = socket_addr.ip(); + let ip_change = if old.socket_addr.port() != 0 { + ip != old.socket_addr.ip() + } else { + ip.to_string() != old.info.ip + } && !ip.is_loopback(); + let request_pk = old.pk.is_empty() || ip_change; + if !request_pk { + old.socket_addr = socket_addr; + old.last_reg_time = Instant::now(); + } + let ip_change = if ip_change && old.reg_pk.0 <= 2 { + Some(if old.socket_addr.port() == 0 { + old.info.ip.clone() + } else { + old.socket_addr.to_string() + }) + } else { + None + }; + (request_pk, ip_change) + } else { + (true, None) + }; + if let Some(old) = ip_change { + log::info!("IP change of {} from {} to {}", id, old, socket_addr); + } + // Update database status for this peer + self.pm.touch_peer(&id).await; + let mut msg_out = RendezvousMessage::new(); + msg_out.set_register_peer_response(RegisterPeerResponse { + request_pk, + ..Default::default() + }); + socket.send(&msg_out, socket_addr).await + } + + #[inline] + async fn handle_hole_sent<'a>( + &mut self, + phs: PunchHoleSent, + addr: SocketAddr, + socket: Option<&'a mut FramedSocket>, + ) -> ResultType<()> { + // punch hole sent from B, tell A that B is ready to be connected + let addr_a = AddrMangle::decode(&phs.socket_addr); + log::debug!( + "{} punch hole response to {:?} from {:?}", + if socket.is_none() { "TCP" } else { "UDP" }, + &addr_a, + &addr + ); + let mut msg_out = RendezvousMessage::new(); + let mut p = PunchHoleResponse { + socket_addr: AddrMangle::encode(addr).into(), + pk: self.get_pk(&phs.version, phs.id).await, + relay_server: phs.relay_server.clone(), + ..Default::default() + }; + if let Ok(t) = phs.nat_type.enum_value() { + p.set_nat_type(t); + } + msg_out.set_punch_hole_response(p); + if let Some(socket) = socket { + socket.send(&msg_out, addr_a).await?; + } else { + self.send_to_tcp(msg_out, addr_a).await; + } + Ok(()) + } + + #[inline] + async fn handle_local_addr<'a>( + &mut self, + la: LocalAddr, + addr: SocketAddr, + socket: Option<&'a mut FramedSocket>, + ) -> ResultType<()> { + // relay local addrs of B to A + let addr_a = AddrMangle::decode(&la.socket_addr); + log::debug!( + "{} local addrs response to {:?} from {:?}", + if socket.is_none() { "TCP" } else { "UDP" }, + &addr_a, + &addr + ); + let mut msg_out = RendezvousMessage::new(); + let mut p = PunchHoleResponse { + socket_addr: la.local_addr.clone(), + pk: self.get_pk(&la.version, la.id).await, + relay_server: la.relay_server, + ..Default::default() + }; + p.set_is_local(true); + msg_out.set_punch_hole_response(p); + if let Some(socket) = socket { + socket.send(&msg_out, addr_a).await?; + } else { + self.send_to_tcp(msg_out, addr_a).await; + } + Ok(()) + } + + #[inline] + async fn handle_punch_hole_request( + &mut self, + addr: SocketAddr, + ph: PunchHoleRequest, + key: &str, + ws: bool, + ) -> ResultType<(RendezvousMessage, Option)> { + let mut ph = ph; + if !key.is_empty() && ph.licence_key != key { + let mut msg_out = RendezvousMessage::new(); + msg_out.set_punch_hole_response(PunchHoleResponse { + failure: punch_hole_response::Failure::LICENSE_MISMATCH.into(), + ..Default::default() + }); + return Ok((msg_out, None)); + } + let id = ph.id; + // punch hole request from A, relay to B, + // check if in same intranet first, + // fetch local addrs if in same intranet. + // because punch hole won't work if in the same intranet, + // all routers will drop such self-connections. + if let Some(peer) = self.pm.get(&id).await { + let (elapsed, peer_addr) = { + let r = peer.read().await; + (r.last_reg_time.elapsed().as_millis() as i32, r.socket_addr) + }; + if elapsed >= REG_TIMEOUT { + let mut msg_out = RendezvousMessage::new(); + msg_out.set_punch_hole_response(PunchHoleResponse { + failure: punch_hole_response::Failure::OFFLINE.into(), + ..Default::default() + }); + return Ok((msg_out, None)); + } + let mut msg_out = RendezvousMessage::new(); + let peer_is_lan = self.is_lan(peer_addr); + let is_lan = self.is_lan(addr); + let mut relay_server = self.get_relay_server(addr.ip(), peer_addr.ip()); + if ALWAYS_USE_RELAY.load(Ordering::SeqCst) || (peer_is_lan ^ is_lan) { + if peer_is_lan { + // https://github.com/rustdesk/rustdesk-server/issues/24 + relay_server = self.inner.local_ip.clone() + } + ph.nat_type = NatType::SYMMETRIC.into(); // will force relay + } + let same_intranet: bool = !ws + && (peer_is_lan && is_lan || { + match (peer_addr, addr) { + (SocketAddr::V4(a), SocketAddr::V4(b)) => a.ip() == b.ip(), + (SocketAddr::V6(a), SocketAddr::V6(b)) => a.ip() == b.ip(), + _ => false, + } + }); + let socket_addr = AddrMangle::encode(addr).into(); + if same_intranet { + log::debug!( + "Fetch local addr {:?} {:?} request from {:?}", + id, + peer_addr, + addr + ); + msg_out.set_fetch_local_addr(FetchLocalAddr { + socket_addr, + relay_server, + ..Default::default() + }); + } else { + log::debug!( + "Punch hole {:?} {:?} request from {:?}", + id, + peer_addr, + addr + ); + msg_out.set_punch_hole(PunchHole { + socket_addr, + nat_type: ph.nat_type, + relay_server, + ..Default::default() + }); + } + Ok((msg_out, Some(peer_addr))) + } else { + let mut msg_out = RendezvousMessage::new(); + msg_out.set_punch_hole_response(PunchHoleResponse { + failure: punch_hole_response::Failure::ID_NOT_EXIST.into(), + ..Default::default() + }); + Ok((msg_out, None)) + } + } + + #[inline] + async fn handle_online_request( + &mut self, + stream: &mut FramedStream, + peers: Vec, + ) -> ResultType<()> { + let mut states = BytesMut::zeroed((peers.len() + 7) / 8); + for (i, peer_id) in peers.iter().enumerate() { + if let Some(peer) = self.pm.get_in_memory(peer_id).await { + let elapsed = peer.read().await.last_reg_time.elapsed().as_millis() as i32; + // bytes index from left to right + let states_idx = i / 8; + let bit_idx = 7 - i % 8; + if elapsed < REG_TIMEOUT { + states[states_idx] |= 0x01 << bit_idx; + } + } + } + + let mut msg_out = RendezvousMessage::new(); + msg_out.set_online_response(OnlineResponse { + states: states.into(), + ..Default::default() + }); + stream.send(&msg_out).await?; + + Ok(()) + } + + #[inline] + async fn send_to_tcp(&mut self, msg: RendezvousMessage, addr: SocketAddr) { + let mut tcp = self.tcp_punch.lock().await.remove(&try_into_v4(addr)); + tokio::spawn(async move { + Self::send_to_sink(&mut tcp, msg).await; + }); + } + + #[inline] + async fn send_to_sink(sink: &mut Option, msg: RendezvousMessage) { + if let Some(sink) = sink.as_mut() { + if let Ok(bytes) = msg.write_to_bytes() { + match sink { + Sink::TcpStream(s) => { + allow_err!(s.send(Bytes::from(bytes)).await); + } + Sink::Ws(ws) => { + allow_err!(ws.send(tungstenite::Message::Binary(bytes)).await); + } + } + } + } + } + + #[inline] + async fn send_to_tcp_sync( + &mut self, + msg: RendezvousMessage, + addr: SocketAddr, + ) -> ResultType<()> { + let mut sink = self.tcp_punch.lock().await.remove(&try_into_v4(addr)); + Self::send_to_sink(&mut sink, msg).await; + Ok(()) + } + + #[inline] + async fn handle_tcp_punch_hole_request( + &mut self, + addr: SocketAddr, + ph: PunchHoleRequest, + key: &str, + ws: bool, + ) -> ResultType<()> { + let (msg, to_addr) = self.handle_punch_hole_request(addr, ph, key, ws).await?; + if let Some(addr) = to_addr { + self.tx.send(Data::Msg(msg.into(), addr))?; + } else { + self.send_to_tcp_sync(msg, addr).await?; + } + Ok(()) + } + + #[inline] + async fn handle_udp_punch_hole_request( + &mut self, + addr: SocketAddr, + ph: PunchHoleRequest, + key: &str, + ) -> ResultType<()> { + let (msg, to_addr) = self.handle_punch_hole_request(addr, ph, key, false).await?; + self.tx.send(Data::Msg( + msg.into(), + match to_addr { + Some(addr) => addr, + None => addr, + }, + ))?; + Ok(()) + } + + async fn check_ip_blocker(&self, ip: &str, id: &str) -> bool { + let mut lock = IP_BLOCKER.lock().await; + let now = Instant::now(); + if let Some(old) = lock.get_mut(ip) { + let counter = &mut old.0; + if counter.1.elapsed().as_secs() > IP_BLOCK_DUR { + counter.0 = 0; + } else if counter.0 > 30 { + return false; + } + counter.0 += 1; + counter.1 = now; + + let counter = &mut old.1; + let is_new = counter.0.get(id).is_none(); + if counter.1.elapsed().as_secs() > DAY_SECONDS { + counter.0.clear(); + } else if counter.0.len() > 300 { + return !is_new; + } + if is_new { + counter.0.insert(id.to_owned()); + } + counter.1 = now; + } else { + lock.insert(ip.to_owned(), ((0, now), (Default::default(), now))); + } + true + } + + fn parse_relay_servers(&mut self, relay_servers: &str) { + let rs = get_servers(relay_servers, "relay-servers"); + self.relay_servers0 = Arc::new(rs); + self.relay_servers = self.relay_servers0.clone(); + } + + fn get_relay_server(&self, _pa: IpAddr, _pb: IpAddr) -> String { + if self.relay_servers.is_empty() { + return "".to_owned(); + } else if self.relay_servers.len() == 1 { + return self.relay_servers[0].clone(); + } + let i = ROTATION_RELAY_SERVER.fetch_add(1, Ordering::SeqCst) % self.relay_servers.len(); + self.relay_servers[i].clone() + } + + async fn check_cmd(&self, cmd: &str) -> String { + use std::fmt::Write as _; + + let mut res = "".to_owned(); + let mut fds = cmd.trim().split(' '); + match fds.next() { + Some("h") => { + res = format!( + "{}\n{}\n{}\n{}\n{}\n{}\n", + "relay-servers(rs) ", + "reload-geo(rg)", + "ip-blocker(ib) [|] [-]", + "ip-changes(ic) [|] [-]", + "always-use-relay(aur)", + "test-geo(tg) " + ) + } + Some("relay-servers" | "rs") => { + if let Some(rs) = fds.next() { + self.tx.send(Data::RelayServers0(rs.to_owned())).ok(); + } else { + for ip in self.relay_servers.iter() { + let _ = writeln!(res, "{ip}"); + } + } + } + Some("ip-blocker" | "ib") => { + let mut lock = IP_BLOCKER.lock().await; + lock.retain(|&_, (a, b)| { + a.1.elapsed().as_secs() <= IP_BLOCK_DUR + || b.1.elapsed().as_secs() <= DAY_SECONDS + }); + res = format!("{}\n", lock.len()); + let ip = fds.next(); + let mut start = ip.map(|x| x.parse::().unwrap_or(-1)).unwrap_or(-1); + if start < 0 { + if let Some(ip) = ip { + if let Some((a, b)) = lock.get(ip) { + let _ = writeln!( + res, + "{}/{}s {}/{}s", + a.0, + a.1.elapsed().as_secs(), + b.0.len(), + b.1.elapsed().as_secs() + ); + } + if fds.next() == Some("-") { + lock.remove(ip); + } + } else { + start = 0; + } + } + if start >= 0 { + let mut it = lock.iter(); + for i in 0..(start + 10) { + let x = it.next(); + if x.is_none() { + break; + } + if i < start { + continue; + } + if let Some((ip, (a, b))) = x { + let _ = writeln!( + res, + "{}: {}/{}s {}/{}s", + ip, + a.0, + a.1.elapsed().as_secs(), + b.0.len(), + b.1.elapsed().as_secs() + ); + } + } + } + } + Some("ip-changes" | "ic") => { + let mut lock = IP_CHANGES.lock().await; + lock.retain(|&_, v| v.0.elapsed().as_secs() < IP_CHANGE_DUR_X2 && v.1.len() > 1); + res = format!("{}\n", lock.len()); + let id = fds.next(); + let mut start = id.map(|x| x.parse::().unwrap_or(-1)).unwrap_or(-1); + if !(0..=10_000_000).contains(&start) { + if let Some(id) = id { + if let Some((tm, ips)) = lock.get(id) { + let _ = writeln!(res, "{}s {:?}", tm.elapsed().as_secs(), ips); + } + if fds.next() == Some("-") { + lock.remove(id); + } + } else { + start = 0; + } + } + if start >= 0 { + let mut it = lock.iter(); + for i in 0..(start + 10) { + let x = it.next(); + if x.is_none() { + break; + } + if i < start { + continue; + } + if let Some((id, (tm, ips))) = x { + let _ = writeln!(res, "{}: {}s {:?}", id, tm.elapsed().as_secs(), ips,); + } + } + } + } + Some("always-use-relay" | "aur") => { + if let Some(rs) = fds.next() { + if rs.to_uppercase() == "Y" { + ALWAYS_USE_RELAY.store(true, Ordering::SeqCst); + } else { + ALWAYS_USE_RELAY.store(false, Ordering::SeqCst); + } + self.tx.send(Data::RelayServers0(rs.to_owned())).ok(); + } else { + let _ = writeln!( + res, + "ALWAYS_USE_RELAY: {:?}", + ALWAYS_USE_RELAY.load(Ordering::SeqCst) + ); + } + } + Some("test-geo" | "tg") => { + if let Some(rs) = fds.next() { + if let Ok(a) = rs.parse::() { + if let Some(rs) = fds.next() { + if let Ok(b) = rs.parse::() { + res = format!("{:?}", self.get_relay_server(a, b)); + } + } else { + res = format!("{:?}", self.get_relay_server(a, a)); + } + } + } + } + _ => {} + } + res + } + + async fn handle_listener2(&self, stream: TcpStream, addr: SocketAddr) { + let mut rs = self.clone(); + if addr.ip().is_loopback() { + tokio::spawn(async move { + let mut stream = stream; + let mut buffer = [0; 1024]; + if let Ok(Ok(n)) = timeout(1000, stream.read(&mut buffer[..])).await { + if let Ok(data) = std::str::from_utf8(&buffer[..n]) { + let res = rs.check_cmd(data).await; + stream.write(res.as_bytes()).await.ok(); + } + } + }); + return; + } + let stream = FramedStream::from(stream, addr); + tokio::spawn(async move { + let mut stream = stream; + if let Some(Ok(bytes)) = stream.next_timeout(30_000).await { + if let Ok(msg_in) = RendezvousMessage::parse_from_bytes(&bytes) { + match msg_in.union { + Some(rendezvous_message::Union::TestNatRequest(_)) => { + let mut msg_out = RendezvousMessage::new(); + msg_out.set_test_nat_response(TestNatResponse { + port: addr.port() as _, + ..Default::default() + }); + stream.send(&msg_out).await.ok(); + } + Some(rendezvous_message::Union::OnlineRequest(or)) => { + allow_err!(rs.handle_online_request(&mut stream, or.peers).await); + } + _ => {} + } + } + } + }); + } + + async fn handle_listener(&self, stream: TcpStream, addr: SocketAddr, key: &str, ws: bool) { + log::debug!("Tcp connection from {:?}, ws: {}", addr, ws); + let mut rs = self.clone(); + let key = key.to_owned(); + tokio::spawn(async move { + allow_err!(rs.handle_listener_inner(stream, addr, &key, ws).await); + }); + } + + #[inline] + async fn handle_listener_inner( + &mut self, + stream: TcpStream, + mut addr: SocketAddr, + key: &str, + ws: bool, + ) -> ResultType<()> { + let mut sink; + if ws { + use tokio_tungstenite::tungstenite::handshake::server::{Request, Response}; + let callback = |req: &Request, response: Response| { + let headers = req.headers(); + let real_ip = headers + .get("X-Real-IP") + .or_else(|| headers.get("X-Forwarded-For")) + .and_then(|header_value| header_value.to_str().ok()); + if let Some(ip) = real_ip { + if ip.contains('.') { + addr = format!("{ip}:0").parse().unwrap_or(addr); + } else { + addr = format!("[{ip}]:0").parse().unwrap_or(addr); + } + } + Ok(response) + }; + let ws_stream = tokio_tungstenite::accept_hdr_async(stream, callback).await?; + let (a, mut b) = ws_stream.split(); + sink = Some(Sink::Ws(a)); + while let Ok(Some(Ok(msg))) = timeout(30_000, b.next()).await { + if let tungstenite::Message::Binary(bytes) = msg { + if !self.handle_tcp(&bytes, &mut sink, addr, key, ws).await { + break; + } + } + } + } else { + let (a, mut b) = Framed::new(stream, BytesCodec::new()).split(); + sink = Some(Sink::TcpStream(a)); + while let Ok(Some(Ok(bytes))) = timeout(30_000, b.next()).await { + if !self.handle_tcp(&bytes, &mut sink, addr, key, ws).await { + break; + } + } + } + if sink.is_none() { + self.tcp_punch.lock().await.remove(&try_into_v4(addr)); + } + log::debug!("Tcp connection from {:?} closed", addr); + Ok(()) + } + + #[inline] + async fn get_pk(&mut self, version: &str, id: String) -> Bytes { + if version.is_empty() || self.inner.sk.is_none() { + Bytes::new() + } else { + match self.pm.get(&id).await { + Some(peer) => { + let pk = peer.read().await.pk.clone(); + sign::sign( + &hbb_common::message_proto::IdPk { + id, + pk, + ..Default::default() + } + .write_to_bytes() + .unwrap_or_default(), + self.inner.sk.as_ref().unwrap(), + ) + .into() + } + _ => Bytes::new(), + } + } + } + + #[inline] + fn get_server_sk(key: &str) -> (String, Option) { + let mut out_sk = None; + let mut key = key.to_owned(); + if let Ok(sk) = base64::decode(&key) { + if sk.len() == sign::SECRETKEYBYTES { + log::info!("The key is a crypto private key"); + key = base64::encode(&sk[(sign::SECRETKEYBYTES / 2)..]); + let mut tmp = [0u8; sign::SECRETKEYBYTES]; + tmp[..].copy_from_slice(&sk); + out_sk = Some(sign::SecretKey(tmp)); + } + } + + if key.is_empty() || key == "-" || key == "_" { + let (pk, sk) = crate::common::gen_sk(0); + out_sk = sk; + if !key.is_empty() { + key = pk; + } + } + + if !key.is_empty() { + log::info!("Key: {}", key); + } + (key, out_sk) + } + + #[inline] + fn is_lan(&self, addr: SocketAddr) -> bool { + if let Some(network) = &self.inner.mask { + match addr { + SocketAddr::V4(v4_socket_addr) => { + return network.contains(*v4_socket_addr.ip()); + } + + SocketAddr::V6(v6_socket_addr) => { + if let Some(v4_addr) = v6_socket_addr.ip().to_ipv4() { + return network.contains(v4_addr); + } + } + } + } + false + } +} + +async fn check_relay_servers(rs0: Arc, tx: Sender) { + let mut futs = Vec::new(); + let rs = Arc::new(Mutex::new(Vec::new())); + for x in rs0.iter() { + let mut host = x.to_owned(); + if !host.contains(':') { + host = format!("{}:{}", host, config::RELAY_PORT); + } + let rs = rs.clone(); + let x = x.clone(); + futs.push(tokio::spawn(async move { + if FramedStream::new(&host, None, CHECK_RELAY_TIMEOUT) + .await + .is_ok() + { + rs.lock().await.push(x); + } + })); + } + join_all(futs).await; + log::debug!("check_relay_servers"); + let rs = std::mem::take(&mut *rs.lock().await); + if !rs.is_empty() { + tx.send(Data::RelayServers(rs)).ok(); + } +} + +// temp solution to solve udp socket failure +async fn test_hbbs(addr: SocketAddr) -> ResultType<()> { + let mut addr = addr; + if addr.ip().is_unspecified() { + addr.set_ip(if addr.is_ipv4() { + IpAddr::V4(Ipv4Addr::LOCALHOST) + } else { + IpAddr::V6(Ipv6Addr::LOCALHOST) + }); + } + + let mut socket = FramedSocket::new(config::Config::get_any_listen_addr(addr.is_ipv4())).await?; + let mut msg_out = RendezvousMessage::new(); + msg_out.set_register_peer(RegisterPeer { + id: "(:test_hbbs:)".to_owned(), + ..Default::default() + }); + let mut last_time_recv = Instant::now(); + + let mut timer = interval(Duration::from_secs(1)); + loop { + tokio::select! { + _ = timer.tick() => { + if last_time_recv.elapsed().as_secs() > 12 { + bail!("Timeout of test_hbbs"); + } + socket.send(&msg_out, addr).await?; + } + Some(Ok((bytes, _))) = socket.next() => { + if let Ok(msg_in) = RendezvousMessage::parse_from_bytes(&bytes) { + log::trace!("Recv {:?} of test_hbbs", msg_in); + last_time_recv = Instant::now(); + } + } + } + } +} + +#[inline] +async fn send_rk_res( + socket: &mut FramedSocket, + addr: SocketAddr, + res: register_pk_response::Result, +) -> ResultType<()> { + let mut msg_out = RendezvousMessage::new(); + msg_out.set_register_pk_response(RegisterPkResponse { + result: res.into(), + ..Default::default() + }); + socket.send(&msg_out, addr).await +} + +async fn create_udp_listener(port: i32, rmem: usize) -> ResultType { + let addr = SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), port as _); + if let Ok(s) = FramedSocket::new_reuse(&addr, true, rmem).await { + log::debug!("listen on udp {:?}", s.local_addr()); + return Ok(s); + } + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port as _); + let s = FramedSocket::new_reuse(&addr, true, rmem).await?; + log::debug!("listen on udp {:?}", s.local_addr()); + Ok(s) +} + +#[inline] +async fn create_tcp_listener(port: i32) -> ResultType { + let s = listen_any(port as _).await?; + log::debug!("listen on tcp {:?}", s.local_addr()); + Ok(s) +} diff --git a/web/app.py b/web/app.py index ca445b5b..71f35fc1 100644 --- a/web/app.py +++ b/web/app.py @@ -1,5 +1,6 @@ from flask import Flask, render_template, request, jsonify, redirect, url_for, g, send_file import sqlite3 +import json from datetime import datetime import os import requests @@ -318,57 +319,95 @@ def client_generator(): @require_auth @limiter.exempt # Authenticated users bypass rate limit def get_devices(): - """Fetch all devices from the database with online status from HBBS API.""" + """Fetch all devices from the database with online status based on last_online.""" try: - # Try to get status from HBBS API - online_ids = set() - api_device_info = {} - try: - headers = {} - if HBBS_API_KEY: - headers['X-API-Key'] = HBBS_API_KEY - - response = requests.get(f'{HBBS_API_URL}/peers', headers=headers, timeout=2) - if response.status_code == 200: - api_data = response.json() - if api_data.get('success') and api_data.get('data'): - for peer in api_data['data']: - device_id = peer.get('id') - if device_id: - api_device_info[device_id] = peer - if peer.get('online'): - online_ids.add(device_id) - elif response.status_code == 401: - print(f"Warning: HBBS API authentication failed. Check API key.") - except Exception as e: - print(f"Warning: Could not connect to HBBS API: {e}") + # Get server config for timeout settings + config = get_server_config() + peer_timeout_secs = config.get('peer_timeout_secs', 60) + warning_threshold = config.get('warning_threshold', 2) + critical_threshold = config.get('critical_threshold', 4) + heartbeat_interval = config.get('heartbeat_interval_secs', 5) # Get devices from database conn = get_db_connection() cursor = conn.cursor() - cursor.execute(''' - SELECT - guid, id, uuid, pk, created_at, user, status, note, info, - is_banned, banned_at, banned_by, ban_reason - FROM peer - WHERE is_deleted = 0 - ORDER BY created_at DESC - ''') + + # Check if last_online column exists + cursor.execute("PRAGMA table_info(peer)") + columns = [row[1] for row in cursor.fetchall()] + has_last_online = 'last_online' in columns + + if has_last_online: + cursor.execute(''' + SELECT + guid, id, uuid, pk, created_at, user, status, note, info, + is_banned, banned_at, banned_by, ban_reason, + previous_ids, id_changed_at, last_online + FROM peer + WHERE is_deleted = 0 + ORDER BY created_at DESC + ''') + else: + cursor.execute(''' + SELECT + guid, id, uuid, pk, created_at, user, status, note, info, + is_banned, banned_at, banned_by, ban_reason, + previous_ids, id_changed_at + FROM peer + WHERE is_deleted = 0 + ORDER BY created_at DESC + ''') devices = [] + now = datetime.now() + for row in cursor.fetchall(): device_id = row['id'] - # IMPORTANT: API /peers has a bug in http_api.rs: - # 1. Query: "WHERE (status IS NULL OR status = 0)" - returns only offline - # 2. Hardcoded: online = false - always false for all - # Due to these bugs, the API is not suitable for determining online status - # - # SOLUTION: We use ONLY the status field from database (updated by HBBS) - # status = 1 = online, status = 0/NULL = offline - # This is the source of truth - HBBS updates this field in real-time + # Determine online status based on last_online timestamp + online = False + status_detail = 'offline' + last_online = None - online = row['status'] == 1 + if has_last_online and row['last_online']: + last_online = row['last_online'] + try: + # Parse timestamp - handle both formats + if isinstance(last_online, str): + if 'T' in last_online: + last_online_dt = datetime.fromisoformat(last_online.replace('Z', '+00:00').replace('+00:00', '')) + else: + last_online_dt = datetime.strptime(last_online, '%Y-%m-%d %H:%M:%S') + else: + last_online_dt = last_online + + # Calculate time since last activity + seconds_since = (now - last_online_dt).total_seconds() + + if seconds_since <= peer_timeout_secs: + # Within timeout - check for degraded/critical states + missed_heartbeats = int(seconds_since / heartbeat_interval) + + if missed_heartbeats >= critical_threshold: + online = True + status_detail = 'critical' + elif missed_heartbeats >= warning_threshold: + online = True + status_detail = 'degraded' + else: + online = True + status_detail = 'online' + else: + status_detail = 'offline' + except Exception as e: + print(f"Warning: Could not parse last_online for {device_id}: {e}") + # Fallback to status field + online = row['status'] == 1 + status_detail = 'online' if online else 'offline' + else: + # No last_online - fallback to status field + online = row['status'] == 1 + status_detail = 'online' if online else 'offline' device = { 'guid': row['guid'].hex() if row['guid'] else '', @@ -379,17 +418,21 @@ def get_devices(): 'user': row['user'].hex() if row['user'] else '', 'status': row['status'], 'online': online, + 'status_detail': status_detail, + 'last_online': last_online, 'note': row['note'] or '', 'info': row['info'] or '', 'is_banned': row['is_banned'] == 1, 'banned_at': row['banned_at'], 'banned_by': row['banned_by'] or '', - 'ban_reason': row['ban_reason'] or '' + 'ban_reason': row['ban_reason'] or '', + 'previous_ids': json.loads(row['previous_ids']) if row['previous_ids'] else [], + 'id_changed_at': row['id_changed_at'] or '' } devices.append(device) conn.close() - return jsonify({'success': True, 'devices': devices}) + return jsonify({'success': True, 'devices': devices, 'config': config}) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 @@ -435,8 +478,23 @@ def update_device(device_id): conn.close() return jsonify({'success': False, 'error': 'Device ID already exists'}), 409 + # Get current previous_ids and add old_id to history + cursor.execute('SELECT previous_ids FROM peer WHERE id = ? AND is_deleted = 0', (device_id,)) + row = cursor.fetchone() + previous_ids = [] + if row and row[0]: + try: + previous_ids = json.loads(row[0]) if row[0] else [] + except: + previous_ids = [] + previous_ids.append(device_id) + updates.append('id = ?') params.append(data['new_id']) + updates.append('previous_ids = ?') + params.append(json.dumps(previous_ids)) + updates.append('id_changed_at = ?') + params.append(datetime.now().strftime('%Y-%m-%d %H:%M:%S')) if not updates: conn.close() @@ -551,6 +609,136 @@ def get_stats(): return jsonify({'success': False, 'error': str(e)}), 500 +# Default server configuration +DEFAULT_SERVER_CONFIG = { + 'peer_timeout_secs': 60, + 'heartbeat_interval_secs': 5, + 'warning_threshold': 2, + 'critical_threshold': 4 +} + +def ensure_server_config_table(): + """Ensure server_config table exists.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(''' + CREATE TABLE IF NOT EXISTS server_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + conn.commit() + conn.close() + +def get_server_config(): + """Get server configuration from database.""" + ensure_server_config_table() + config = DEFAULT_SERVER_CONFIG.copy() + try: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute('SELECT key, value FROM server_config') + rows = cursor.fetchall() + conn.close() + for row in rows: + key = row['key'] + if key in config: + try: + config[key] = int(row['value']) + except ValueError: + config[key] = row['value'] + except Exception as e: + print(f"Warning: Could not load server config: {e}") + return config + +def save_server_config(config): + """Save server configuration to database.""" + ensure_server_config_table() + conn = get_db_connection() + cursor = conn.cursor() + for key, value in config.items(): + cursor.execute(''' + INSERT OR REPLACE INTO server_config (key, value, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ''', (key, str(value))) + conn.commit() + conn.close() + + +@app.route('/api/server/config', methods=['GET']) +@require_auth +@limiter.exempt +def get_server_config_endpoint(): + """Get server configuration.""" + try: + config = get_server_config() + return jsonify({ + 'success': True, + 'config': config + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/server/config', methods=['POST']) +@require_auth +@require_role(ROLE_ADMIN) +def update_server_config_endpoint(): + """Update server configuration (admin only).""" + try: + data = request.get_json() + if not data: + return jsonify({'success': False, 'error': 'No data provided'}), 400 + + # Validate and sanitize config values + config = get_server_config() + + if 'peer_timeout_secs' in data: + val = int(data['peer_timeout_secs']) + if val < 10 or val > 300: + return jsonify({'success': False, 'error': 'peer_timeout_secs must be between 10 and 300'}), 400 + config['peer_timeout_secs'] = val + + if 'heartbeat_interval_secs' in data: + val = int(data['heartbeat_interval_secs']) + if val < 1 or val > 30: + return jsonify({'success': False, 'error': 'heartbeat_interval_secs must be between 1 and 30'}), 400 + config['heartbeat_interval_secs'] = val + + if 'warning_threshold' in data: + val = int(data['warning_threshold']) + if val < 1 or val > 10: + return jsonify({'success': False, 'error': 'warning_threshold must be between 1 and 10'}), 400 + config['warning_threshold'] = val + + if 'critical_threshold' in data: + val = int(data['critical_threshold']) + if val < 2 or val > 20: + return jsonify({'success': False, 'error': 'critical_threshold must be between 2 and 20'}), 400 + config['critical_threshold'] = val + + # Validate thresholds relationship + if config['warning_threshold'] >= config['critical_threshold']: + return jsonify({'success': False, 'error': 'warning_threshold must be less than critical_threshold'}), 400 + + save_server_config(config) + + # Log the change + user_id = g.user.get('id') if g.user else None + log_audit(user_id, 'config_change', None, f"Server config updated: {config}") + + return jsonify({ + 'success': True, + 'message': 'Configuration saved successfully', + 'config': config + }) + except ValueError as e: + return jsonify({'success': False, 'error': 'Invalid numeric value'}), 400 + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + @app.route('/api/device//ban', methods=['POST']) @require_auth @require_role(ROLE_ADMIN, ROLE_OPERATOR) diff --git a/web/static/script.js b/web/static/script.js index 6153f14f..5cc0e97c 100644 --- a/web/static/script.js +++ b/web/static/script.js @@ -6,6 +6,7 @@ let authToken = null; let userRole = null; let username = null; let publicKeyCache = null; +let refreshIntervalId = null; // Initialize on page load document.addEventListener('DOMContentLoaded', function() { @@ -22,15 +23,45 @@ document.addEventListener('DOMContentLoaded', function() { loadDevices(); loadStats(); - // Auto-refresh dashboard every 5 seconds - setInterval(() => { + // Load server config first, then start auto-refresh with configured interval + loadServerConfigAndStartRefresh(); +}); + +// Load server config and start auto-refresh with configured interval +async function loadServerConfigAndStartRefresh() { + try { + const response = await fetch('/api/server/config', { + headers: getAuthHeaders() + }); + + if (response.ok) { + const data = await response.json(); + if (data.success && data.config) { + serverConfig = data.config; + console.log('Server config loaded:', serverConfig); + } + } + } catch (error) { + console.warn('Could not load server config, using defaults'); + } + + // Start auto-refresh with configured interval (default 5 seconds, min 3 seconds) + const refreshInterval = Math.max(3, serverConfig.heartbeat_interval_secs || 5) * 1000; + console.log('Starting auto-refresh every', refreshInterval, 'ms'); + + // Clear any existing interval + if (refreshIntervalId) { + clearInterval(refreshIntervalId); + } + + refreshIntervalId = setInterval(() => { const dashboardSection = document.getElementById('dashboard'); if (dashboardSection && dashboardSection.classList.contains('active')) { loadDevices(); loadStats(); } - }, 5000); -}); + }, refreshInterval); +} // Authentication check function checkAuth() { @@ -90,6 +121,9 @@ function setupSidebar() { if (sectionId === 'users' && userRole === 'admin') { loadUsers(); } + if (sectionId === 'settings' && userRole === 'admin') { + loadServerSettings(); + } } }); }); @@ -222,6 +256,34 @@ function renderDevices(devices) { const canEdit = userRole === 'admin' || userRole === 'operator'; const canBan = userRole === 'admin' || userRole === 'operator'; + // Handle detailed status (online/degraded/critical/offline) + const statusDetail = device.status_detail || (device.online ? 'online' : 'offline'); + let statusClass = 'status-inactive'; + let statusIcon = 'fa-circle'; + let statusText = 'Offline'; + + switch (statusDetail) { + case 'online': + statusClass = 'status-active'; + statusIcon = 'fa-circle'; + statusText = 'Online'; + break; + case 'degraded': + statusClass = 'status-warning'; + statusIcon = 'fa-exclamation-circle'; + statusText = 'Degraded'; + break; + case 'critical': + statusClass = 'status-critical'; + statusIcon = 'fa-exclamation-triangle'; + statusText = 'Critical'; + break; + default: + statusClass = 'status-inactive'; + statusIcon = 'fa-circle'; + statusText = 'Offline'; + } + return ` @@ -230,9 +292,9 @@ function renderDevices(devices) { ${escapeHtml(device.note) || 'No note'} - - - ${device.online ? 'Online' : 'Offline'} + + + ${statusText} ${formatDate(device.created_at)} @@ -341,9 +403,14 @@ function showDetails(deviceId) {
Status:
- - - ${device.online ? 'Online' : 'Offline'} + + + ${device.status_detail === 'degraded' ? 'Degraded' : + device.status_detail === 'critical' ? 'Critical' : + device.online ? 'Online' : 'Offline'}
@@ -388,6 +455,23 @@ function editDevice(deviceId) { document.getElementById('editNewId').value = ''; document.getElementById('editNote').value = device.note || ''; + // Show ID history if available + const historyGroup = document.getElementById('idHistoryGroup'); + const historyList = document.getElementById('idHistoryList'); + + if (device.previous_ids && device.previous_ids.length > 0) { + historyGroup.style.display = 'block'; + historyList.innerHTML = device.previous_ids.map(id => + `${escapeHtml(id)}` + ).join(' β†’ '); + if (device.id_changed_at) { + historyList.innerHTML += `
Last changed: ${device.id_changed_at}`; + } + } else { + historyGroup.style.display = 'none'; + historyList.innerHTML = ''; + } + openModal('editModal'); } @@ -839,6 +923,108 @@ window.onclick = function(event) { } } +// ============================================================================ +// SERVER SETTINGS (ADMIN ONLY) +// ============================================================================ + +// Global server config cache +let serverConfig = { + peer_timeout_secs: 60, + heartbeat_interval_secs: 5, + warning_threshold: 2, + critical_threshold: 4 +}; + +async function loadServerSettings() { + if (!checkAuth()) return; + if (userRole !== 'admin') return; + + try { + const response = await fetch('/api/server/config', { + headers: getAuthHeaders() + }); + + if (handleAuthError(null, response)) return; + + const data = await response.json(); + + if (data.success && data.config) { + serverConfig = data.config; + + // Update form fields + document.getElementById('peerTimeoutSecs').value = serverConfig.peer_timeout_secs || 60; + document.getElementById('heartbeatIntervalSecs').value = serverConfig.heartbeat_interval_secs || 5; + document.getElementById('warningThreshold').value = serverConfig.warning_threshold || 2; + document.getElementById('criticalThreshold').value = serverConfig.critical_threshold || 4; + + console.log('Server settings loaded:', serverConfig); + } + } catch (error) { + console.error('Error loading server settings:', error); + } +} + +async function saveServerSettings(event) { + event.preventDefault(); + + if (!checkAuth()) return; + if (userRole !== 'admin') { + showToast('Only admins can change server settings', 'error'); + return; + } + + const newConfig = { + peer_timeout_secs: parseInt(document.getElementById('peerTimeoutSecs').value) || 60, + heartbeat_interval_secs: parseInt(document.getElementById('heartbeatIntervalSecs').value) || 5, + warning_threshold: parseInt(document.getElementById('warningThreshold').value) || 2, + critical_threshold: parseInt(document.getElementById('criticalThreshold').value) || 4 + }; + + // Validation + if (newConfig.peer_timeout_secs < 10 || newConfig.peer_timeout_secs > 300) { + showToast('Peer timeout must be between 10 and 300 seconds', 'error'); + return; + } + if (newConfig.heartbeat_interval_secs < 1 || newConfig.heartbeat_interval_secs > 30) { + showToast('Heartbeat interval must be between 1 and 30 seconds', 'error'); + return; + } + if (newConfig.critical_threshold <= newConfig.warning_threshold) { + showToast('Critical threshold must be greater than warning threshold', 'error'); + return; + } + + try { + const response = await fetch('/api/server/config', { + method: 'POST', + headers: { + ...getAuthHeaders(), + 'Content-Type': 'application/json' + }, + body: JSON.stringify(newConfig) + }); + + if (handleAuthError(null, response)) return; + + const data = await response.json(); + + if (data.success) { + serverConfig = newConfig; + showToast('Server settings saved successfully'); + } else { + showToast('Error: ' + (data.error || 'Failed to save settings'), 'error'); + } + } catch (error) { + console.error('Error saving server settings:', error); + showToast('Failed to save server settings', 'error'); + } +} + +// Get peer timeout for online status calculation (in milliseconds) +function getPeerTimeoutMs() { + return (serverConfig.peer_timeout_secs || 60) * 1000; +} + // ============================================================================ // UTILITY FUNCTIONS // ============================================================================ diff --git a/web/static/style.css b/web/static/style.css index 744d1c03..2ddab7ce 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -366,6 +366,16 @@ body { color: var(--success-color); } +.status-warning { + background: rgba(255, 193, 7, 0.2); + color: #ffc107; +} + +.status-critical { + background: rgba(255, 152, 0, 0.2); + color: #ff9800; +} + .status-inactive { background: rgba(245, 87, 108, 0.2); color: var(--danger-color); @@ -562,6 +572,37 @@ textarea.form-control { font-family: inherit; } +.form-text { + font-size: 0.8rem; + margin-top: 0.25rem; +} + +.text-muted { + color: var(--text-secondary); + opacity: 0.7; +} + +/* ID History Display */ +.id-history-list { + padding: 0.75rem; + background: rgba(255, 255, 255, 0.03); + border-radius: 8px; + font-size: 0.9rem; + color: var(--text-secondary); +} + +.id-history-item { + display: inline-block; + padding: 0.25rem 0.5rem; + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: 4px; + margin: 0.1rem; + font-family: monospace; + font-size: 0.85rem; + color: var(--text-primary); +} + .warning-text { color: var(--warning-color); margin-top: 0.5rem; diff --git a/web/templates/index.html b/web/templates/index.html index cf496ef5..0ffc8e27 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -550,8 +550,50 @@
-

Account Settings

+

Settings

+ +
+

Server Settings

+
+
+ + + + Time before a device is marked as offline (10-300 seconds) + +
+
+ + + + How often to check device status (1-30 seconds) + +
+
+ + + + Number of missed heartbeats before "Degraded" status + +
+
+ + + + Number of missed heartbeats before "Critical" status + +
+ + +
+
+ +

Change Password

@@ -730,6 +772,11 @@
+ 6-16 characters, letters/numbers/dash/underscore +
+