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 }); + } + }); +});