mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 01:27:11 +00:00
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:
@@ -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
|
||||
@@ -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
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user