From 29d03492fb18bcfed8442394eab79c419a4d33bc Mon Sep 17 00:00:00 2001 From: UNITRONIX <36471318+UNITRONIX@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:50:22 +0200 Subject: [PATCH] fix(installer): harden cross-platform lifecycle reliability Harden native, agent and Docker lifecycle paths with tracked update verification, rollback and preflight checks, safe uninstall semantics, and health verification. Add shared protocol tests, installer CI gates, and lifecycle documentation. Thanks: INSOLVE (Honorary); Marco Jakobs (@jacotec); MyNameisStitch (@MyNameisStitch); Redspin (@playerumpknow) --- .github/workflows/installer-ci.yml | 185 +++++++++ .github/workflows/version-verify.yml | 2 + CHANGELOG.md | 11 +- betterdesk-agent/install/install.ps1 | 112 +++-- betterdesk-agent/install/install.sh | 98 +++-- betterdesk-docker.sh | 52 ++- betterdesk-support-agent/install.go | 72 +++- betterdesk-support-agent/main.go | 13 +- betterdesk.ps1 | 387 +++++++++++++----- betterdesk.sh | 127 ++++-- docker-compose.quick.single.yml | 4 +- docs/PRE_RELEASE_CHECKLIST.md | 32 ++ docs/docker/DOCKER_TROUBLESHOOTING.md | 2 +- docs/important/betterdesk-update-flow.md | 30 ++ docs/important/installer-contract.md | 90 ++++ .../installer-tui-and-protocol-tests.md | 10 +- docs/important/installer-tui-modernization.md | 8 +- docs/setup/UPDATE_GUIDE.md | 22 +- install.sh | 31 +- scripts/bump-version.js | 6 +- scripts/installer-protocol-check.js | 155 +++++++ web-nodejs/lib/safePath.js | 32 +- web-nodejs/services/updateService.js | 275 ++++++++++++- .../tests/installerProtocolCheck.test.js | 47 +++ .../tests/linuxServerBinaryDeploy.test.js | 4 +- .../tests/linuxSystemdUnitPrivileged.test.js | 4 +- .../tests/updateService.channel.test.js | 37 ++ .../tests/updateService.consoleSync.test.js | 28 ++ .../tests/writeInstallerEnvSubst.test.js | 36 ++ 29 files changed, 1652 insertions(+), 260 deletions(-) create mode 100644 .github/workflows/installer-ci.yml create mode 100644 docs/important/installer-contract.md create mode 100644 scripts/installer-protocol-check.js create mode 100644 web-nodejs/tests/installerProtocolCheck.test.js create mode 100644 web-nodejs/tests/writeInstallerEnvSubst.test.js diff --git a/.github/workflows/installer-ci.yml b/.github/workflows/installer-ci.yml new file mode 100644 index 00000000..6a61e5da --- /dev/null +++ b/.github/workflows/installer-ci.yml @@ -0,0 +1,185 @@ +name: Installer CI + +on: + push: + branches: [main, dev] + paths: + - 'install.sh' + - 'betterdesk.sh' + - 'betterdesk.ps1' + - 'betterdesk-docker.sh' + - 'betterdesk-agent/install/**' + - 'betterdesk-support-agent/**' + - 'scripts/installer-protocol-check.js' + - 'web-nodejs/lib/safePath.js' + - 'web-nodejs/services/updateService.js' + - 'web-nodejs/tests/installerProtocolCheck.test.js' + - 'Dockerfile*' + - 'docker-compose*.yml' + - 'docker/**' + - 'docker-entrypoint.sh' + - '.github/workflows/installer-ci.yml' + pull_request: + branches: [main, dev] + paths: + - 'install.sh' + - 'betterdesk.sh' + - 'betterdesk.ps1' + - 'betterdesk-docker.sh' + - 'betterdesk-agent/install/**' + - 'betterdesk-support-agent/**' + - 'scripts/installer-protocol-check.js' + - 'web-nodejs/lib/safePath.js' + - 'web-nodejs/services/updateService.js' + - 'web-nodejs/tests/installerProtocolCheck.test.js' + - 'Dockerfile*' + - 'docker-compose*.yml' + - 'docker/**' + - 'docker-entrypoint.sh' + - '.github/workflows/installer-ci.yml' + workflow_dispatch: + inputs: + image_tag: + description: 'Published GHCR tag to smoke-test' + required: false + default: 'latest' + type: string + +permissions: + contents: read + +jobs: + bash-static: + name: Bash installer syntax + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Parse all Bash installers + run: | + set -euo pipefail + while IFS= read -r file; do + bash -n "$file" + done < <(printf '%s\n' \ + install.sh \ + betterdesk.sh \ + betterdesk-docker.sh \ + betterdesk-agent/install/install.sh) + - name: Run ShellCheck + run: | + shellcheck --severity=error \ + install.sh \ + betterdesk.sh \ + betterdesk-docker.sh \ + betterdesk-agent/install/install.sh + + powershell-static: + name: PowerShell installer syntax + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - name: Parse PowerShell installers + shell: pwsh + run: | + $files = @( + "betterdesk.ps1", + "betterdesk-agent/install/install.ps1" + ) + foreach ($file in $files) { + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile( + (Resolve-Path $file), + [ref]$tokens, + [ref]$errors + ) | Out-Null + if ($errors.Count -gt 0) { + $messages = $errors | ForEach-Object { $_.Message } + throw "$file failed to parse: $($messages -join '; ')" + } + } + + installer-unit: + name: Installer reliability unit tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: web-nodejs + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + cache-dependency-path: web-nodejs/package-lock.json + - name: Install console dependencies + run: npm ci --no-audit --no-fund + - name: Run installer-focused tests + run: > + npm test -- --runInBand + tests/installerProtocolCheck.test.js + tests/safePath.test.js + tests/updateService.channel.test.js + tests/updateService.consoleSync.test.js + + installer-dry-run: + name: Installer non-mutating CLI checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Verify help paths do not mutate the host + run: | + set -euo pipefail + bash install.sh --help >/dev/null + bash betterdesk.sh --help >/dev/null + bash betterdesk-docker.sh --help >/dev/null + + compose-static: + name: Docker Compose validation + runs-on: ubuntu-latest + env: + PG_PASSWORD: installer-ci-placeholder + MACVLAN_IPV4: 192.0.2.51 + steps: + - uses: actions/checkout@v4 + - name: Validate compose manifests + run: | + set -euo pipefail + for file in \ + docker-compose.yml \ + docker-compose.single.yml \ + docker-compose.quick.yml \ + docker-compose.quick.single.yml \ + docker-compose.quick.single.macvlan.yml; do + docker compose -f "$file" config --quiet + done + + docker-runtime-smoke: + name: Docker runtime smoke + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + BETTERDESK_IMAGE_TAG: ${{ inputs.image_tag || 'latest' }} + ADMIN_PASSWORD: InstallerSmoke-ChangeMe-9 + steps: + - uses: actions/checkout@v4 + - name: Start official single-container stack + run: | + set -euo pipefail + docker compose -f docker-compose.quick.single.yml pull + docker compose -f docker-compose.quick.single.yml up -d --wait + - name: Verify API and console health + run: | + set -euo pipefail + curl --fail --retry 30 --retry-delay 2 --retry-connrefused \ + http://127.0.0.1:21121/api/health + curl --fail --retry 30 --retry-delay 2 --retry-connrefused \ + http://127.0.0.1:5000/health + node scripts/installer-protocol-check.js \ + --api-url http://127.0.0.1:21121/api/health \ + --panel-url http://127.0.0.1:5000/health \ + --port 127.0.0.1:21121 + docker compose -f docker-compose.quick.single.yml ps + - name: Tear down smoke stack + if: always() + run: docker compose -f docker-compose.quick.single.yml down -v diff --git a/.github/workflows/version-verify.yml b/.github/workflows/version-verify.yml index 881efd9c..14d3777c 100644 --- a/.github/workflows/version-verify.yml +++ b/.github/workflows/version-verify.yml @@ -11,6 +11,7 @@ on: - 'betterdesk.sh' - 'betterdesk.ps1' - 'betterdesk-docker.sh' + - 'install.sh' - 'CHANGELOG.md' - 'README.md' - 'Dockerfile*' @@ -31,6 +32,7 @@ on: - 'betterdesk.sh' - 'betterdesk.ps1' - 'betterdesk-docker.sh' + - 'install.sh' - 'CHANGELOG.md' - 'README.md' - 'Dockerfile*' diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f8ef6a7..08be8be7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,16 @@ ## [Unreleased] ### Changed -- _(none yet)_ +- **Installer lifecycle reliability:** Native Linux/Windows updates now + verify tracked commits, preserve rollback state and perform safer + preflight/health checks; Windows agent installation can fall back to a + scheduled task when NSSM is unavailable. +- **Cross-platform installer verification:** Added shared protocol checks, + installer-focused unit tests, Docker smoke validation and static CI gates + for Bash, PowerShell and Compose paths. +- **Agent uninstall safety:** Native agent uninstall now preserves config and + enrollment data by default; explicit purge flags are required for cleanup, + including the support-agent binary's persistent state. --- diff --git a/betterdesk-agent/install/install.ps1 b/betterdesk-agent/install/install.ps1 index 5644becc..eb001630 100644 --- a/betterdesk-agent/install/install.ps1 +++ b/betterdesk-agent/install/install.ps1 @@ -1,13 +1,14 @@ # BetterDesk Agent — Windows installer (NSSM service) # Usage: Run as Administrator -# .\install.ps1 [-Server URL] [-Key KEY] [-Name NAME] [-Uninstall] +# .\install.ps1 [-Server URL] [-Key KEY] [-Name NAME] [-Uninstall] [-Purge] [CmdletBinding()] param( [string]$Server, [string]$Key, [string]$Name, [string]$InstallDir = "$env:ProgramFiles\BetterDesk\Agent", - [switch]$Uninstall + [switch]$Uninstall, + [switch]$Purge ) $ErrorActionPreference = "Stop" @@ -36,10 +37,16 @@ if ($Uninstall) { Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue sc.exe delete $ServiceName 2>$null } - if (Test-Path $InstallDir) { - Remove-Item -Path $InstallDir -Recurse -Force + Unregister-ScheduledTask -TaskName $ServiceName -Confirm:$false -ErrorAction SilentlyContinue + if ($Purge) { + if (Test-Path $InstallDir) { + Remove-Item -Path $InstallDir -Recurse -Force + } + Write-Host "BetterDesk Agent uninstalled and data purged." -ForegroundColor Green + } else { + Remove-Item -Path "$InstallDir\betterdesk-agent.exe" -Force -ErrorAction SilentlyContinue + Write-Host "BetterDesk Agent uninstalled; config and data preserved at $InstallDir." -ForegroundColor Green } - Write-Host "BetterDesk Agent uninstalled." -ForegroundColor Green exit 0 } @@ -79,6 +86,12 @@ if (-not (Test-Path $ConfigFile)) { if (-not $Name) { $Name = $env:COMPUTERNAME } + if ($Server -notmatch '^(ws|wss)://') { + throw "Gateway URL must start with ws:// or wss://" + } + if (-not $Key) { + throw "API key must not be empty" + } $config = @{ server = $Server auth_method = "api_key" @@ -104,45 +117,80 @@ if (-not (Test-Path $ConfigFile)) { # Install NSSM if not present $nssmPath = "$InstallDir\nssm.exe" +$serviceMode = "nssm" if (-not (Test-Path $nssmPath)) { Write-Host "Downloading NSSM..." - $zipPath = "$env:TEMP\nssm.zip" - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - Invoke-WebRequest -Uri $NSSMUrl -OutFile $zipPath -UseBasicParsing - $extractDir = "$env:TEMP\nssm-extract" - Expand-Archive -Path $zipPath -DestinationPath $extractDir -Force - $nssmBin = Get-ChildItem -Path $extractDir -Recurse -Filter "nssm.exe" | - Where-Object { $_.DirectoryName -like "*win64*" } | Select-Object -First 1 - if ($nssmBin) { - Copy-Item -Path $nssmBin.FullName -Destination $nssmPath -Force - } else { - Write-Host "ERROR: Failed to find nssm.exe in archive" -ForegroundColor Red - exit 1 + try { + $zipPath = "$env:TEMP\nssm.zip" + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Invoke-WebRequest -Uri $NSSMUrl -OutFile $zipPath -UseBasicParsing + $extractDir = "$env:TEMP\nssm-extract" + Expand-Archive -Path $zipPath -DestinationPath $extractDir -Force + $nssmBin = Get-ChildItem -Path $extractDir -Recurse -Filter "nssm.exe" | + Where-Object { $_.DirectoryName -like "*win64*" } | Select-Object -First 1 + if ($nssmBin) { + Copy-Item -Path $nssmBin.FullName -Destination $nssmPath -Force + } else { + throw "nssm.exe was not found in the archive" + } + Remove-Item $zipPath, $extractDir -Recurse -Force -ErrorAction SilentlyContinue + } catch { + Write-Warning "NSSM installation failed: $($_.Exception.Message)" + Write-Warning "Falling back to a per-user scheduled task." + $serviceMode = "scheduled-task" } - Remove-Item $zipPath, $extractDir -Recurse -Force -ErrorAction SilentlyContinue } -# Create service -& $nssmPath stop $ServiceName 2>$null -& $nssmPath remove $ServiceName confirm 2>$null +if ($serviceMode -eq "nssm" -and (Test-Path $nssmPath)) { + # Create NSSM service + & $nssmPath stop $ServiceName 2>$null + & $nssmPath remove $ServiceName confirm 2>$null + Unregister-ScheduledTask -TaskName $ServiceName -Confirm:$false -ErrorAction SilentlyContinue -& $nssmPath install $ServiceName "$InstallDir\betterdesk-agent.exe" -& $nssmPath set $ServiceName AppParameters "-config `"$ConfigFile`"" -& $nssmPath set $ServiceName AppDirectory $InstallDir -& $nssmPath set $ServiceName Start SERVICE_AUTO_START -& $nssmPath set $ServiceName AppStdout "$InstallDir\data\agent.log" -& $nssmPath set $ServiceName AppStderr "$InstallDir\data\agent.log" -& $nssmPath set $ServiceName AppRotateFiles 1 -& $nssmPath set $ServiceName AppRotateBytes 10485760 -& $nssmPath set $ServiceName Description "BetterDesk CDAP Agent" + & $nssmPath install $ServiceName "$InstallDir\betterdesk-agent.exe" + & $nssmPath set $ServiceName AppParameters "-config `"$ConfigFile`"" + & $nssmPath set $ServiceName AppDirectory $InstallDir + & $nssmPath set $ServiceName Start SERVICE_AUTO_START + & $nssmPath set $ServiceName AppStdout "$InstallDir\data\agent.log" + & $nssmPath set $ServiceName AppStderr "$InstallDir\data\agent.log" + & $nssmPath set $ServiceName AppRotateFiles 1 + & $nssmPath set $ServiceName AppRotateBytes 10485760 + & $nssmPath set $ServiceName Description "BetterDesk CDAP Agent" -& $nssmPath start $ServiceName + & $nssmPath start $ServiceName + if ($LASTEXITCODE -ne 0) { + throw "NSSM failed to start $ServiceName (exit code $LASTEXITCODE)" + } + Start-Sleep -Seconds 2 + $service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue + if (-not $service -or $service.Status -ne "Running") { + throw "$ServiceName was installed but is not running" + } +} else { + # NSSM is optional: keep the installer usable on restricted hosts. + $taskAction = New-ScheduledTaskAction ` + -Execute "$InstallDir\betterdesk-agent.exe" ` + -Argument "-config `"$ConfigFile`"" ` + -WorkingDirectory $InstallDir + $taskTrigger = New-ScheduledTaskTrigger -AtLogOn + $taskPrincipal = New-ScheduledTaskPrincipal ` + -UserId "$env:USERDOMAIN\$env:USERNAME" ` + -LogonType Interactive ` + -RunLevel Limited + Unregister-ScheduledTask -TaskName $ServiceName -Confirm:$false -ErrorAction SilentlyContinue + Register-ScheduledTask -TaskName $ServiceName -Action $taskAction ` + -Trigger $taskTrigger -Principal $taskPrincipal -Force | Out-Null + Start-ScheduledTask -TaskName $ServiceName + if (-not (Get-ScheduledTask -TaskName $ServiceName -ErrorAction SilentlyContinue)) { + throw "Scheduled-task fallback was not registered" + } +} Write-Host "" Write-Host "=== BetterDesk Agent Installed ===" -ForegroundColor Green Write-Host " Binary: $InstallDir\betterdesk-agent.exe" Write-Host " Config: $ConfigFile" -Write-Host " Service: $ServiceName" +Write-Host " Autostart: $ServiceName ($serviceMode)" Write-Host "" Write-Host "Commands:" Write-Host " nssm status $ServiceName" diff --git a/betterdesk-agent/install/install.sh b/betterdesk-agent/install/install.sh index c3eb707a..08bb6375 100644 --- a/betterdesk-agent/install/install.sh +++ b/betterdesk-agent/install/install.sh @@ -6,6 +6,7 @@ # -n NAME Device name # -d DIR Install directory (default: /opt/betterdesk-agent) # -u Uninstall +# -p, --purge With -u, also remove config, data and service user set -euo pipefail INSTALL_DIR="/opt/betterdesk-agent" @@ -16,24 +17,39 @@ SERVER_URL="" API_KEY="" DEVICE_NAME="" UNINSTALL=false +PURGE=false + +# Accept the long purge flag while retaining POSIX getopts for the existing +# short options. +filtered_args=() +for arg in "$@"; do + if [ "$arg" = "--purge" ]; then + PURGE=true + else + filtered_args+=("$arg") + fi +done +set -- "${filtered_args[@]}" usage() { - echo "Usage: sudo $0 [-s URL] [-k KEY] [-n NAME] [-d DIR] [-u]" + echo "Usage: sudo $0 [-s URL] [-k KEY] [-n NAME] [-d DIR] [-u] [-p|--purge]" echo " -s URL Gateway WebSocket URL (ws://host:21122/cdap)" echo " -k KEY API key for authentication" echo " -n NAME Device name (default: hostname)" echo " -d DIR Install directory (default: /opt/betterdesk-agent)" - echo " -u Uninstall" + echo " -u Uninstall (preserves config and data)" + echo " -p With -u, remove config, data and service user" exit 1 } -while getopts "s:k:n:d:uh" opt; do +while getopts "s:k:n:d:uph" opt; do case $opt in s) SERVER_URL="$OPTARG" ;; k) API_KEY="$OPTARG" ;; n) DEVICE_NAME="$OPTARG" ;; d) INSTALL_DIR="$OPTARG" ;; u) UNINSTALL=true ;; + p) PURGE=true ;; h|*) usage ;; esac done @@ -52,8 +68,16 @@ uninstall() { if id "$USER_NAME" &>/dev/null; then userdel "$USER_NAME" 2>/dev/null || true fi - rm -rf "$INSTALL_DIR" - echo "BetterDesk Agent uninstalled." + if $PURGE; then + rm -rf "$INSTALL_DIR" + if id "$USER_NAME" &>/dev/null; then + userdel "$USER_NAME" 2>/dev/null || true + fi + echo "BetterDesk Agent uninstalled and data purged." + else + rm -f "${INSTALL_DIR}/betterdesk-agent" + echo "BetterDesk Agent uninstalled; config and data preserved at ${INSTALL_DIR}." + fi exit 0 } @@ -102,27 +126,48 @@ if [ ! -f "$CONFIG_FILE" ]; then if [ -z "$DEVICE_NAME" ]; then DEVICE_NAME="$(hostname)" fi + case "$SERVER_URL" in + ws://*|wss://*) ;; + *) echo "ERROR: Gateway URL must start with ws:// or wss://"; exit 1 ;; + esac + if [ -z "$API_KEY" ]; then + echo "ERROR: API key must not be empty"; exit 1 + fi + if ! command -v python3 >/dev/null 2>&1; then + echo "ERROR: python3 is required to safely create config.json"; exit 1 + fi + BD_SERVER_URL="$SERVER_URL" \ + BD_API_KEY="$API_KEY" \ + BD_DEVICE_NAME="$DEVICE_NAME" \ + BD_INSTALL_DIR="$INSTALL_DIR" \ + python3 - "$CONFIG_FILE" <<'PY' +import json +import os +import sys - cat > "$CONFIG_FILE" </dev/null 2>&1 \ + && curl -fsS --max-time 3 "http://127.0.0.1:5000/health" >/dev/null 2>&1; then + print_success "API and web console health checks passed" + return 0 + fi + sleep 2 + health_deadline=$((health_deadline - 2)) + done + + print_error "BetterDesk containers started but health checks failed" + print_info "Inspect logs with: $COMPOSE_CMD -f $COMPOSE_FILE logs --tail=100" + return 1 } stop_containers() { @@ -1252,22 +1272,12 @@ create_admin_user() { local target_container target_container=$(resolve_panel_container) - docker exec -u betterdesk "$target_container" node /app/scripts/reset-password.js "$admin_password" admin 2>/dev/null || { - # If script fails, try via environment variable approach - print_info "Setting admin password via API..." - - # The console will create admin:admin by default on first run - # We need to change it to a secure random password - sleep 2 - - # Use curl to change password (requires internal API) - # If this fails, admin will use default password which must be changed - docker exec -u betterdesk "$target_container" sh -c " - if [ -f /app/scripts/reset-password.js ]; then - node /app/scripts/reset-password.js '$admin_password' admin 2>/dev/null - fi - " 2>/dev/null || true - } + if ! docker exec -u betterdesk "$target_container" \ + node /app/scripts/reset-password.js "$admin_password" admin 2>/dev/null; then + print_error "Could not set the admin password safely" + print_info "The installation is incomplete; inspect container logs before retrying" + return 1 + fi echo "" echo -e "${GREEN}╔════════════════════════════════════════════════════════╗${NC}" @@ -2621,7 +2631,13 @@ do_uninstall() { print_step "Stopping containers..." cd "$SCRIPT_DIR" - $COMPOSE_CMD down -v 2>/dev/null || true + if confirm "Remove Docker volumes (this deletes named-volume data)?"; then + $COMPOSE_CMD down -v 2>/dev/null || true + print_info "Docker volumes removed" + else + $COMPOSE_CMD down 2>/dev/null || true + print_info "Docker volumes preserved" + fi if confirm "Remove Docker images?"; then docker rmi betterdesk-server betterdesk-console 2>/dev/null || true diff --git a/betterdesk-support-agent/install.go b/betterdesk-support-agent/install.go index 98287207..acd6934e 100644 --- a/betterdesk-support-agent/install.go +++ b/betterdesk-support-agent/install.go @@ -81,17 +81,58 @@ func Install() error { return nil } -// Uninstall removes autostart and the installed binary. +// Uninstall removes autostart and installed binaries while preserving the +// agent's persistent state for a later reinstall. func Uninstall() error { + return uninstall(false) +} + +// UninstallPurge removes autostart, installed binaries and persistent state. +// It is intentionally separate so normal uninstall cannot destroy enrollment +// identity or operator preferences. +func UninstallPurge() error { + return uninstall(true) +} + +func uninstall(purge bool) error { if err := unregisterAutostart(); err != nil { - fmt.Printf("warning: remove autostart: %v\n", err) + return fmt.Errorf("remove autostart: %w", err) } dst, err := installedBinaryPath() - if err == nil { - _ = os.Remove(dst) - _ = os.Remove(filepath.Dir(dst)) + if err != nil { + return err + } + installPath := filepath.Dir(dst) + if purge { + if err := os.RemoveAll(installPath); err != nil { + return fmt.Errorf("remove install directory: %w", err) + } + if err := os.RemoveAll(stateDir()); err != nil { + return fmt.Errorf("remove state directory: %w", err) + } + } else { + for _, name := range []string{ + filepath.Base(dst), + "betterdesk-support-x11", + "betterdesk-support-wayland", + "opengl32.dll", + "libgallium_wgl.dll", + } { + if err := os.Remove(filepath.Join(installPath, name)); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove installed binary %s: %w", name, err) + } + } + // Remove only an empty binary directory; any remaining operator files + // are deliberately preserved. + if err := os.Remove(installPath); err != nil && !os.IsNotExist(err) { + fmt.Printf("Preserved non-empty install directory %s.\n", installPath) + } + } + if purge { + fmt.Println("Uninstalled autostart entry and purged state.") + } else { + fmt.Println("Uninstalled autostart entry; persistent state preserved.") } - fmt.Println("Uninstalled autostart entry.") return nil } @@ -106,7 +147,24 @@ func copyExecutable(src, dst string) error { if err := os.WriteFile(tmp, data, 0o755); err != nil { return err } - return os.Rename(tmp, dst) + if err := os.Rename(tmp, dst); err == nil { + return nil + } else if runtime.GOOS == "windows" { + // Windows cannot replace an existing executable with Rename. Remove + // only the previous target after the new bytes are safely staged. + if removeErr := os.Remove(dst); removeErr != nil && !os.IsNotExist(removeErr) { + _ = os.Remove(tmp) + return removeErr + } + if replaceErr := os.Rename(tmp, dst); replaceErr != nil { + _ = os.Remove(tmp) + return replaceErr + } + return nil + } else { + _ = os.Remove(tmp) + return err + } } // copyLinuxUIBundle installs the session launcher plus X11/Wayland UI binaries on Linux. diff --git a/betterdesk-support-agent/main.go b/betterdesk-support-agent/main.go index 4515dddd..7aebf77e 100644 --- a/betterdesk-support-agent/main.go +++ b/betterdesk-support-agent/main.go @@ -23,10 +23,15 @@ func main() { showVer = flag.Bool("version", false, "Print version and exit") doInstall = flag.Bool("install", false, "Install to a per-user location and enable autostart") doUninst = flag.Bool("uninstall", false, "Remove autostart entry and installed binary") + doPurge = flag.Bool("purge", false, "With -uninstall, also remove persistent state") doReset = flag.Bool("reset-enrollment", false, "Clear local enrollment state and exit") noGUI = flag.Bool("nogui", false, "Run without graphical interface (no window)") ) flag.Parse() + if *doPurge && !*doUninst { + fmt.Fprintln(os.Stderr, "-purge requires -uninstall") + os.Exit(2) + } antiDebugChecks() prepWindowsGraphics() @@ -46,7 +51,13 @@ func main() { } if *doUninst { - if err := Uninstall(); err != nil { + var err error + if *doPurge { + err = UninstallPurge() + } else { + err = Uninstall() + } + if err != nil { fmt.Fprintf(os.Stderr, "uninstall failed: %v\n", err) os.Exit(1) } diff --git a/betterdesk.ps1 b/betterdesk.ps1 index d5542882..9b60baca 100644 --- a/betterdesk.ps1 +++ b/betterdesk.ps1 @@ -1,4 +1,4 @@ -#Requires -RunAsAdministrator +#Requires -RunAsAdministrator <# .SYNOPSIS BetterDesk Console Manager v3.5.38 - All-in-One Interactive Tool for Windows @@ -29,6 +29,12 @@ .PARAMETER Auto Run installation in automatic mode (non-interactive) +.PARAMETER Uninstall + Stop services and remove the native installation; data is preserved by default + +.PARAMETER Purge + With -Uninstall, also remove installation data and keys + .PARAMETER SkipVerify Skip SHA256 verification of binaries @@ -84,6 +90,8 @@ param( [switch]$Auto, + [switch]$Uninstall, + [switch]$Purge, [switch]$SkipVerify, [switch]$Minimal, [switch]$NodeJs, @@ -107,6 +115,8 @@ $script:ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path # Auto mode flags $script:AUTO_MODE = $Auto +$script:UNINSTALL_MODE = $Uninstall +$script:PURGE_MODE = $Purge $script:SKIP_VERIFY = $SkipVerify $script:MINIMAL_MODE = $Minimal @@ -2590,6 +2600,32 @@ function Test-ServiceHealth { return $true } +function Test-HttpEndpoint { + param( + [Parameter(Mandatory = $true)][string]$Url, + [int]$TimeoutSeconds = 30 + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + do { + try { + $response = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 5 -MaximumRedirection 3 + # A 3xx response is valid for a panel configured to redirect HTTP + # to HTTPS; the listener is reachable and the operator can use + # the protocol-specific check from the installer menu. + if ($response.StatusCode -ge 200 -and $response.StatusCode -lt 400) { + return $true + } + } catch { + # The service may still be warming up; retry until the deadline. + } + Start-Sleep -Seconds 1 + } while ((Get-Date) -lt $deadline) + + Print-Error "HTTP health check failed: $Url" + return $false +} + function Start-ServicesWithVerification { Print-Step "Starting services with health verification..." @@ -2676,6 +2712,29 @@ function Start-ServicesWithVerification { } Start-Sleep -Seconds 2 + $healthOk = $true + if (-not (Test-HttpEndpoint -Url "http://127.0.0.1:$($script:GO_API_PORT)/api/health")) { + $healthOk = $false + } + if (-not (Test-HttpEndpoint -Url "http://127.0.0.1:5000/health")) { + $healthOk = $false + } + $protocolScript = Join-Path $script:ScriptDir "scripts\installer-protocol-check.js" + $node = Get-Command node -ErrorAction SilentlyContinue + if ($node -and (Test-Path $protocolScript)) { + & $node.Source $protocolScript ` + --api-url "http://127.0.0.1:$($script:GO_API_PORT)/api/health" ` + --panel-url "http://127.0.0.1:5000/health" ` + --port "127.0.0.1:21116" | ForEach-Object { Print-Info "$_" } + if ($LASTEXITCODE -ne 0) { + $healthOk = $false + } + } + if (-not $healthOk) { + Print-Error "Services are running but HTTP health verification failed" + return $false + } + Print-Success "All services started and verified" return $true @@ -3014,6 +3073,68 @@ function Read-UpdateGitHubBranchFromEnv { } } +function Resolve-UpdateRemoteSha { + param([Parameter(Mandatory = $true)][string]$CloneDir) + + $remoteSha = "" + $git = Get-Command git -ErrorAction SilentlyContinue + if ($git -and (Test-Path (Join-Path $CloneDir ".git"))) { + $remoteSha = ((& git -C $CloneDir rev-parse HEAD 2>$null) | Select-Object -First 1).Trim() + } + if ($remoteSha -notmatch '^[0-9a-fA-F]{40}$' -and $git) { + $remoteSha = ((& git ls-remote ` + "https://github.com/$($script:UPDATE_GITHUB_OWNER)/$($script:UPDATE_GITHUB_REPO).git" ` + "refs/heads/$($script:UPDATE_GITHUB_BRANCH)" 2>$null) | + Select-Object -First 1) + if ($remoteSha -is [array]) { $remoteSha = $remoteSha[0] } + if ($remoteSha) { $remoteSha = ($remoteSha -split '\s+')[0] } + } + if ($remoteSha -notmatch '^[0-9a-fA-F]{40}$') { + try { + $encodedBranch = [Uri]::EscapeDataString($script:UPDATE_GITHUB_BRANCH) + $apiUrl = "https://api.github.com/repos/$($script:UPDATE_GITHUB_OWNER)/$($script:UPDATE_GITHUB_REPO)/commits?sha=$encodedBranch&per_page=1" + $commit = Invoke-RestMethod -Uri $apiUrl -Headers @{ Accept = "application/vnd.github+json" } -TimeoutSec 30 + $firstCommit = if ($commit -is [array]) { $commit[0] } else { $commit } + $remoteSha = [string]$firstCommit.sha + } catch { + $remoteSha = "" + } + } + + if ($remoteSha -match '^[0-9a-fA-F]{40}$') { + return $remoteSha + } + return $null +} + +function Stage-SupportAgentSource { + param([Parameter(Mandatory = $true)][string]$CloneDir) + + $base = Join-Path $script:CONSOLE_PATH "agent-source" + $sources = @( + @{ Name = "betterdesk-support-agent"; Required = "build.sh" }, + @{ Name = "betterdesk-agent"; Required = "go.mod" }, + @{ Name = "betterdesk-server"; Required = "go.mod" } + ) + $staged = 0 + foreach ($item in $sources) { + $source = Join-Path $CloneDir $item.Name + $destination = Join-Path $base $item.Name + if (-not (Test-Path (Join-Path $source $item.Required))) { + Print-Warning "Support-agent source missing: $source" + continue + } + New-Item -ItemType Directory -Path $base -Force | Out-Null + Remove-Item -Path $destination -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path $destination -Force | Out-Null + Get-ChildItem -Path $source -Force | + Where-Object { $_.Name -notin @(".git", "dist", "data") } | + Copy-Item -Destination $destination -Recurse -Force + $staged++ + } + return ($staged -gt 0) +} + function Write-UpdateGitHubBranchToEnv { param([Parameter(Mandatory = $true)][ValidateSet('main', 'dev')][string]$Branch) $envFile = Join-Path $script:CONSOLE_PATH ".env" @@ -3103,6 +3224,8 @@ function Invoke-TerminalProjectUpdate { function Update-FromGitHub { $cloneDir = Join-Path $env:TEMP "betterdesk-update-$PID" $script:ServerBuildFailed = $false + $previousGoSource = "" + $remoteSha = "" Read-UpdateGitHubBranchFromEnv @@ -3171,6 +3294,14 @@ function Update-FromGitHub { return $false } + $remoteSha = Resolve-UpdateRemoteSha -CloneDir $cloneDir + if (-not $remoteSha) { + Print-Error "Could not resolve the downloaded commit SHA; refusing an untracked update" + Remove-Item -Recurse -Force $cloneDir -ErrorAction SilentlyContinue + return $false + } + Print-Info "Downloaded commit: $($remoteSha.Substring(0, 7))" + # Read remote version $remoteVersion = "" $versionFile = Join-Path $cloneDir "VERSION" @@ -3185,8 +3316,13 @@ function Update-FromGitHub { Print-Step "Updating Go server source..." $goServerSource = $script:GO_SERVER_SOURCE if (Test-Path $goServerSource) { - $backupName = "$goServerSource.pre-update.$PID" - Rename-Item -Path $goServerSource -NewName $backupName -ErrorAction SilentlyContinue + $previousGoSource = "$goServerSource.pre-update.$PID" + try { + Rename-Item -Path $goServerSource -NewName $previousGoSource -ErrorAction Stop + } catch { + $previousGoSource = "" + Print-Warning "Could not stage the previous Go source tree; update will continue in place" + } } $sourceDir = Join-Path $cloneDir "betterdesk-server" # Copy the *contents* into a guaranteed-existing destination. Copying the @@ -3198,11 +3334,10 @@ function Update-FromGitHub { Copy-Item -Path "$sourceDir\*" -Destination $goServerSource -Recurse -Force # Restore any local data/ directory from old source - $oldDataDir = "$goServerSource.pre-update.$PID\data" - if (Test-Path $oldDataDir) { + $oldDataDir = if ($previousGoSource) { Join-Path $previousGoSource "data" } else { "" } + if ($oldDataDir -and (Test-Path $oldDataDir)) { Copy-Item -Path "$oldDataDir\*" -Destination (Join-Path $goServerSource "data") -Recurse -Force -ErrorAction SilentlyContinue } - Remove-Item -Path "$goServerSource.pre-update.$PID" -Recurse -Force -ErrorAction SilentlyContinue Print-Success "Go server source updated" # Compile Go server @@ -3302,10 +3437,12 @@ function Update-FromGitHub { # ---- Step 4: Update installer scripts ---- Print-Step "Updating installer scripts..." $scriptFiles = @( - "betterdesk.sh", "betterdesk.ps1", "betterdesk-docker.sh", + "install.sh", "betterdesk.sh", "betterdesk.ps1", "betterdesk-docker.sh", "docker-compose.yml", "docker-compose.single.yml", "docker-compose.quick.yml", "docker-compose.quick.single.yml", "docker-compose.quick.single.macvlan.yml", - "Dockerfile", "Dockerfile.server", "Dockerfile.console", "VERSION" + "Dockerfile", "Dockerfile.server", "Dockerfile.console", "docker-entrypoint.sh", + "docker\entrypoint.sh", "docker\server-entrypoint.sh", "docker\console-entrypoint.sh", + "docker\supervisord.conf", "scripts\installer-protocol-check.js", "VERSION" ) $scriptsUpdated = 0 foreach ($sf in $scriptFiles) { @@ -3317,36 +3454,52 @@ function Update-FromGitHub { } Print-Success "$scriptsUpdated installer files updated" - # ---- Step 5: Update SHA tracking for in-app updater ---- - $gitCmd2 = Get-Command git -ErrorAction SilentlyContinue - if ($gitCmd2 -and (Test-Path (Join-Path $cloneDir ".git"))) { - try { - $remoteSha = (& git -C $cloneDir rev-parse HEAD 2>$null).Trim() - if ($remoteSha) { - $dataDir = Join-Path $script:CONSOLE_PATH "data" - if (-not (Test-Path $dataDir)) { New-Item -ItemType Directory -Path $dataDir -Force | Out-Null } - Set-Content -Path (Join-Path $dataDir ".update_sha") -Value $remoteSha - Set-Content -Path (Join-Path $dataDir ".agent_source_sha") -Value $remoteSha - Remove-Item -Path (Join-Path $dataDir ".last_update_result.json") -Force -ErrorAction SilentlyContinue - Print-Info "SHA tracking updated: $($remoteSha.Substring(0, 7))" - } - } catch { } + # Stage agent sources where the console build worker expects them. This + # keeps Windows update parity with the Linux installer and avoids a full + # repository checkout on the production host. + Print-Step "Staging support-agent source for Generator builds..." + if (Stage-SupportAgentSource -CloneDir $cloneDir) { + $dataDir = Join-Path $script:CONSOLE_PATH "data" + if (-not (Test-Path $dataDir)) { New-Item -ItemType Directory -Path $dataDir -Force | Out-Null } + $pending = @{ reason = "betterdesk.ps1 update"; at = (Get-Date).ToUniversalTime().ToString("o") } | + ConvertTo-Json -Compress + Set-Content -Path (Join-Path $dataDir ".agent_rebuild_pending") -Value $pending -Encoding UTF8 + Print-Info "Generator bundles will rebuild after console restart" + } else { + Print-Warning "Support-agent source staging skipped" } - # ---- Step 6: Update VERSION file ---- + if ($script:ServerBuildFailed) { + if ($previousGoSource -and (Test-Path $previousGoSource)) { + Remove-Item -Path $goServerSource -Recurse -Force -ErrorAction SilentlyContinue + try { + Rename-Item -Path $previousGoSource -NewName $goServerSource -ErrorAction Stop + } catch { + Print-Warning "Could not restore the previous Go source tree" + } + } + Remove-Item -Recurse -Force $cloneDir -ErrorAction SilentlyContinue + Print-Error "Go server binary was not rebuilt — update incomplete for server component" + return $false + } + + # Only mark the update complete after the server build/deploy succeeded. + $dataDir = Join-Path $script:CONSOLE_PATH "data" + if (-not (Test-Path $dataDir)) { New-Item -ItemType Directory -Path $dataDir -Force | Out-Null } + Set-Content -Path (Join-Path $dataDir ".update_sha") -Value $remoteSha + Set-Content -Path (Join-Path $dataDir ".agent_source_sha") -Value $remoteSha + Remove-Item -Path (Join-Path $dataDir ".last_update_result.json") -Force -ErrorAction SilentlyContinue + Print-Info "SHA tracking updated: $($remoteSha.Substring(0, 7))" + if ($remoteVersion -and (Test-Path (Join-Path $cloneDir "VERSION"))) { Copy-Item -Path (Join-Path $cloneDir "VERSION") -Destination (Join-Path $script:ScriptDir "VERSION") -Force -ErrorAction SilentlyContinue Copy-Item -Path (Join-Path $cloneDir "VERSION") -Destination (Join-Path $script:CONSOLE_PATH "VERSION") -Force -ErrorAction SilentlyContinue } - - # Cleanup Remove-Item -Recurse -Force $cloneDir -ErrorAction SilentlyContinue - - Print-Success "All project files updated from GitHub" - if ($script:ServerBuildFailed) { - Print-Error "Go server binary was not rebuilt — update incomplete for server component" - return $false + if ($previousGoSource) { + Remove-Item -Path $previousGoSource -Recurse -Force -ErrorAction SilentlyContinue } + Print-Success "All project files updated from GitHub" return $true } @@ -3529,77 +3682,57 @@ function Do-Repair { } function Repair-Binaries { - Print-Step "Repairing binaries (enhanced v2.1.2)..." - - # Verify binaries exist - $binSource = Join-Path $script:ScriptDir "hbbs-patch-v2" - $hbbsPath = Join-Path $binSource "hbbs-windows-x86_64.exe" - $hbbrPath = Join-Path $binSource "hbbr-windows-x86_64.exe" - - if (-not (Test-Path $hbbsPath) -or -not (Test-Path $hbbrPath)) { - Print-Error "BetterDesk binaries not found in $binSource" + Print-Step "Repairing BetterDesk server binaries..." + + # The supported installer architecture uses one Go binary. Do not gate + # repairs on hbbs-patch-v2: those legacy RustDesk artifacts are absent from + # fresh Go installations and are not needed by betterdesk-server.exe. + $goSourceDir = $script:GO_SERVER_SOURCE + $goSourceBinary = Join-Path $goSourceDir "betterdesk-server.exe" + $installedGoBinary = Join-Path $script:RUSTDESK_PATH "betterdesk-server.exe" + $goSourceAvailable = (Test-Path $goSourceBinary) -or (Test-Path (Join-Path $goSourceDir "go.mod")) + + if ($goSourceAvailable) { + if (-not (Install-Binaries -ForceRecompile)) { + Print-Error "Failed to compile or install betterdesk-server.exe" + return + } + } elseif (Test-Path $installedGoBinary) { + # A binary-only installation can still be repaired by validating and + # restarting it. Rebuilding requires the source tree or a later update. + try { + $header = [System.IO.File]::ReadAllBytes($installedGoBinary)[0..1] + if ($header[0] -ne 0x4D -or $header[1] -ne 0x5A) { + Print-Error "Invalid Windows executable: $installedGoBinary" + return + } + } catch { + Print-Error "Unable to validate $installedGoBinary`: $($_.Exception.Message)" + return + } + Print-Info "Validated existing Go server binary (source tree not present)" + } elseif ((Test-Path (Join-Path $script:RUSTDESK_PATH "hbbs.exe")) -and + (Test-Path (Join-Path $script:RUSTDESK_PATH "hbbr.exe"))) { + Print-Warning "Legacy RustDesk binaries detected; no Go source or Go binary is available." + Print-Info "Run an update or fresh Go installation to migrate this deployment." + if (-not (Start-ServicesWithVerification)) { + Print-Error "Legacy services failed to start after repair" + return + } + Print-Success "Legacy services verified; no Go binary was changed." + return + } else { + Print-Error "No BetterDesk server binary or source tree found." + Print-Info "Run a fresh installation or update before repairing binaries." return } - - # Backup current binaries - $timestamp = Get-Date -Format "yyyyMMddHHmmss" - if (Test-Path "$script:RUSTDESK_PATH\hbbs.exe") { - Copy-Item "$script:RUSTDESK_PATH\hbbs.exe" "$script:RUSTDESK_PATH\hbbs.exe.backup.$timestamp" -ErrorAction SilentlyContinue - } - if (Test-Path "$script:RUSTDESK_PATH\hbbr.exe") { - Copy-Item "$script:RUSTDESK_PATH\hbbr.exe" "$script:RUSTDESK_PATH\hbbr.exe.backup.$timestamp" -ErrorAction SilentlyContinue - } - - # Stop services and wait - Stop-AllServices - Start-Sleep -Seconds 3 - - # Extra check - make sure files are not locked - $hbbsLocked = $false - $hbbrLocked = $false - - try { - if (Test-Path "$script:RUSTDESK_PATH\betterdesk-server.exe") { - $stream = [System.IO.File]::Open("$script:RUSTDESK_PATH\betterdesk-server.exe", 'Open', 'ReadWrite', 'None') - $stream.Close() - } elseif (Test-Path "$script:RUSTDESK_PATH\hbbs.exe") { - $stream = [System.IO.File]::Open("$script:RUSTDESK_PATH\hbbs.exe", 'Open', 'ReadWrite', 'None') - $stream.Close() - } - } catch { - $hbbsLocked = $true - Print-Warning "Server binary is still locked, killing stale processes..." - Get-Process -Name "betterdesk-server" -ErrorAction SilentlyContinue | Stop-Process -Force - Get-Process -Name "hbbs" -ErrorAction SilentlyContinue | Stop-Process -Force - Start-Sleep -Seconds 2 - } - - try { - # Legacy hbbr check (Go server no longer uses separate relay binary) - if (Test-Path "$script:RUSTDESK_PATH\hbbr.exe") { - $stream = [System.IO.File]::Open("$script:RUSTDESK_PATH\hbbr.exe", 'Open', 'ReadWrite', 'None') - $stream.Close() - } - } catch { - $hbbrLocked = $true - Print-Warning "hbbr.exe is still locked, killing stale processes..." - Get-Process -Name "hbbr" -ErrorAction SilentlyContinue | Stop-Process -Force - Start-Sleep -Seconds 2 - } - - # Install binaries - if (-not (Install-Binaries)) { - Print-Error "Failed to install binaries" - return - } - - # Start with verification + if (-not (Start-ServicesWithVerification)) { - Print-Error "Services failed to start after repair" + Print-Error "Services failed to start after binary repair" return } - - Print-Success "Binaries repaired and verified!" + + Print-Success "BetterDesk server binaries repaired and verified!" } function Repair-Database { @@ -4463,11 +4596,13 @@ function Do-Uninstall { Print-Warning "This operation will remove BetterDesk Console!" Write-Host "" - if (-not (Confirm-Action "Are you sure you want to continue?")) { - return + if (-not $script:AUTO_MODE -and -not $script:UNINSTALL_MODE) { + if (-not (Confirm-Action "Are you sure you want to continue?")) { + return + } } - if (Confirm-Action "Create backup before uninstall?") { + if ($script:AUTO_MODE -or (Confirm-Action "Create backup before uninstall?")) { Do-BackupSilent } @@ -4476,28 +4611,61 @@ function Do-Uninstall { Print-Step "Removing services..." - # Remove Windows services (NSSM) - $nssmPath = Get-Command nssm -ErrorAction SilentlyContinue - if ($nssmPath) { - $nssm = if ($nssmPath -is [System.Management.Automation.ApplicationInfo]) { $nssmPath.Source } else { $nssmPath } + # Remove Windows services (NSSM). Installations may keep NSSM beside the + # installer instead of putting it on PATH. + $nssmCommand = Get-Command nssm -ErrorAction SilentlyContinue + $nssmCandidates = @() + if ($nssmCommand) { + $nssmCandidates += if ($nssmCommand -is [System.Management.Automation.ApplicationInfo]) { + $nssmCommand.Source + } else { + [string]$nssmCommand + } + } + $nssmCandidates += Join-Path $script:ScriptDir "tools\nssm.exe" + $nssm = $nssmCandidates | + Where-Object { $_ -and (Test-Path $_) } | + Select-Object -First 1 + if ($nssm) { + & $nssm remove $script:SERVER_SERVICE confirm 2>$null & $nssm remove $script:HBBS_SERVICE confirm 2>$null & $nssm remove $script:HBBR_SERVICE confirm 2>$null & $nssm remove $script:CONSOLE_SERVICE confirm 2>$null } + + # Also remove services directly when NSSM is unavailable or a stale + # service definition survived an earlier uninstall. + foreach ($serviceName in @( + $script:SERVER_SERVICE, + $script:HBBS_SERVICE, + $script:HBBR_SERVICE, + $script:CONSOLE_SERVICE, + "BetterDeskAPI" + )) { + if (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) { + Stop-Service -Name $serviceName -Force -ErrorAction SilentlyContinue + sc.exe delete $serviceName 2>$null | Out-Null + } + } # Remove scheduled tasks + Unregister-ScheduledTask -TaskName $script:SERVER_SERVICE -Confirm:$false -ErrorAction SilentlyContinue Unregister-ScheduledTask -TaskName $script:HBBS_SERVICE -Confirm:$false -ErrorAction SilentlyContinue Unregister-ScheduledTask -TaskName $script:HBBR_SERVICE -Confirm:$false -ErrorAction SilentlyContinue Unregister-ScheduledTask -TaskName $script:CONSOLE_SERVICE -Confirm:$false -ErrorAction SilentlyContinue - if (Confirm-Action "Remove installation files ($script:RUSTDESK_PATH)?") { + if ($script:PURGE_MODE -or (-not $script:AUTO_MODE -and (Confirm-Action "Remove installation files ($script:RUSTDESK_PATH)?"))) { Remove-Item -Path $script:RUSTDESK_PATH -Recurse -Force -ErrorAction SilentlyContinue Print-Info "Removed: $script:RUSTDESK_PATH" + } else { + Print-Info "Preserved server data: $script:RUSTDESK_PATH" } - if (Confirm-Action "Remove Web Console ($script:CONSOLE_PATH)?") { + if ($script:PURGE_MODE -or (-not $script:AUTO_MODE -and (Confirm-Action "Remove Web Console ($script:CONSOLE_PATH)?"))) { Remove-Item -Path $script:CONSOLE_PATH -Recurse -Force -ErrorAction SilentlyContinue Print-Info "Removed: $script:CONSOLE_PATH" + } else { + Print-Info "Preserved console data: $script:CONSOLE_PATH" } Print-Success "BetterDesk has been uninstalled" @@ -5840,10 +6008,17 @@ function Main { Write-Host "" Start-Sleep -Seconds 1 + if ($script:UNINSTALL_MODE -and -not $script:AUTO_MODE) { + Do-Uninstall + exit 0 + } + # Auto mode - run installation directly if ($script:AUTO_MODE) { Print-Info "Running in AUTO mode..." - if ($script:MINIMAL_MODE) { + if ($script:UNINSTALL_MODE) { + Do-Uninstall + } elseif ($script:MINIMAL_MODE) { Do-InstallMinimal } else { Do-Install diff --git a/betterdesk.sh b/betterdesk.sh index 7b3d35b7..9403c390 100644 --- a/betterdesk.sh +++ b/betterdesk.sh @@ -47,6 +47,8 @@ BETTERDESK_ORIG_ARGV=("$@") AUTO_MODE=false SKIP_VERIFY=false MINIMAL_MODE=false +UNINSTALL_MODE=false +PURGE_MODE=false PREFERRED_CONSOLE_TYPE="nodejs" # Always Node.js (Flask removed in v2.3.0) # Relay server selection mode: @@ -71,6 +73,14 @@ while [[ $# -gt 0 ]]; do MINIMAL_MODE=true shift ;; + --uninstall) + UNINSTALL_MODE=true + shift + ;; + --purge) + PURGE_MODE=true + shift + ;; --nodejs) PREFERRED_CONSOLE_TYPE="nodejs" shift @@ -117,6 +127,8 @@ while [[ $# -gt 0 ]]; do echo "" echo "Options:" echo " --auto, -a Run in automatic mode (non-interactive)" + echo " --uninstall Stop services and remove the native installation" + echo " --purge With --uninstall, also remove data and keys" echo " --skip-verify Skip SHA256 verification of binaries" echo " --minimal Install Go server only (no web console)" echo " --nodejs Install Node.js web console (default)" @@ -4428,6 +4440,32 @@ read_update_github_branch_from_env() { fi } +resolve_update_remote_sha() { + local clone_dir="$1" + local remote_sha="" + + if command -v git &>/dev/null && [ -d "$clone_dir/.git" ]; then + remote_sha=$(git -C "$clone_dir" rev-parse HEAD 2>/dev/null || true) + fi + if ! [[ "$remote_sha" =~ ^[0-9a-fA-F]{40}$ ]] && command -v git &>/dev/null; then + remote_sha=$(git ls-remote \ + "https://github.com/${UPDATE_GITHUB_OWNER}/${UPDATE_GITHUB_REPO}.git" \ + "refs/heads/${UPDATE_GITHUB_BRANCH}" 2>/dev/null | awk 'NR == 1 { print $1; exit }' || true) + fi + if ! [[ "$remote_sha" =~ ^[0-9a-fA-F]{40}$ ]] && command -v curl &>/dev/null; then + remote_sha=$(curl -fsSL --connect-timeout 15 --max-time 30 \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/${UPDATE_GITHUB_OWNER}/${UPDATE_GITHUB_REPO}/commits?sha=${UPDATE_GITHUB_BRANCH}&per_page=1" \ + 2>/dev/null | awk -F'"' '/"sha"[[:space:]]*:/ { print $4; exit }' || true) + fi + + if [[ "$remote_sha" =~ ^[0-9a-fA-F]{40}$ ]]; then + printf '%s\n' "$remote_sha" + return 0 + fi + return 1 +} + write_update_github_branch_to_env() { local branch="$1" local env_file="${CONSOLE_PATH:-}/.env" @@ -4514,6 +4552,8 @@ run_terminal_project_update() { update_from_github() { local clone_dir="$UPDATE_CLONE_DIR" local server_build_failed=0 + local previous_source_dir="" + local remote_sha="" read_update_github_branch_from_env @@ -4557,6 +4597,13 @@ update_from_github() { return 1 fi + if ! remote_sha=$(resolve_update_remote_sha "$clone_dir"); then + print_error "Could not resolve the downloaded commit SHA; refusing an untracked update" + rm -rf "$clone_dir" + return 1 + fi + print_info "Downloaded commit: ${remote_sha:0:7}" + # Read remote version local remote_version="" if [ -f "$clone_dir/VERSION" ]; then @@ -4569,8 +4616,13 @@ update_from_github() { # ---- Step 2: Update Go server source & compile ---- print_step "Updating Go server source..." if [ -d "$GO_SERVER_SOURCE" ]; then - # Backup existing source (lightweight — just rename) - mv "$GO_SERVER_SOURCE" "${GO_SERVER_SOURCE}.pre-update.$$" 2>/dev/null || true + # Keep the old tree until the new server has built successfully so a + # failed update can restore a known-good source tree. + previous_source_dir="${GO_SERVER_SOURCE}.pre-update.$$" + if ! mv "$GO_SERVER_SOURCE" "$previous_source_dir" 2>/dev/null; then + previous_source_dir="" + print_warning "Could not stage the previous Go source tree; update will continue in place" + fi fi # Copy the *contents* into a guaranteed-existing destination. Copying the # directory itself would nest the new tree inside an existing @@ -4581,10 +4633,9 @@ update_from_github() { cp -rf "$clone_dir/betterdesk-server/." "$GO_SERVER_SOURCE/" # Restore any local data/ directory that existed in the old source dir - if [ -d "${GO_SERVER_SOURCE}.pre-update.$$/data" ]; then - cp -rn "${GO_SERVER_SOURCE}.pre-update.$$/data" "$GO_SERVER_SOURCE/" 2>/dev/null || true + if [ -n "$previous_source_dir" ] && [ -d "$previous_source_dir/data" ]; then + cp -rn "$previous_source_dir/data" "$GO_SERVER_SOURCE/" 2>/dev/null || true fi - rm -rf "${GO_SERVER_SOURCE}.pre-update.$$" print_success "Go server source updated" # Compile Go server @@ -4594,7 +4645,7 @@ update_from_github() { if ! install_golang; then print_warning "Go toolchain not available — server binary not updated" print_info "Install Go manually from https://go.dev/dl/ and re-run update" - # Non-critical: source files were updated, binary can be built later + server_build_failed=1 fi fi @@ -4619,6 +4670,8 @@ update_from_github() { print_info "Use the panel Rebuild server binary button or option 7 (Build & deploy server)" server_build_failed=1 fi + else + server_build_failed=1 fi # ---- Step 3: Update Node.js console files ---- @@ -4708,10 +4761,12 @@ update_from_github() { # ---- Step 4: Update installer scripts ---- print_step "Updating installer scripts..." local scripts_updated=0 - for script_file in betterdesk.sh betterdesk.ps1 betterdesk-docker.sh \ + for script_file in install.sh betterdesk.sh betterdesk.ps1 betterdesk-docker.sh \ docker-compose.yml docker-compose.single.yml docker-compose.quick.yml \ docker-compose.quick.single.yml docker-compose.quick.single.macvlan.yml \ - Dockerfile Dockerfile.server Dockerfile.console VERSION; do + Dockerfile Dockerfile.server Dockerfile.console docker-entrypoint.sh \ + docker/entrypoint.sh docker/server-entrypoint.sh docker/console-entrypoint.sh \ + docker/supervisord.conf scripts/installer-protocol-check.js VERSION; do if [ -f "$clone_dir/$script_file" ]; then cp "$clone_dir/$script_file" "$SCRIPT_DIR/$script_file" 2>/dev/null || true if [[ "$script_file" == *.sh ]]; then @@ -4722,33 +4777,33 @@ update_from_github() { done print_success "$scripts_updated installer files updated" - # ---- Step 5: Update SHA tracking for in-app updater ---- - if command -v git &>/dev/null && [ -d "$clone_dir/.git" ]; then - local remote_sha - remote_sha=$(git -C "$clone_dir" rev-parse HEAD 2>/dev/null) - if [ -n "$remote_sha" ]; then - mkdir -p "$CONSOLE_PATH/data" - echo "$remote_sha" > "$CONSOLE_PATH/data/.update_sha" - echo "$remote_sha" > "$CONSOLE_PATH/data/.agent_source_sha" - rm -f "$CONSOLE_PATH/data/.last_update_result.json" - print_info "SHA tracking updated: ${remote_sha:0:7}" + if [ "$server_build_failed" -eq 1 ]; then + if [ -n "$previous_source_dir" ] && [ -d "$previous_source_dir" ]; then + rm -rf "$GO_SERVER_SOURCE" + mv "$previous_source_dir" "$GO_SERVER_SOURCE" 2>/dev/null || \ + print_warning "Could not restore the previous Go source tree" fi + rm -rf "$clone_dir" + print_error "Go server binary was not rebuilt — update incomplete for server component" + return 1 fi - # ---- Step 6: Update VERSION file in project root ---- + # Only mark the update complete after the server build/deploy succeeded. + mkdir -p "$CONSOLE_PATH/data" + printf '%s\n' "$remote_sha" > "$CONSOLE_PATH/data/.update_sha" + printf '%s\n' "$remote_sha" > "$CONSOLE_PATH/data/.agent_source_sha" + rm -f "$CONSOLE_PATH/data/.last_update_result.json" + print_info "SHA tracking updated: ${remote_sha:0:7}" + if [ -f "$clone_dir/VERSION" ] && [ -n "$remote_version" ]; then cp "$clone_dir/VERSION" "$SCRIPT_DIR/VERSION" 2>/dev/null || true cp "$clone_dir/VERSION" "$CONSOLE_PATH/VERSION" 2>/dev/null || true fi - - # Cleanup rm -rf "$clone_dir" - - print_success "All project files updated from GitHub" - if [ "$server_build_failed" -eq 1 ]; then - print_error "Go server binary was not rebuilt — update incomplete for server component" - return 1 + if [ -n "$previous_source_dir" ]; then + rm -rf "$previous_source_dir" fi + print_success "All project files updated from GitHub" return 0 } @@ -6457,11 +6512,13 @@ do_uninstall() { print_warning "This operation will remove BetterDesk Console!" echo "" - if ! confirm "Are you sure you want to continue?"; then - return + if [ "$AUTO_MODE" != true ] && [ "$UNINSTALL_MODE" != true ]; then + if ! confirm "Are you sure you want to continue?"; then + return + fi fi - if confirm "Create backup before uninstall?"; then + if [ "$AUTO_MODE" = true ] || confirm "Create backup before uninstall?"; then do_backup_silent fi @@ -6485,14 +6542,18 @@ do_uninstall() { rm -f /etc/systemd/system/betterdesk-go.service systemctl daemon-reload - if confirm "Remove installation files ($RUSTDESK_PATH)?"; then + if [ "$PURGE_MODE" = true ] || { [ "$AUTO_MODE" != true ] && confirm "Remove installation files ($RUSTDESK_PATH)?"; }; then rm -rf "$RUSTDESK_PATH" print_info "Removed: $RUSTDESK_PATH" + else + print_info "Preserved server data: $RUSTDESK_PATH" fi - if confirm "Remove Web Console ($CONSOLE_PATH)?"; then + if [ "$PURGE_MODE" = true ] || { [ "$AUTO_MODE" != true ] && confirm "Remove Web Console ($CONSOLE_PATH)?"; }; then rm -rf "$CONSOLE_PATH" print_info "Removed: $CONSOLE_PATH" + else + print_info "Preserved console data: $CONSOLE_PATH" fi print_success "BetterDesk has been uninstalled" @@ -7591,7 +7652,9 @@ main() { # Auto mode - run installation directly if [ "$AUTO_MODE" = true ]; then print_info "Running in AUTO mode..." - if [ "$MINIMAL_MODE" = true ]; then + if [ "$UNINSTALL_MODE" = true ]; then + do_uninstall + elif [ "$MINIMAL_MODE" = true ]; then do_install_minimal else do_install diff --git a/docker-compose.quick.single.yml b/docker-compose.quick.single.yml index 2bac75c4..093517d3 100644 --- a/docker-compose.quick.single.yml +++ b/docker-compose.quick.single.yml @@ -5,7 +5,7 @@ # Go server + Node.js console in one container (recommended for all deployments). # # Image tag (aligned with CHANGELOG / git tag): -# Default: 3.3.169 | Rolling: BETTERDESK_IMAGE_TAG=latest +# Default: 3.5.37 | Rolling: BETTERDESK_IMAGE_TAG=latest # # Usage (automated — recommended): # curl -fsSL https://raw.githubusercontent.com/UNITRONIX/BetterDesk/main/install.sh | sudo bash @@ -15,7 +15,7 @@ # docker compose pull && docker compose up -d # # Pin a specific release: -# BETTERDESK_IMAGE_TAG=3.3.169 docker compose up -d +# BETTERDESK_IMAGE_TAG=3.5.37 docker compose up -d # # Web Console: http://localhost:5000 # RustDesk client API: http://localhost:21121 (Go server — all-in-one default port) diff --git a/docs/PRE_RELEASE_CHECKLIST.md b/docs/PRE_RELEASE_CHECKLIST.md index ef58d115..d744a456 100644 --- a/docs/PRE_RELEASE_CHECKLIST.md +++ b/docs/PRE_RELEASE_CHECKLIST.md @@ -73,6 +73,38 @@ Use this checklist before every tagged release to ensure quality and stability. - [ ] **Windows fresh**: `.\betterdesk.ps1 -Auto` on clean Windows Server - [ ] **Windows update**: `.\betterdesk.ps1` option 2 preserves DB + config - [ ] **Docker script**: `./betterdesk-docker.sh` option 1 installs successfully +- [ ] **Static CI**: `Installer CI` passes Bash syntax, PowerShell AST and + Compose validation; `install.sh` is included in version verification. +- [ ] **Installer unit gate**: `Installer CI` runs the protocol, safe-path, + disk-space preflight and binary-rollback tests; the non-mutating `--help` + checks pass. +- [ ] **Protocol matrix**: run + `node scripts/installer-protocol-check.js` against the selected API, + console/reverse-proxy URL and signal/relay ports; for HTTPS confirm the + certificate SAN and redirect behaviour. +- [ ] **Agent fallback**: Linux agent service and Windows NSSM service start; + on a disposable Windows host with NSSM unavailable, the scheduled-task + fallback starts and `-Uninstall` / `-u` removes both service/task variants + while preserving data; use `-Purge` / `--purge` only for explicit cleanup. +- [ ] **Support Agent lifecycle**: `betterdesk-support-agent -install` is + idempotent; `-uninstall` removes autostart and binaries while preserving + enrollment state, and `-uninstall -purge` removes state only when requested. +- [ ] **Native uninstall**: `betterdesk.sh --auto --uninstall` and + `betterdesk.ps1 -Auto -Uninstall` remove services while preserving data; + repeat with `--purge` / `-Purge` only when data removal is intended. +- [ ] **Docker uninstall**: default `install.sh --uninstall` preserves + volumes; `--purge` removes them only after an explicit data-loss decision. +- [ ] **Rollback**: force a failed update in a disposable environment and + confirm the previous console/source/binary remains usable and update SHA is + not advanced. +- [ ] **Runtime smoke**: run the manual `Installer CI` Docker runtime smoke + workflow for the exact GHCR tag intended for release. +- [ ] **Lifecycle E2E**: on isolated Linux and Windows hosts, execute fresh + install → update → repair → backup/restore → uninstall → reinstall; record + elapsed time and confirm no duplicate services, rules or data. + +See [`docs/important/installer-contract.md`](important/installer-contract.md) +for the lifecycle guarantees and platform endpoint matrix. ## 9. Documentation & Release diff --git a/docs/docker/DOCKER_TROUBLESHOOTING.md b/docs/docker/DOCKER_TROUBLESHOOTING.md index dac1485e..23a4a47c 100644 --- a/docs/docker/DOCKER_TROUBLESHOOTING.md +++ b/docs/docker/DOCKER_TROUBLESHOOTING.md @@ -443,7 +443,7 @@ OCI runtime exec failed: exec failed: … executable file not found in $PATH ```bash # Pull the current tag (match VERSION / compose default), then recreate: cd /opt/betterdesk/docker # or your compose directory -# Ensure .env has BETTERDESK_IMAGE_TAG=, e.g. 3.3.169 +# Ensure .env has BETTERDESK_IMAGE_TAG=, e.g. 3.5.37 docker compose pull && docker compose up -d # Official single-container service name: diff --git a/docs/important/betterdesk-update-flow.md b/docs/important/betterdesk-update-flow.md index 023a57bb..0f29480b 100644 --- a/docs/important/betterdesk-update-flow.md +++ b/docs/important/betterdesk-update-flow.md @@ -28,11 +28,41 @@ GitHub `compare` API caps `files` at 300, so large diffs are truncated and chang - **Passwords:** Panel login uses `users.password_hash` in `auth.db` / PostgreSQL. Updates must **not** change DB passwords. - **Services:** `patch_service_definitions` / `Patch-ServiceDefinitions` on every update when units exist; full `Setup-Services` only when missing or when operator confirms recreate (`[y/N]` prompt) / `UPDATE_REFRESH_SERVICES=true`. - **Script update failure:** GitHub update returns non-zero if Go server binary compile fails. +- **Tracked commit:** both Bash and PowerShell installers resolve the downloaded + commit SHA through Git or the GitHub API. Tar/ZIP fallback updates refuse to + advance tracking when the commit cannot be verified. +- **Failure recovery:** panel updates create a manifest for changed console, + server and installer files. Critical failures automatically restore the + manifest and any deployed server binary backup unless `autoRollback` is + explicitly disabled. ### Stale panel warning after script / Docker update (#192) `data/.last_update_result.json` stores the last **in-panel** update outcome. A failed panel attempt (e.g. `EACCES` on root-owned `/opt/` files) can leave a red banner even when a later **script** or **Docker** update succeeded. +An update is not considered complete until the critical source/binary steps +finish. `.update_sha`, `.agent_source_sha` and the stale-result marker are +updated only after that point. Before applying changes, the panel also checks +the writable data path and available disk space (override the 512 MiB minimum +with `UPDATE_MIN_FREE_MB` when a deployment has a documented different +requirement). If the host exposes `statfsSync` without usable block +statistics, the check is reported as unsupported rather than blocking every +Windows update. + +The same cross-platform protocol harness is available after installation: + +```bash +node scripts/installer-protocol-check.js \ + --api-url http://127.0.0.1:21114/api/health \ + --panel-url http://127.0.0.1:5000/health \ + --port 127.0.0.1:21116 +``` + +Use `21121` instead of `21114` for the Docker single-container layout. The +harness distinguishes TCP reachability from HTTP success, accepts a recorded +3xx redirect, validates HTTPS certificate SANs by default, and supports +`--insecure` only for explicitly disposable/self-signed checks. + | Update path | Clears stale banner | |-------------|---------------------| | Settings → Updates (panel, success) | Yes — or only critical failures persisted | diff --git a/docs/important/installer-contract.md b/docs/important/installer-contract.md new file mode 100644 index 00000000..10087a16 --- /dev/null +++ b/docs/important/installer-contract.md @@ -0,0 +1,90 @@ +# BetterDesk installer contract + +This document defines the supported lifecycle contract for BetterDesk +installation paths. The contract is intentionally platform-neutral; each +installer may use native service tooling, but must provide the same observable +result. + +## Supported entry points + +| Path | Runtime | Scope | +|---|---|---| +| `install.sh` | Linux | Docker quick install, native bootstrap, Docker rescue/uninstall | +| `betterdesk.sh` | Linux | Native server and console lifecycle manager | +| `betterdesk.ps1` | Windows | Native server and console lifecycle manager | +| `betterdesk-docker.sh` | Linux + Docker | Compose lifecycle, rescue, migration and diagnostics | +| `betterdesk-agent/install/install.sh` | Linux | Native agent install/uninstall | +| `betterdesk-agent/install/install.ps1` | Windows | Native agent install/uninstall | +| `betterdesk-support-agent/install.go` | Linux, Windows, macOS | Support-agent self-install/uninstall | +| `scripts/installer-protocol-check.js` | Linux, Windows, Docker | Shared HTTP/HTTPS, redirect, SAN and TCP verification | + +The `build-betterdesk.*` wrappers and `betterdesk-server/deploy.sh` are +development or migration tools. They must not silently replace the official +installer lifecycle or be presented as the primary production installation +path. + +## Lifecycle guarantees + +Every official installer must implement or explicitly reject these operations +with a clear message and non-zero exit code: + +1. **Preflight** — verify platform, privilege, dependencies, writable paths, + available disk space, required ports, network access and compatible version. +2. **Fresh install** — create only the required directories and services, + preserve operator-supplied secrets, initialize the selected database and + finish with a health check. +3. **Update** — download a complete, validated source/image, preserve runtime + state, apply migrations, deploy binaries atomically, restart affected + services and write the commit/version marker only after verification. +4. **Repair** — restore missing binaries, dependencies, permissions, service + definitions and TLS material without requiring legacy RustDesk artifacts for + the Go deployment. +5. **Validate/diagnose** — report actionable errors and warnings without + changing data in read-only diagnostic mode. +6. **Backup and restore** — include the database, keys, `.env` and other + runtime credentials; restore must validate the backup manifest before + writing files. +7. **Uninstall** — stop and remove all service variants created by the + installer. Default uninstall preserves data; an explicit purge may remove + data, volumes and generated firewall rules. +8. **Reinstall** — be safe after a non-purge uninstall and preserve state when + the operator chooses to keep it. + +## Completion criteria + +An operation is successful only when all of the following are true: + +- the process exits with code `0`; +- the installed product version and update SHA describe the deployed code; +- required services are running under the intended account; +- configured HTTP/API/TLS health checks pass; +- database, key material and operator configuration remain usable; +- a repeated invocation does not duplicate services, rules, files or data; +- failures leave the previous binary/configuration usable or provide a + verified rollback path. + +An update must not advance `.update_sha`, `.agent_source_sha` or clear a +previous failure marker while a critical source, binary, migration or service +step is incomplete. + +## Platform-specific endpoints + +- Native Linux and Windows: panel health on the configured HTTP/HTTPS port, + Go API on `21114`, client API on `21121`, and signal/relay listeners as + configured. +- Docker single layout: Go/client API on `21121` and console on `5000`. +- Docker split layout: Go API on `21114` and console on `5000`. + +The protocol verification must distinguish a successful TCP listener from a +successful HTTP response and, for HTTPS, a valid certificate/SAN and TLS +handshake. + +## Safety rules + +- Never overwrite existing `.env` secrets with template values. +- Never remove database, keys, volumes or firewall rules without explicit + confirmation or a purge flag. +- Never require `hbbs`, `hbbr` or `hbbs-patch-v2` to repair a Go + `betterdesk-server` installation. +- Never report success after a partial update merely because the process + restarted. diff --git a/docs/important/installer-tui-and-protocol-tests.md b/docs/important/installer-tui-and-protocol-tests.md index 544274b3..8430edb7 100644 --- a/docs/important/installer-tui-and-protocol-tests.md +++ b/docs/important/installer-tui-and-protocol-tests.md @@ -49,4 +49,12 @@ - HTTP `:5000` is a redirect listener only when HTTPS + `HTTP_REDIRECT_HTTPS=true`; plain panel URL in HTTP mode is `http://:5000`. - If stuck after a failed toggle: **Repair → Repair HTTPS / TLS**, then restart services. Panel updates also re-run LE redeploy + `SIGNAL_PORT=21116` isolation (#219). -NOT yet mirrored to betterdesk.ps1 / betterdesk-docker.sh. +Windows now performs HTTP health verification for the Go API and web console +after service start. Docker installers verify the API and console health +endpoints after containers start, and the rescue diagnostics remain +non-destructive. Linux keeps the full `run_protocol_tests()` reverse-proxy +matrix; Linux, Windows and Docker also share the built-in +`scripts/installer-protocol-check.js` harness for HTTP/HTTPS, redirects, +certificate SANs and TCP listeners. Run it with the endpoint/port matrix from +[`installer-contract.md`](installer-contract.md) whenever a platform-specific +TLS or reverse-proxy check is needed. diff --git a/docs/important/installer-tui-modernization.md b/docs/important/installer-tui-modernization.md index 735b1a54..682783b6 100644 --- a/docs/important/installer-tui-modernization.md +++ b/docs/important/installer-tui-modernization.md @@ -15,12 +15,16 @@ Goal: arrow-key TUI look across betterdesk.sh, betterdesk-docker.sh, betterdesk. - betterdesk-docker.sh: TUI ported, main + 7 sub-menus. `bash -n` OK. - betterdesk.ps1: 10 menus via Invoke-MenuChoose (DatabaseType, update-method, repair, password-reset, diagnostics, paths, SSL, protocol-toggle, build, migration) + - main menu via Invoke-TuiSelect with menuLabels/menuActions. NO pwsh on Fedora -> not runtime-tested. + main menu via Invoke-TuiSelect with menuLabels/menuActions. PowerShell AST + parsing is now enforced by `.github/workflows/installer-ci.yml`; runtime + install/update tests still require a Windows environment. ## Rules learned - Keep emoji OUT of printf/PadRight TUI labels (renderer counts 1 cell, term shows 2). Use ASCII `->`, `+--+`. - Data-entry prompts (host/port/password/paths/domain) stay plain read/Read-Host. Only MENUS convert. - menu_choose/Invoke-MenuChoose keep SAME return tokens the existing case/switch expects. - Docker SSL menu originally had no back option -> added `0` + `0) return ;;`. -- Run `bash -n` after each bash script. PS1: flag user to verify on Windows. +- Run `bash -n` after each bash script. CI parses every official Bash and + PowerShell installer; runtime install/update tests remain an environment + validation step. - Classic fallback: BETTERDESK_CLASSIC_MENU=1 (bash) / $env:BETTERDESK_CLASSIC_MENU=1 (ps1). diff --git a/docs/setup/UPDATE_GUIDE.md b/docs/setup/UPDATE_GUIDE.md index 6457e344..f8114bd5 100644 --- a/docs/setup/UPDATE_GUIDE.md +++ b/docs/setup/UPDATE_GUIDE.md @@ -1,6 +1,24 @@ -# BetterDesk Console - Update Scripts +# Legacy BetterDesk v1 Update Scripts -This directory contains scripts to update BetterDesk Console from v1.0.0 to v1.1.0. +This document is retained for historical migrations from the v1.0.0/v1.1.0 +Flask-era console. The scripts and SSH workflow described below are not the +supported update path for current BetterDesk releases. + +## Current supported update paths + +- Native Linux: `sudo ./betterdesk.sh` → Update, or + `sudo ./betterdesk.sh --auto` for a scripted update. +- Native Windows: run `.\betterdesk.ps1` as Administrator and choose Update, + or `.\betterdesk.ps1 -Auto`. +- Docker: `docker compose pull && docker compose up -d` for image deployments, + or use `betterdesk-docker.sh` for a source rebuild. +- Panel/CLI: Settings → Updates or `node web-nodejs/scripts/update-cli.js`. + +See [`docs/important/installer-contract.md`](../important/installer-contract.md) +and [`docs/important/betterdesk-update-flow.md`](../important/betterdesk-update-flow.md) +for current backup, rollback, SHA and health-check guarantees. + +The remainder of this file documents the legacy migration only. ## What's New in v1.1.0 diff --git a/install.sh b/install.sh index 6871c58c..e39a1899 100755 --- a/install.sh +++ b/install.sh @@ -463,8 +463,12 @@ EOF configure_firewall log "Waiting for services..." - wait_for_http "$api_health_url" "BetterDesk API" 90 || true - wait_for_http "http://127.0.0.1:5000/login" "Web console" 60 || true + local health_failed=0 + wait_for_http "$api_health_url" "BetterDesk API" 90 || health_failed=1 + wait_for_http "http://127.0.0.1:5000/login" "Web console" 60 || health_failed=1 + if [ "$health_failed" -ne 0 ]; then + die "BetterDesk containers did not pass health checks; inspect ${compose_dir}/docker-compose.yml logs before retrying" + fi print_docker_summary "$relay" } @@ -555,6 +559,23 @@ install_native_mode() { ok "Native installation finished. See ${repo_dir} for logs and credentials." } +uninstall_native_mode() { + local repo_dir="${INSTALL_DIR}/source" + local native_installer="${repo_dir}/betterdesk.sh" + + require_root + if [ ! -x "$native_installer" ]; then + die "Native installer not found at ${native_installer}; nothing was removed" + fi + + log "Running native uninstall (data is preserved unless --purge is supplied)..." + local args=(--auto --uninstall) + if [ "$DO_PURGE" = true ]; then + args+=(--purge) + fi + (cd "$repo_dir" && "$native_installer" "${args[@]}") +} + rescue_native_mode() { local repo_dir="${INSTALL_DIR}/source" @@ -577,7 +598,11 @@ main() { echo "" if [ "$DO_UNINSTALL" = true ]; then - uninstall_docker_mode + case "$INSTALL_MODE" in + docker) uninstall_docker_mode ;; + native) uninstall_native_mode ;; + *) die "Unknown install mode: $INSTALL_MODE" ;; + esac exit 0 fi diff --git a/scripts/bump-version.js b/scripts/bump-version.js index 82ee09b5..1d395327 100644 --- a/scripts/bump-version.js +++ b/scripts/bump-version.js @@ -170,8 +170,10 @@ const FILE_RULES = [ const m = content.match(/\$\{BETTERDESK_IMAGE_TAG:-([^}]+)\}/); return m?.[1]; }, - apply: (content, version) => - content.replace(/\$\{BETTERDESK_IMAGE_TAG:-[^}]+\}/g, `\${BETTERDESK_IMAGE_TAG:-${version}}`), + apply: (content, version) => content + .replace(/\$\{BETTERDESK_IMAGE_TAG:-[^}]+\}/g, `\${BETTERDESK_IMAGE_TAG:-${version}}`) + .replace(/(#\s+Default:\s+)[^ \t]+(\s+\|\s+Rolling:)/, `$1${version}$2`) + .replace(/(BETTERDESK_IMAGE_TAG=)[^ \s]+(\s+docker compose up -d)/, `$1${version}$2`), }, { id: 'docker-compose-quick-single-macvlan', diff --git a/scripts/installer-protocol-check.js b/scripts/installer-protocol-check.js new file mode 100644 index 00000000..3a25d012 --- /dev/null +++ b/scripts/installer-protocol-check.js @@ -0,0 +1,155 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Cross-platform installer protocol harness. + * + * It deliberately uses only Node.js built-ins so Linux, Windows and Docker + * can run the same checks without curl/OpenSSL-specific behaviour. + */ +const http = require('http'); +const https = require('https'); +const net = require('net'); +const { URL } = require('url'); + +function parseArgs(argv) { + const options = { + apiUrl: 'http://127.0.0.1:21121/api/health', + panelUrl: 'http://127.0.0.1:5000/health', + proxyUrl: '', + ports: [], + timeoutMs: 5000, + insecure: false, + }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + const next = argv[i + 1]; + if (arg === '--api-url') options.apiUrl = next; + else if (arg === '--panel-url') options.panelUrl = next; + else if (arg === '--proxy-url') options.proxyUrl = next; + else if (arg === '--port') options.ports.push(next); + else if (arg === '--timeout-ms') options.timeoutMs = Number(next); + else if (arg === '--insecure') options.insecure = true; + else if (arg === '--help' || arg === '-h') options.help = true; + else throw new Error(`Unknown option: ${arg}`); + if (arg !== '--insecure' && arg !== '--help' && arg !== '-h') i += 1; + } + if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 250) { + throw new Error('--timeout-ms must be at least 250'); + } + return options; +} + +function requestEndpoint(rawUrl, options = {}) { + const target = new URL(rawUrl); + const transport = target.protocol === 'https:' ? https : http; + return new Promise((resolve, reject) => { + const request = transport.request(target, { + method: 'GET', + timeout: options.timeoutMs || 5000, + rejectUnauthorized: options.insecure === true ? false : true, + headers: { 'User-Agent': 'BetterDesk-Installer-Protocol-Check/1' }, + }, (response) => { + let body = ''; + response.setEncoding('utf8'); + response.on('data', (chunk) => { body += chunk; }); + response.on('end', () => resolve({ + statusCode: response.statusCode || 0, + headers: response.headers, + body, + certificate: response.socket?.getPeerCertificate?.() || null, + })); + }); + request.on('timeout', () => request.destroy(new Error('request timed out'))); + request.on('error', reject); + request.end(); + }); +} + +function checkCertificateHostname(certificate, hostname) { + if (!certificate || !hostname) return false; + const names = String(certificate.subjectaltname || '') + .split(',') + .map((name) => name.trim().replace(/^DNS:/i, '')) + .filter(Boolean); + return names.includes(hostname) + || names.some((name) => name.startsWith('*.') && hostname.endsWith(name.slice(1))); +} + +async function checkEndpoint(rawUrl, options = {}) { + const target = new URL(rawUrl); + const result = { url: rawUrl, ok: false, statusCode: 0, redirect: null }; + try { + const response = await requestEndpoint(rawUrl, options); + result.statusCode = response.statusCode; + if (response.statusCode >= 200 && response.statusCode < 300) { + result.ok = true; + } else if (response.statusCode >= 300 && response.statusCode < 400) { + result.redirect = response.headers.location || null; + result.ok = Boolean(result.redirect); + } + if (target.protocol === 'https:' && !options.insecure) { + result.certificateValid = checkCertificateHostname(response.certificate, target.hostname); + result.ok = result.ok && result.certificateValid; + } + } catch (error) { + result.error = error.message; + } + return result; +} + +function checkPort(rawPort, timeoutMs = 5000) { + const [host, portText] = String(rawPort).includes(':') + ? String(rawPort).split(/:(?=[^:]+$)/) + : ['127.0.0.1', rawPort]; + const port = Number(portText); + return new Promise((resolve) => { + const socket = net.connect({ host, port, timeout: timeoutMs }); + const finish = (ok, error) => { + socket.destroy(); + resolve({ port: rawPort, ok, error: error?.message || null }); + }; + socket.once('connect', () => finish(true)); + socket.once('timeout', () => finish(false, new Error('connection timed out'))); + socket.once('error', (error) => finish(false, error)); + }); +} + +async function run(options) { + const checks = []; + for (const endpoint of [options.apiUrl, options.panelUrl, options.proxyUrl].filter(Boolean)) { + checks.push(await checkEndpoint(endpoint, options)); + } + for (const port of options.ports) checks.push(await checkPort(port, options.timeoutMs)); + return { ok: checks.every((check) => check.ok), checks }; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + console.log('Usage: installer-protocol-check.js [--api-url URL] [--panel-url URL] [--proxy-url URL] [--port HOST:PORT] [--insecure]'); + return; + } + const result = await run(options); + for (const check of result.checks) { + const label = check.url || check.port; + console.log(`${check.ok ? 'PASS' : 'FAIL'} ${label}${check.statusCode ? ` (${check.statusCode})` : ''}${check.error ? `: ${check.error}` : ''}`); + } + if (!result.ok) process.exitCode = 1; +} + +if (require.main === module) { + main().catch((error) => { + console.error(`FAIL ${error.message}`); + process.exitCode = 1; + }); +} + +module.exports = { + parseArgs, + requestEndpoint, + checkEndpoint, + checkCertificateHostname, + checkPort, + run, +}; diff --git a/web-nodejs/lib/safePath.js b/web-nodejs/lib/safePath.js index 92744ba2..6d0a10e9 100644 --- a/web-nodejs/lib/safePath.js +++ b/web-nodejs/lib/safePath.js @@ -6,9 +6,37 @@ const path = require('path'); /** * True when resolvedPath is rootDir or a descendant (no .. escape). */ +function normalizeComparablePath(value) { + let normalized = path.resolve(value); + const unresolvedSegments = []; + let existingPath = normalized; + while (!fs.existsSync(existingPath)) { + const parent = path.dirname(existingPath); + if (parent === existingPath) break; + unresolvedSegments.unshift(path.basename(existingPath)); + existingPath = parent; + } + if (fs.existsSync(existingPath)) { + try { + normalized = path.join( + fs.realpathSync.native(existingPath), + ...unresolvedSegments + ); + } catch (_e) { + // Keep the lexical path when the filesystem cannot resolve it. + } + } + if (process.platform === 'win32') { + // realpathSync.native may return the extended-length form while + // path.resolve returns a regular drive path. + normalized = normalized.replace(/^\\\\\?\\/, '').toLowerCase(); + } + return normalized; +} + function isPathInsideRoot(resolvedPath, rootDir) { - const root = path.resolve(rootDir); - const target = path.resolve(resolvedPath); + const root = normalizeComparablePath(rootDir); + const target = normalizeComparablePath(resolvedPath); const rel = path.relative(root, target); return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); } diff --git a/web-nodejs/services/updateService.js b/web-nodejs/services/updateService.js index 0c92eb89..4e7a0ad6 100644 --- a/web-nodejs/services/updateService.js +++ b/web-nodejs/services/updateService.js @@ -176,10 +176,13 @@ const COMPONENTS = { scripts: { // matched by exact file names, not prefix files: [ - 'betterdesk.sh', 'betterdesk.ps1', 'betterdesk-docker.sh', + 'install.sh', 'betterdesk.sh', 'betterdesk.ps1', 'betterdesk-docker.sh', 'docker-compose.yml', 'docker-compose.single.yml', 'docker-compose.quick.yml', 'docker-compose.quick.single.yml', 'docker-compose.quick.single.macvlan.yml', - 'Dockerfile', 'Dockerfile.server', 'Dockerfile.console' + 'Dockerfile', 'Dockerfile.server', 'Dockerfile.console', + 'docker-entrypoint.sh', 'docker/entrypoint.sh', + 'docker/server-entrypoint.sh', 'docker/console-entrypoint.sh', + 'docker/supervisord.conf', 'scripts/installer-protocol-check.js' ], label: 'Scripts & Docker', localRoot: PROJECT_ROOT, @@ -2284,7 +2287,7 @@ async function getChangedFiles(remoteSHA) { /** * Create a pre-update backup of console files that will be changed. */ -async function createPreUpdateBackup(allFiles) { +async function createPreUpdateBackup(allFiles, opts = {}) { const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); const backupName = `pre-update-${ts}`; const backupPath = resolveChildPath(path.resolve(BACKUP_DIR), backupName); @@ -2293,24 +2296,72 @@ async function createPreUpdateBackup(allFiles) { const localVersion = getLocalVersion(); const localSHA = getLocalSHA(); let backedUp = 0; + const backedUpFiles = []; + const removeOnRestore = []; + + const copyFileToBackup = (src, relativePath) => { + if (!relativePath || isProtectedRuntimePath(src)) return false; + const dest = resolvePathUnderRoot(backupPath, relativePath); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(src, dest); + backedUpFiles.push(relativePath.replace(/\\/g, '/')); + backedUp++; + return true; + }; for (const file of allFiles) { - if (file.component !== 'console' || !file.localPath) continue; - const src = resolveConsoleLocalPath(file.localPath); - if (fs.existsSync(src)) { - const dest = resolvePathUnderRoot(backupPath, file.localPath); - fs.mkdirSync(path.dirname(dest), { recursive: true }); - fs.copyFileSync(src, dest); - backedUp++; + if (!file.localPath) continue; + const sourceRoot = file.component === 'console' + ? ROOT_DIR + : file.component === 'scripts' + ? PROJECT_ROOT + : file.component === 'server' + ? resolveServerSourceRootForUpdate() + : null; + if (!sourceRoot) continue; + const relativePath = file.component === 'console' + ? file.localPath + : file.component === 'server' + ? file.path.slice(COMPONENTS.server.prefix.length) + : file.localPath; + const src = path.join(sourceRoot, relativePath); + if (fs.existsSync(src) && fs.statSync(src).isFile()) { + copyFileToBackup(src, `${file.component}/${relativePath}`); + } else { + removeOnRestore.push(`${file.component}/${relativePath}`.replace(/\\/g, '/')); } } + // A truncated GitHub compare diff is followed by a full tree sync. Back + // up the complete deployable console tree in that case, otherwise a + // restore could only recover the files listed by the truncated compare. + if (opts.fullConsole) { + const walkConsoleTree = (currentDir, relativeDir = '') => { + for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) { + const relativePath = path.join(relativeDir, entry.name); + const sourcePath = path.join(currentDir, entry.name); + if (['data', 'node_modules'].includes(entry.name) && !relativeDir) continue; + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory()) { + walkConsoleTree(sourcePath, relativePath); + continue; + } + if (entry.isFile() && isConsoleDeployLocalPath(relativePath)) { + copyFileToBackup(sourcePath, `console/${relativePath}`); + } + } + }; + walkConsoleTree(ROOT_DIR); + } + fs.writeFileSync(resolveChildPath(backupPath, 'manifest.json'), JSON.stringify({ version: localVersion, sha: localSHA, timestamp: new Date().toISOString(), filesBackedUp: backedUp, - files: allFiles.filter(f => f.component === 'console' && f.localPath).map(f => f.localPath) + fullConsole: !!opts.fullConsole, + files: backedUpFiles, + removeOnRestore }, null, 2)); // Mesh agent-server cert (loss requires re-enrolling all MeshAgents) @@ -2319,7 +2370,8 @@ async function createPreUpdateBackup(allFiles) { if (rustdeskDir) { const meshCert = path.join(rustdeskDir, 'mesh_agent_server.pem'); if (fs.existsSync(meshCert)) { - const dest = resolveChildPath(backupPath, 'mesh_agent_server.pem'); + const dest = resolveChildPath(backupPath, 'special/mesh_agent_server.pem'); + fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.copyFileSync(meshCert, dest); backedUp++; } @@ -2453,7 +2505,9 @@ async function applyUpdate(remoteSHA, changedData, opts = {}) { let backupInfo = null; if (createBackup) { const allFiles = Object.values(changedData.grouped).flat(); - backupInfo = await createPreUpdateBackup(allFiles); + backupInfo = await createPreUpdateBackup(allFiles, { + fullConsole: !!changedData.compareTruncated, + }); } const results = { @@ -2676,6 +2730,12 @@ async function applyUpdate(remoteSHA, changedData, opts = {}) { try { const sourceResult = await ensureServerSource(remoteSHA, { force: true }); console.log(`[UPDATE] Server source: strategy=${sourceResult.strategy}, files=${sourceResult.filesDownloaded}`); + for (const failure of sourceResult.failed || []) { + results.failed.push({ + file: failure.path || 'server-source', + error: failure.error || 'Server source file download failed', + }); + } } catch (err) { results.failed.push({ file: 'server-source', error: `Source download failed: ${err.message}` }); } @@ -2790,7 +2850,8 @@ async function applyUpdate(remoteSHA, changedData, opts = {}) { success: deployResult.success, backupPath: deployResult.backupPath || null, error: deployResult.error || null, - method: buildUsed + method: buildUsed, + targetPath }; if (deployResult.success) { @@ -2824,6 +2885,36 @@ async function applyUpdate(remoteSHA, changedData, opts = {}) { results.criticalFailures = criticalFailures; results.nonCriticalFailures = nonCriticalFailures; + if (criticalFailures.length > 0 && createBackup && opts.autoRollback !== false && backupInfo?.backupPath) { + try { + const rollback = restoreFromBackup(path.basename(backupInfo.backupPath)); + const binaryRollback = results.serverDeploy?.backupPath + ? restoreServerBinaryBackup( + results.serverDeploy.backupPath, + results.serverDeploy.targetPath + ) + : { restored: false, skipped: true }; + results.rollback = { + attempted: true, + success: !binaryRollback.error && rollback.restored >= 0, + filesRestored: rollback.restored, + filesRemoved: rollback.removed || 0, + binary: binaryRollback, + }; + console.warn( + `[UPDATE] Critical update failure — restored ${rollback.restored} file(s)` + + ` and removed ${rollback.removed || 0} new file(s)` + ); + } catch (rollbackErr) { + results.rollback = { + attempted: true, + success: false, + error: rollbackErr.message || String(rollbackErr), + }; + console.error(`[UPDATE] Automatic rollback failed: ${rollbackErr.message}`); + } + } + // Security visibility: if the Go server source changed // dependency bump shipping a security fix) but the binary could not be // rebuilt/deployed, the running process is still the OLD binary. Persist a @@ -3114,8 +3205,41 @@ function pruneBackups(keep) { return { kept: n, deleted }; } +function restoreServerBinaryBackup(backupPath, targetPath) { + if (!backupPath || !targetPath) { + return { restored: false, error: 'Server binary backup path is incomplete' }; + } + const backup = path.resolve(backupPath); + const target = path.resolve(targetPath); + const expectedPrefix = `${path.basename(target)}.bak.`; + if (path.dirname(backup) !== path.dirname(target) + || !path.basename(backup).startsWith(expectedPrefix) + || !fs.existsSync(backup)) { + return { restored: false, error: 'Server binary backup path failed validation' }; + } + + const staging = `${target}.rollback.${process.pid}.${Date.now()}`; + try { + fs.copyFileSync(backup, staging); + if (IS_WINDOWS) { + fs.copyFileSync(staging, target); + fs.unlinkSync(staging); + } else { + fs.renameSync(staging, target); + } + return { restored: true, targetPath: target }; + } catch (err) { + try { if (fs.existsSync(staging)) fs.unlinkSync(staging); } catch (_e) { /* best effort */ } + return { restored: false, error: err.message || String(err), targetPath: target }; + } +} + /** - * Restore console files from a pre-update backup and revert the SHA. + * Restore files from a pre-update backup and revert the SHA. + * + * Current manifests prefix entries with `console/`, `server/` or `scripts/` + * so a restore can recover more than the console tree. Older manifests used + * unprefixed console paths and remain supported for backwards compatibility. */ function restoreFromBackup(backupName) { if (!isValidBackupName(backupName)) throw new Error('Invalid backup name'); @@ -3128,12 +3252,36 @@ function restoreFromBackup(backupName) { const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); let restored = 0; - for (const filePath of (manifest.files || [])) { - if (!isValidManifestRelativePath(filePath)) { - throw new Error(`Invalid path in backup manifest: ${filePath}`); + let removed = 0; + const resolveManifestTarget = (backupFilePath) => { + let filePath = backupFilePath; + let targetRoot = ROOT_DIR; + if (backupFilePath.startsWith('console/')) { + filePath = backupFilePath.slice('console/'.length); + } else if (backupFilePath.startsWith('server/')) { + filePath = backupFilePath.slice('server/'.length); + targetRoot = resolveServerSourceRootForUpdate(); + } else if (backupFilePath.startsWith('scripts/')) { + filePath = backupFilePath.slice('scripts/'.length); + targetRoot = PROJECT_ROOT; } - const src = resolvePathUnderRoot(backupPath, filePath); - const dest = resolvePathUnderRoot(ROOT_DIR, filePath); + if (!isValidManifestRelativePath(filePath)) { + throw new Error(`Invalid target path in backup manifest: ${filePath}`); + } + return { + backupFilePath, + filePath, + targetRoot, + }; + }; + + for (const backupFilePath of (manifest.files || [])) { + if (!isValidManifestRelativePath(backupFilePath)) { + throw new Error(`Invalid path in backup manifest: ${backupFilePath}`); + } + const target = resolveManifestTarget(backupFilePath); + const src = resolvePathUnderRoot(backupPath, backupFilePath); + const dest = resolvePathUnderRoot(target.targetRoot, target.filePath); if (fs.existsSync(src)) { fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.copyFileSync(src, dest); @@ -3141,10 +3289,46 @@ function restoreFromBackup(backupName) { } } + for (const backupFilePath of (manifest.removeOnRestore || [])) { + if (!isValidManifestRelativePath(backupFilePath)) { + throw new Error(`Invalid remove path in backup manifest: ${backupFilePath}`); + } + const target = resolveManifestTarget(backupFilePath); + const dest = resolvePathUnderRoot(target.targetRoot, target.filePath); + if (fs.existsSync(dest)) { + fs.rmSync(dest, { force: true }); + removed++; + } + } + + // Mesh agent certificates live beside the server data, not in the + // console root. Older backups placed this file at the backup root; accept + // both formats but always restore to the configured runtime directory. + const rustdeskDir = config.rustdeskDir || config.keysPath; + if (rustdeskDir) { + const meshSources = [ + resolveChildPath(backupPath, 'special/mesh_agent_server.pem'), + resolveChildPath(backupPath, 'mesh_agent_server.pem'), + ]; + const meshSource = meshSources.find((candidate) => fs.existsSync(candidate)); + if (meshSource) { + const meshTarget = path.join(rustdeskDir, 'mesh_agent_server.pem'); + fs.mkdirSync(path.dirname(meshTarget), { recursive: true }); + fs.copyFileSync(meshSource, meshTarget); + restored++; + } + } + // Revert SHA to the pre-update value if (manifest.sha) saveLocalSHA(manifest.sha); - return { restored, version: manifest.version, sha: manifest.sha, totalFiles: (manifest.files || []).length }; + return { + restored, + removed, + version: manifest.version, + sha: manifest.sha, + totalFiles: (manifest.files || []).length, + }; } /** @@ -3257,6 +3441,39 @@ async function rebuildServerBinary(opts = {}) { * Pre-install checks for panel update (issue #158). * @returns {Promise<{ ready: boolean, issues: string[], warnings: string[], go: object, prebuiltAvailable: boolean, canBuildServer: boolean }>} */ +function checkUpdateDiskSpace(targetPath = ROOT_DIR) { + const minimumFreeBytes = Math.max( + 64 * 1024 * 1024, + (Number.parseInt(process.env.UPDATE_MIN_FREE_MB, 10) || 512) * 1024 * 1024 + ); + const result = { + availableBytes: null, + minimumFreeBytes, + path: targetPath, + supported: typeof fs.statfsSync === 'function', + sufficient: null, + }; + + if (!result.supported) return result; + + try { + const stats = fs.statfsSync(targetPath); + result.availableBytes = Number(stats.bavail) * Number(stats.bsize); + if (Number.isFinite(result.availableBytes)) { + result.sufficient = result.availableBytes >= minimumFreeBytes; + } else { + // Some Node/platform combinations expose statfsSync but do not + // return usable block statistics. Treat that as unsupported + // rather than incorrectly blocking every Windows update. + result.supported = false; + result.availableBytes = null; + } + } catch (_e) { + result.supported = false; + } + return result; +} + async function runUpdatePreflight(opts = {}) { const issues = []; const warnings = []; @@ -3295,6 +3512,17 @@ async function runUpdatePreflight(opts = {}) { issues.push(`Console data directory is not writable: ${config.dataDir}`); } + const disk = checkUpdateDiskSpace(config.dataDir); + if (disk.sufficient === false) { + issues.push( + `Insufficient free disk space under ${disk.path}: ` + + `${Math.floor(disk.availableBytes / 1024 / 1024)} MiB available, ` + + `${Math.floor(disk.minimumFreeBytes / 1024 / 1024)} MiB required` + ); + } else if (!disk.supported) { + warnings.push('Free disk-space check is unavailable on this platform'); + } + try { const { ensureConsoleNpmDirs } = require('../lib/consoleNpmInstall'); ensureConsoleNpmDirs(config.dataDir); @@ -3354,7 +3582,8 @@ async function runUpdatePreflight(opts = {}) { warnings, go: goInfo, prebuiltAvailable, - canBuildServer + canBuildServer, + disk }; } @@ -3377,6 +3606,7 @@ module.exports = { deleteBackup, pruneBackups, restoreFromBackup, + restoreServerBinaryBackup, getLocalVersion, getLocalSHA, saveLocalSHA, @@ -3420,6 +3650,7 @@ module.exports = { isUpdatePermissionError, readLastUpdateResult: () => require('../lib/updateResultStore').readLastUpdateResult(config.dataDir), ensureConsoleSource, + checkUpdateDiskSpace, }; bootstrapDockerImageDeployment(); diff --git a/web-nodejs/tests/installerProtocolCheck.test.js b/web-nodejs/tests/installerProtocolCheck.test.js new file mode 100644 index 00000000..66ae8532 --- /dev/null +++ b/web-nodejs/tests/installerProtocolCheck.test.js @@ -0,0 +1,47 @@ +'use strict'; + +const http = require('http'); +const { + checkEndpoint, + checkPort, + checkCertificateHostname, +} = require('../../scripts/installer-protocol-check'); + +describe('installer protocol check', () => { + let server; + let port; + + beforeAll(async () => { + server = http.createServer((request, response) => { + if (request.url === '/redirect') { + response.writeHead(302, { Location: '/health' }); + response.end(); + return; + } + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end('{"status":"ok"}'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + port = server.address().port; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(resolve)); + }); + + test('accepts health responses and reports redirects', async () => { + await expect(checkEndpoint(`http://127.0.0.1:${port}/health`)) + .resolves.toMatchObject({ ok: true, statusCode: 200 }); + await expect(checkEndpoint(`http://127.0.0.1:${port}/redirect`)) + .resolves.toMatchObject({ ok: true, statusCode: 302, redirect: '/health' }); + }); + + test('checks TCP listeners and certificate SAN matching', async () => { + await expect(checkPort(`127.0.0.1:${port}`)) + .resolves.toMatchObject({ ok: true }); + expect(checkCertificateHostname({ subjectaltname: 'DNS:panel.example.test' }, 'panel.example.test')) + .toBe(true); + expect(checkCertificateHostname({ subjectaltname: 'DNS:other.example.test' }, 'panel.example.test')) + .toBe(false); + }); +}); diff --git a/web-nodejs/tests/linuxServerBinaryDeploy.test.js b/web-nodejs/tests/linuxServerBinaryDeploy.test.js index 9ba61d49..ab41188e 100644 --- a/web-nodejs/tests/linuxServerBinaryDeploy.test.js +++ b/web-nodejs/tests/linuxServerBinaryDeploy.test.js @@ -10,7 +10,9 @@ const { resolveDeployScriptPath, } = require('../lib/linuxServerBinaryDeploy'); -describe('linuxServerBinaryDeploy', () => { +const describeLinux = process.platform === 'linux' ? describe : describe.skip; + +describeLinux('linuxServerBinaryDeploy', () => { let tmpRoot; let consoleRoot; let serverRoot; diff --git a/web-nodejs/tests/linuxSystemdUnitPrivileged.test.js b/web-nodejs/tests/linuxSystemdUnitPrivileged.test.js index 6f677c0c..c1132627 100644 --- a/web-nodejs/tests/linuxSystemdUnitPrivileged.test.js +++ b/web-nodejs/tests/linuxSystemdUnitPrivileged.test.js @@ -7,7 +7,9 @@ const { privilegedSystemdUnitHint, } = require('../lib/linuxSystemdUnitPrivileged'); -describe('linuxSystemdUnitPrivileged', () => { +const describeLinux = process.platform === 'linux' ? describe : describe.skip; + +describeLinux('linuxSystemdUnitPrivileged', () => { test('allows BetterDesk systemd unit paths only', () => { expect(isAllowedSystemdUnitPath('/etc/systemd/system/betterdesk-server.service')).toBe(true); expect(isAllowedSystemdUnitPath('/etc/systemd/system/betterdesk-console.service')).toBe(true); diff --git a/web-nodejs/tests/updateService.channel.test.js b/web-nodejs/tests/updateService.channel.test.js index 9e304d67..580f8444 100644 --- a/web-nodejs/tests/updateService.channel.test.js +++ b/web-nodejs/tests/updateService.channel.test.js @@ -80,4 +80,41 @@ describe('updateService update channel', () => { expect(updateService.UPDATE_CHANNELS.stable.branch).toBe('main'); expect(updateService.UPDATE_CHANNELS.development.branch).toBe('dev'); }); + + test('reports insufficient disk space before an update', () => { + const updateService = loadUpdateService({ dataDir }); + if (typeof fs.statfsSync !== 'function') { + expect(updateService.checkUpdateDiskSpace(dataDir).supported).toBe(false); + return; + } + + const statfs = jest.spyOn(fs, 'statfsSync').mockReturnValue({ + bavail: 1, + bsize: 4096, + }); + try { + const result = updateService.checkUpdateDiskSpace(dataDir); + expect(result.supported).toBe(true); + expect(result.sufficient).toBe(false); + expect(result.availableBytes).toBe(4096); + } finally { + statfs.mockRestore(); + } + }); + + test('does not block updates when filesystem statistics are unusable', () => { + const updateService = loadUpdateService({ dataDir }); + if (typeof fs.statfsSync !== 'function') return; + + const statfs = jest.spyOn(fs, 'statfsSync').mockReturnValue({}); + try { + expect(updateService.checkUpdateDiskSpace(dataDir)).toMatchObject({ + supported: false, + sufficient: null, + availableBytes: null, + }); + } finally { + statfs.mockRestore(); + } + }); }); diff --git a/web-nodejs/tests/updateService.consoleSync.test.js b/web-nodejs/tests/updateService.consoleSync.test.js index e60874a5..01cc4c04 100644 --- a/web-nodejs/tests/updateService.consoleSync.test.js +++ b/web-nodejs/tests/updateService.consoleSync.test.js @@ -1,5 +1,8 @@ 'use strict'; +const fs = require('fs'); +const os = require('os'); +const path = require('path'); const { createConsoleDeployGraph } = require('../lib/consoleDeployGraph'); const { GITHUB_COMPARE_FILE_LIMIT, @@ -7,6 +10,7 @@ const { isRetryableDownloadStatus, getDownloadRetryDelayMs, ensureGoServerSignalRelayPorts, + restoreServerBinaryBackup, } = require('../services/updateService'); describe('updateService console sync helpers', () => { @@ -79,4 +83,28 @@ describe('updateService console sync helpers', () => { const again = ensureGoServerSignalRelayPorts(patched.text); expect(again.changed).toBe(false); }); + + test('restores a validated server binary backup atomically', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'bd-binary-rollback-')); + const target = path.join(root, 'betterdesk-server.exe'); + const backup = `${target}.bak.test`; + try { + fs.writeFileSync(target, 'new'); + fs.writeFileSync(backup, 'old'); + expect(restoreServerBinaryBackup(backup, target)).toMatchObject({ restored: true }); + expect(fs.readFileSync(target, 'utf8')).toBe('old'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test('rejects a binary backup outside the target directory', () => { + expect(restoreServerBinaryBackup( + path.join(os.tmpdir(), 'betterdesk-server.exe.bak.test'), + path.join(os.tmpdir(), 'other', 'betterdesk-server.exe') + )).toMatchObject({ + restored: false, + error: 'Server binary backup path failed validation', + }); + }); }); diff --git a/web-nodejs/tests/writeInstallerEnvSubst.test.js b/web-nodejs/tests/writeInstallerEnvSubst.test.js new file mode 100644 index 00000000..27092ba8 --- /dev/null +++ b/web-nodejs/tests/writeInstallerEnvSubst.test.js @@ -0,0 +1,36 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +describe('write-installer-env-subst', () => { + test('writes special characters without shell expansion or truncation', () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'bd-installer-subst-')); + const output = path.join(tmpRoot, 'subst.json'); + const env = { + ...process.env, + BD_SUBST_DEFAULT_ADMIN_PASSWORD: 'p@$$ word&"quoted"=value', + BD_SUBST_DATABASE_URL: 'postgres://user:p%40ss@db:5432/betterdesk?sslmode=disable', + BD_SUBST_SSL_KEY_PATH: 'C:\\BetterDesk\\ssl\\private key.pem', + }; + + try { + execFileSync(process.execPath, [ + path.join(__dirname, '..', 'scripts', 'write-installer-env-subst.js'), + output, + ], { env, stdio: 'pipe' }); + + const parsed = JSON.parse(fs.readFileSync(output, 'utf8')); + expect(parsed.DEFAULT_ADMIN_PASSWORD).toBe(env.BD_SUBST_DEFAULT_ADMIN_PASSWORD); + expect(parsed.DATABASE_URL).toBe(env.BD_SUBST_DATABASE_URL); + expect(parsed.SSL_KEY_PATH).toBe(env.BD_SUBST_SSL_KEY_PATH); + if (process.platform !== 'win32') { + expect(fs.statSync(output).mode & 0o777).toBe(0o600); + } + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + }); +});