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.
This commit is contained in:
UNITRONIX
2026-02-06 23:34:35 +01:00
parent c97cf0b2c1
commit b14a8cf162
29 changed files with 4833 additions and 1224 deletions
+121 -20
View File
@@ -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: <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*
+362
View File
@@ -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
+3
View File
@@ -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*/
+482
View File
@@ -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
+548
View File
@@ -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 "$@"
+15 -30
View File
@@ -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
+109
View File
@@ -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<String> = r.get("previous_ids");
prev.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default()'''
new_code = ''' let prev: Option<String> = 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<String> = if let Some(r) = row {
let prev: Option<String> = 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<String>,)> = sqlx::query_as("SELECT previous_ids FROM peer WHERE id = ?")
.bind(&old)
.fetch_optional(&mut conn)
.await?;
let mut prev_ids: Vec<String> = 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")
+40
View File
@@ -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<bool> {'''
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")
+75
View File
@@ -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")
+30
View File
@@ -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")
+22
View File
@@ -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!")
+31
View File
@@ -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}")
+302
View File
@@ -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<u16>,
// 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 <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
+29 -16
View File
@@ -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/).
+258
View File
@@ -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
+143 -294
View File
@@ -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<DbPool>;
@@ -17,38 +16,11 @@ pub struct DbPool {
impl deadpool::managed::Manager for DbPool {
type Type = SqliteConnection;
type Error = SqlxError;
async fn create(&self) -> Result<SqliteConnection, SqlxError> {
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<AtomicU32>,
last_failure: Arc<tokio::sync::Mutex<Option<Instant>>>,
is_open: Arc<AtomicBool>,
}
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<F, T, E>(&self, f: F) -> Result<T, E>
where
F: std::future::Future<Output = Result<T, E>>,
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<Database> {
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<Option<Peer>> {
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<Vec<u8>> {
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<bool> {
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<bool> {
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<bool> {
let conn = rusqlite::Connection::open(&db_path)?;
let mut stmt = conn.prepare("SELECT is_banned FROM peer WHERE id = ?")?;
let is_banned: Option<i32> = stmt
.query_row([&id], |row| row.get(0))
.unwrap_or(None);
Ok(is_banned == Some(1))
}).await?;
result
}
}
-263
View File
@@ -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<DbPool>;
pub struct DbPool {
url: String,
}
#[async_trait]
impl deadpool::managed::Manager for DbPool {
type Type = SqliteConnection;
type Error = SqlxError;
async fn create(&self) -> Result<SqliteConnection, SqlxError> {
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<SqlxError> {
Ok(obj.ping().await?)
}
}
#[derive(Clone)]
pub struct Database {
pool: Pool,
url: String,
}
#[derive(Default)]
pub struct Peer {
pub guid: Vec<u8>,
pub id: String,
pub uuid: Vec<u8>,
pub pk: Vec<u8>,
pub user: Option<Vec<u8>>,
pub info: String,
pub status: Option<i64>,
}
impl Database {
pub async fn new(url: &str) -> ResultType<Database> {
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<Option<Peer>> {
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<Vec<u8>> {
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<u8>,
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<bool> {
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<bool> {
let conn = rusqlite::Connection::open(&db_path)?;
let mut stmt = conn.prepare("SELECT is_banned FROM peer WHERE id = ?")?;
let is_banned: Option<i32> = stmt
.query_row([&id], |row| row.get(0))
.unwrap_or(None);
Ok(is_banned == Some(1))
}).await?;
result
}
}
+192 -13
View File
@@ -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<String>,
}
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<String>, 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<Arc<ApiState>>,
) -> Result<Json<ApiResponse<Vec<PeerStatus>>>, 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<String> = row.get("note");
let last_online: Option<String> = 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<String> = row.get("note");
let last_online: Option<String> = 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<Arc<ApiState>>,
Path(old_id): Path<String>,
Json(payload): Json<ChangeIdRequest>,
) -> Result<Json<ApiResponse<ChangeIdResponse>>, 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<String> = 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<dyn
// Try to connect, but don't fail if DB doesn't exist yet
let connect_options = match SqliteConnectOptions::from_str(&format!("sqlite://{}", db_path)) {
Ok(opts) => 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<dyn
// Wait and retry
hbb_common::tokio::time::sleep(std::time::Duration::from_secs(10)).await;
let opts = SqliteConnectOptions::from_str(&format!("sqlite://{}", db_path))?
.read_only(true)
.read_only(false)
.create_if_missing(false);
SqlitePool::connect_with(opts).await?
}
@@ -277,6 +454,7 @@ pub async fn start_api_server(db_path: String, port: u16) -> Result<(), Box<dyn
.route("/api/health", get(health_check))
.route("/api/peers", get(get_online_peers))
.route("/api/peers/:id", get(get_peer_details))
.route("/api/peers/:id/change-id", post(change_peer_id))
.layer(Extension(state));
let addr = SocketAddr::from(([0, 0, 0, 0], port));
@@ -285,9 +463,10 @@ pub async fn start_api_server(db_path: String, port: u16) -> Result<(), Box<dyn
hbb_common::log::info!("HTTP API Server on port {}", port);
hbb_common::log::info!("========================================");
hbb_common::log::info!("Endpoints:");
hbb_common::log::info!(" GET /api/health");
hbb_common::log::info!(" GET /api/peers");
hbb_common::log::info!(" GET /api/peers/:id");
hbb_common::log::info!(" GET /api/health");
hbb_common::log::info!(" GET /api/peers");
hbb_common::log::info!(" GET /api/peers/:id");
hbb_common::log::info!(" POST /api/peers/:id/change-id");
hbb_common::log::info!("========================================");
// axum 0.5 uses Server::bind
+9 -4
View File
@@ -1,6 +1,11 @@
// BetterDesk Enhanced Server v2.0.0
// BetterDesk Enhanced Server v2.1.1
// Based on RustDesk Server 1.1.14 with HTTP API
// https://tools.ietf.org/rfc/rfc5128.txt
//
// Features:
// - HTTP API with peer management
// - Peer ID change with history tracking
// - Database with soft-delete support
use flexi_logger::*;
use hbb_common::{bail, config::RENDEZVOUS_PORT, ResultType, tokio};
@@ -9,7 +14,7 @@ use hbbs::{common::*, *};
mod http_api;
const RMEM: usize = 0;
const API_PORT: u16 = 21120;
const API_PORT: u16 = 21114;
fn main() -> 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::<i32>()?;
if port < 3 {
@@ -41,7 +46,7 @@ fn main() -> ResultType<()> {
let api_port = get_arg("api-port").parse::<u16>().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);
+220 -196
View File
@@ -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<u8>,
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<RwLock<Peer>>;
/// 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<RwLock<HashMap<String, LockPeer>>>,
pub(crate) db: database::Database,
last_cleanup: Arc<RwLock<Instant>>,
}
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::<PeerInfo>(&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::<u64>().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<String> = 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<String> {
/// Find device ID by socket address (for ban enforcement)
pub(crate) async fn get_id_by_addr(&self, addr: SocketAddr) -> Option<String> {
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::<u64>().ok())
.unwrap_or(15);
let warning_threshold = std::env::var("HEARTBEAT_WARNING_THRESHOLD")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(2);
let critical_threshold = std::env::var("HEARTBEAT_CRITICAL_THRESHOLD")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(4);
let heartbeat_interval = std::env::var("HEARTBEAT_INTERVAL_SECS")
.ok()
.and_then(|s| s.parse::<u64>().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::<u64>().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,
}
-335
View File
@@ -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<String, ((u32, Instant), (HashSet<String>, Instant))>;
type UserStatusMap = HashMap<Vec<u8>, Arc<(Option<Vec<u8>>, bool)>>;
type IpChangesMap = HashMap<String, (Instant, HashMap<String, i32>)>;
lazy_static::lazy_static! {
pub(crate) static ref IP_BLOCKER: Mutex<IpBlockMap> = Default::default();
pub(crate) static ref USER_STATUS: RwLock<UserStatusMap> = Default::default();
pub(crate) static ref IP_CHANGES: Mutex<IpChangesMap> = 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<u8>,
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<RwLock<Peer>>;
#[derive(Clone)]
pub(crate) struct PeerMap {
map: Arc<RwLock<HashMap<String, LockPeer>>>,
pub(crate) db: database::Database,
}
impl PeerMap {
pub(crate) async fn new() -> ResultType<Self> {
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<LockPeer> {
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::<PeerInfo>(&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<LockPeer> {
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<String> {
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)
}
}
File diff suppressed because it is too large Load Diff
+230 -42
View File
@@ -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/<device_id>/ban', methods=['POST'])
@require_auth
@require_role(ROLE_ADMIN, ROLE_OPERATOR)
+196 -10
View File
@@ -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 `
<tr ${rowClass}>
<td>
@@ -230,9 +292,9 @@ function renderDevices(devices) {
</td>
<td>${escapeHtml(device.note) || '<span style="color: var(--text-secondary);">No note</span>'}</td>
<td>
<span class="status-badge ${device.online ? 'status-active' : 'status-inactive'}">
<i class="fas fa-circle"></i>
${device.online ? 'Online' : 'Offline'}
<span class="status-badge ${statusClass}">
<i class="fas ${statusIcon}"></i>
${statusText}
</span>
</td>
<td>${formatDate(device.created_at)}</td>
@@ -341,9 +403,14 @@ function showDetails(deviceId) {
<div class="detail-item">
<div class="detail-label">Status:</div>
<div class="detail-value">
<span class="status-badge ${device.online ? 'status-active' : 'status-inactive'}">
<i class="fas fa-circle"></i>
${device.online ? 'Online' : 'Offline'}
<span class="status-badge ${device.status_detail === 'online' || device.status_detail === 'degraded' || device.status_detail === 'critical' ?
(device.status_detail === 'degraded' ? 'status-warning' :
device.status_detail === 'critical' ? 'status-critical' : 'status-active') : 'status-inactive'}">
<i class="fas ${device.status_detail === 'degraded' ? 'fa-exclamation-circle' :
device.status_detail === 'critical' ? 'fa-exclamation-triangle' : 'fa-circle'}"></i>
${device.status_detail === 'degraded' ? 'Degraded' :
device.status_detail === 'critical' ? 'Critical' :
device.online ? 'Online' : 'Offline'}
</span>
</div>
</div>
@@ -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 =>
`<span class="id-history-item">${escapeHtml(id)}</span>`
).join(' → ');
if (device.id_changed_at) {
historyList.innerHTML += `<br><small class="text-muted">Last changed: ${device.id_changed_at}</small>`;
}
} 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
// ============================================================================
+41
View File
@@ -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;
+48 -1
View File
@@ -550,8 +550,50 @@
<!-- Settings Section -->
<div class="content-section" id="settings">
<div class="settings-container">
<h1 class="page-title">Account Settings</h1>
<h1 class="page-title">Settings</h1>
<!-- Server Settings Card (Admin Only) -->
<div class="settings-card admin-only" id="serverSettingsCard">
<h3><i class="fas fa-server"></i> Server Settings</h3>
<form id="serverSettingsForm" onsubmit="saveServerSettings(event)">
<div class="form-group">
<label for="peerTimeoutSecs">Peer Offline Timeout (seconds)</label>
<input type="number" id="peerTimeoutSecs" min="10" max="300" value="60">
<small style="color: rgba(255, 255, 255, 0.6); font-size: 0.8rem;">
Time before a device is marked as offline (10-300 seconds)
</small>
</div>
<div class="form-group">
<label for="heartbeatIntervalSecs">Status Check Interval (seconds)</label>
<input type="number" id="heartbeatIntervalSecs" min="1" max="30" value="5">
<small style="color: rgba(255, 255, 255, 0.6); font-size: 0.8rem;">
How often to check device status (1-30 seconds)
</small>
</div>
<div class="form-group">
<label for="warningThreshold">Warning Threshold (missed heartbeats)</label>
<input type="number" id="warningThreshold" min="1" max="10" value="2">
<small style="color: rgba(255, 255, 255, 0.6); font-size: 0.8rem;">
Number of missed heartbeats before "Degraded" status
</small>
</div>
<div class="form-group">
<label for="criticalThreshold">Critical Threshold (missed heartbeats)</label>
<input type="number" id="criticalThreshold" min="2" max="20" value="4">
<small style="color: rgba(255, 255, 255, 0.6); font-size: 0.8rem;">
Number of missed heartbeats before "Critical" status
</small>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Save Server Settings
</button>
<button type="button" class="btn btn-secondary" onclick="loadServerSettings()">
<i class="fas fa-sync"></i> Reload
</button>
</form>
</div>
<!-- Account Settings Card -->
<div class="settings-card">
<h3><i class="fas fa-key"></i> Change Password</h3>
<form id="passwordForm" onsubmit="changePassword(event)">
@@ -730,6 +772,11 @@
<div class="form-group">
<label for="editNewId">New Device ID (optional)</label>
<input type="text" id="editNewId" class="form-control" placeholder="Leave empty to keep current ID">
<small class="form-text text-muted">6-16 characters, letters/numbers/dash/underscore</small>
</div>
<div class="form-group" id="idHistoryGroup" style="display: none;">
<label><i class="fas fa-history"></i> Previous IDs</label>
<div id="idHistoryList" class="id-history-list"></div>
</div>
<div class="form-group">
<label for="editNote">Note</label>