fix(installer): harden cross-platform lifecycle reliability

Harden native, agent and Docker lifecycle paths with tracked update verification, rollback and preflight checks, safe uninstall semantics, and health verification. Add shared protocol tests, installer CI gates, and lifecycle documentation.

Thanks: INSOLVE (Honorary); Marco Jakobs (@jacotec); MyNameisStitch (@MyNameisStitch); Redspin (@playerumpknow)
This commit is contained in:
UNITRONIX
2026-08-15 21:50:22 +02:00
parent 67910cd8e3
commit 29d03492fb
29 changed files with 1652 additions and 260 deletions
+185
View File
@@ -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
+2
View File
@@ -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*'
+10 -1
View File
@@ -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.
---
+80 -32
View File
@@ -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"
+74 -24
View File
@@ -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" <<JSONEOF
{
"server": "${SERVER_URL}",
"auth_method": "api_key",
"api_key": "${API_KEY}",
"device_name": "${DEVICE_NAME}",
"device_type": "os_agent",
"terminal": true,
"file_browser": true,
"clipboard": true,
"screenshot": true,
"file_root": "/",
"heartbeat_sec": 15,
"reconnect_sec": 5,
"max_reconnect": 300,
"log_level": "info",
"data_dir": "${INSTALL_DIR}/data"
config_path = sys.argv[1]
config = {
"server": os.environ["BD_SERVER_URL"],
"auth_method": "api_key",
"api_key": os.environ["BD_API_KEY"],
"device_name": os.environ["BD_DEVICE_NAME"],
"device_type": "os_agent",
"terminal": True,
"file_browser": True,
"clipboard": True,
"screenshot": True,
"file_root": "/",
"heartbeat_sec": 15,
"reconnect_sec": 5,
"max_reconnect": 300,
"log_level": "info",
"data_dir": os.path.join(os.environ["BD_INSTALL_DIR"], "data"),
}
JSONEOF
chmod 600 "$CONFIG_FILE"
with open(config_path, "w", encoding="utf-8") as handle:
json.dump(config, handle, ensure_ascii=False, indent=2)
handle.write("\n")
os.chmod(config_path, 0o600)
PY
chown "$USER_NAME:$USER_NAME" "$CONFIG_FILE"
echo "Config created: $CONFIG_FILE"
else
@@ -161,6 +206,11 @@ EOF
systemctl daemon-reload
systemctl enable "$SERVICE_NAME"
systemctl start "$SERVICE_NAME"
if ! systemctl is-active --quiet "$SERVICE_NAME"; then
echo "ERROR: BetterDesk Agent service failed to start"
systemctl status "$SERVICE_NAME" --no-pager || true
exit 1
fi
echo ""
echo "=== BetterDesk Agent Installed ==="
+34 -18
View File
@@ -1210,8 +1210,28 @@ start_containers() {
elif [ "$SERVER_RUNNING" = true ] && [ "$CONSOLE_RUNNING" = true ]; then
print_success "All containers running"
else
print_warning "Some containers might not be working properly"
print_error "Required BetterDesk containers are not running"
return 1
fi
local api_port="21114"
if [ "$DOCKER_LAYOUT" = "single" ] || [ "$AIO_RUNNING" = true ]; then
api_port="21121"
fi
local health_deadline=60
while [ "$health_deadline" -gt 0 ]; do
if curl -fsS --max-time 3 "http://127.0.0.1:${api_port}/api/health" >/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
+65 -7
View File
@@ -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.
+12 -1
View File
@@ -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)
}
+281 -106
View File
@@ -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
+95 -32
View File
@@ -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
+2 -2
View File
@@ -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)
+32
View File
@@ -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
+1 -1
View File
@@ -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=<current VERSION>, e.g. 3.3.169
# Ensure .env has BETTERDESK_IMAGE_TAG=<current VERSION>, e.g. 3.5.37
docker compose pull && docker compose up -d
# Official single-container service name:
+30
View File
@@ -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 |
+90
View File
@@ -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.
@@ -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://<server>: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.
@@ -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).
+20 -2
View File
@@ -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
+28 -3
View File
@@ -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
+4 -2
View File
@@ -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',
+155
View File
@@ -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,
};
+30 -2
View File
@@ -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));
}
+253 -22
View File
@@ -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();
@@ -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);
});
});
@@ -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;
@@ -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);
@@ -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();
}
});
});
@@ -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',
});
});
});
@@ -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 });
}
});
});