mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-10-25 22:10:49 +00:00
791 lines
32 KiB
Bash
Executable File
791 lines
32 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# =============================================================================
|
|
# BetterDesk — one-line installer (Linux)
|
|
#
|
|
# Docker (default — official all-in-one GHCR image, fully automated):
|
|
# curl -fsSL https://raw.githubusercontent.com/UNITRONIX/BetterDesk/main/install.sh | sudo bash
|
|
#
|
|
# Docker legacy (two-container split images):
|
|
# curl -fsSL .../install.sh | sudo bash -s -- --split
|
|
#
|
|
# Native stable (git clone + betterdesk.sh --auto):
|
|
# curl -fsSL https://raw.githubusercontent.com/UNITRONIX/BetterDesk/main/install.sh | sudo bash -s -- --native
|
|
#
|
|
# Native Development channel (this file on the `dev` branch defaults to --branch dev):
|
|
# curl -fsSL https://raw.githubusercontent.com/UNITRONIX/BetterDesk/dev/install.sh | sudo bash -s -- --native
|
|
#
|
|
# Options (pass after "bash -s --"):
|
|
# --docker | --native Installation mode (default: docker)
|
|
# --split Legacy two-container layout (server + console images)
|
|
# --install-dir PATH Install directory (default: /opt/betterdesk)
|
|
# --version TAG Docker image tag / release baseline (default: 3.5.176)
|
|
# --branch BRANCH Git branch for native install (default: this installer channel)
|
|
# --relay-mode auto|local|public Relay auto-detection strategy
|
|
# --relay-servers IP[:port] Fixed relay address (overrides --relay-mode)
|
|
# --admin-password PASS Set admin password (Docker: ADMIN_PASSWORD env)
|
|
# --skip-docker-install Do not install Docker when missing
|
|
# --skip-firewall Skip UFW/firewalld/iptables configuration
|
|
# --rescue Safe repair: permissions, restart, health checks
|
|
# --diagnose Read-only diagnostics for Docker deployments
|
|
# --repair-permissions Only repair Docker data/volume permissions
|
|
# --check-permissions Check update/config/restart capabilities
|
|
# --set-config KEY=VALUE Apply an allowlisted runtime setting
|
|
# --purge With --uninstall: also remove Docker volumes
|
|
# --uninstall Remove BetterDesk Docker installation
|
|
# --help Show usage
|
|
#
|
|
# Environment overrides (same names as flags where applicable):
|
|
# BETTERDESK_REPO, BETTERDESK_BRANCH, BETTERDESK_VERSION, INSTALL_DIR,
|
|
# RELAY_MODE, RELAY_SERVERS, ADMIN_PASSWORD, BETTERDESK_RAW_BASE
|
|
# =============================================================================
|
|
|
|
set -euo pipefail
|
|
|
|
VERSION="1.0.0"
|
|
BETTERDESK_REPO="${BETTERDESK_REPO:-UNITRONIX/BetterDesk}"
|
|
# Must match the GitHub branch that serves this install.sh URL
|
|
# (.../dev/install.sh → dev, .../main/install.sh → main). Override with
|
|
# --branch / BETTERDESK_BRANCH. Flip to "main" when releasing this file on main.
|
|
BETTERDESK_INSTALLER_CHANNEL="dev"
|
|
BETTERDESK_BRANCH="${BETTERDESK_BRANCH:-$BETTERDESK_INSTALLER_CHANNEL}"
|
|
BETTERDESK_VERSION="${BETTERDESK_VERSION:-3.5.176}"
|
|
BETTERDESK_RAW_BASE="${BETTERDESK_RAW_BASE:-https://raw.githubusercontent.com/${BETTERDESK_REPO}/${BETTERDESK_BRANCH}}"
|
|
INSTALL_DIR="${INSTALL_DIR:-/opt/betterdesk}"
|
|
INSTALL_MODE="docker"
|
|
DOCKER_LAYOUT="single"
|
|
RELAY_MODE="${RELAY_MODE:-auto}"
|
|
RELAY_SERVERS="${RELAY_SERVERS:-}"
|
|
ADMIN_PASSWORD="${ADMIN_PASSWORD:-}"
|
|
SKIP_DOCKER_INSTALL=false
|
|
SKIP_FIREWALL=false
|
|
DO_UNINSTALL=false
|
|
DO_PURGE=false
|
|
DO_RESCUE=false
|
|
RESCUE_ACTION="rescue"
|
|
CHECK_CAPABILITIES=false
|
|
MANAGED_CONFIG_CHANGES=()
|
|
|
|
# Docker quick-start ports (single: 21121 API; split adds 21114)
|
|
DOCKER_PORTS_SINGLE="21115 21116 21117 21118 21119 21121 5000"
|
|
DOCKER_PORTS_SPLIT="21114 21115 21116 21117 21118 21119 5000"
|
|
|
|
# ── Output helpers ────────────────────────────────────────────────────────────
|
|
|
|
if [ -t 1 ]; then
|
|
C_RESET='\033[0m'
|
|
C_BOLD='\033[1m'
|
|
C_DIM='\033[2m'
|
|
C_RED='\033[0;31m'
|
|
C_GREEN='\033[0;32m'
|
|
C_YELLOW='\033[1;33m'
|
|
C_CYAN='\033[0;36m'
|
|
C_WHITE='\033[1;37m'
|
|
else
|
|
C_RESET='' C_BOLD='' C_DIM='' C_RED='' C_GREEN='' C_YELLOW='' C_CYAN='' C_WHITE=''
|
|
fi
|
|
|
|
log() { echo -e "${C_CYAN}[install]${C_RESET} $*"; }
|
|
ok() { echo -e "${C_GREEN}✓${C_RESET} $*"; }
|
|
warn() { echo -e "${C_YELLOW}!${C_RESET} $*" >&2; }
|
|
die() { echo -e "${C_RED}✗${C_RESET} $*" >&2; exit 1; }
|
|
|
|
usage() {
|
|
sed -n '4,33p' "$0" | sed 's/^# \?//'
|
|
exit 0
|
|
}
|
|
|
|
# ── Argument parsing ──────────────────────────────────────────────────────────
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--docker) INSTALL_MODE="docker"; shift ;;
|
|
--split) DOCKER_LAYOUT="split"; shift ;;
|
|
--native) INSTALL_MODE="native"; shift ;;
|
|
--install-dir) INSTALL_DIR="$2"; shift 2 ;;
|
|
--version) BETTERDESK_VERSION="$2"; shift 2 ;;
|
|
--branch) BETTERDESK_BRANCH="$2"; BETTERDESK_RAW_BASE="https://raw.githubusercontent.com/${BETTERDESK_REPO}/${BETTERDESK_BRANCH}"; shift 2 ;;
|
|
--relay-mode) RELAY_MODE="$2"; shift 2 ;;
|
|
--relay-servers) RELAY_SERVERS="$2"; shift 2 ;;
|
|
--admin-password) ADMIN_PASSWORD="$2"; shift 2 ;;
|
|
--skip-docker-install) SKIP_DOCKER_INSTALL=true; shift ;;
|
|
--skip-firewall) SKIP_FIREWALL=true; shift ;;
|
|
--rescue) DO_RESCUE=true; RESCUE_ACTION="rescue"; shift ;;
|
|
--diagnose|--diagnostics) DO_RESCUE=true; RESCUE_ACTION="diagnose"; shift ;;
|
|
--repair-permissions) DO_RESCUE=true; RESCUE_ACTION="repair-permissions"; shift ;;
|
|
--check-permissions|--check-capabilities) CHECK_CAPABILITIES=true; shift ;;
|
|
--set-config)
|
|
[ $# -ge 2 ] && [[ "$2" == *=* ]] || die "--set-config requires KEY=VALUE"
|
|
MANAGED_CONFIG_CHANGES+=("$2")
|
|
shift 2
|
|
;;
|
|
--purge) DO_PURGE=true; shift ;;
|
|
--uninstall) DO_UNINSTALL=true; shift ;;
|
|
-h|--help) usage ;;
|
|
*) die "Unknown option: $1 (use --help)" ;;
|
|
esac
|
|
done
|
|
|
|
# ── Preflight ─────────────────────────────────────────────────────────────────
|
|
|
|
require_root() {
|
|
if [ "$(id -u)" -ne 0 ]; then
|
|
die "This installer must run as root. Re-run with: sudo bash"
|
|
fi
|
|
}
|
|
|
|
require_command() {
|
|
command -v "$1" >/dev/null 2>&1 || die "Required command not found: $1"
|
|
}
|
|
|
|
get_public_ip() {
|
|
local ip
|
|
ip=$(curl -4 -fsS --max-time 5 https://checkip.amazonaws.com 2>/dev/null | tr -d '[:space:]') && [ -n "$ip" ] && echo "$ip" && return
|
|
ip=$(curl -4 -fsS --max-time 5 https://api.ipify.org 2>/dev/null | tr -d '[:space:]') && [ -n "$ip" ] && echo "$ip" && return
|
|
ip=$(curl -4 -fsS --max-time 5 https://ifconfig.me/ip 2>/dev/null | tr -d '[:space:]') && [ -n "$ip" ] && echo "$ip" && return
|
|
echo "127.0.0.1"
|
|
}
|
|
|
|
get_local_ip() {
|
|
local ip
|
|
ip=$(ip -4 route get 1.1.1.1 2>/dev/null | grep -oP 'src \K[0-9.]+' | head -1)
|
|
[ -n "$ip" ] && echo "$ip" && return
|
|
ip=$(ip -4 addr show scope global 2>/dev/null | grep -oP 'inet \K[0-9.]+' | head -1)
|
|
[ -n "$ip" ] && echo "$ip" && return
|
|
ip=$(hostname -I 2>/dev/null | awk '{print $1}')
|
|
[ -n "$ip" ] && echo "$ip" && return
|
|
echo "127.0.0.1"
|
|
}
|
|
|
|
is_private_ip() {
|
|
case "$1" in
|
|
127.*|10.*|192.168.*) return 0 ;;
|
|
172.1[6-9].*|172.2[0-9].*|172.3[0-1].*) return 0 ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
resolve_relay_address() {
|
|
if [ -n "$RELAY_SERVERS" ]; then
|
|
warn "Using fixed relay address: $RELAY_SERVERS"
|
|
echo "$RELAY_SERVERS"
|
|
return
|
|
fi
|
|
|
|
local ip
|
|
case "${RELAY_MODE}" in
|
|
local|lan)
|
|
ip=$(get_local_ip)
|
|
log "Relay mode 'local': using LAN IP $ip"
|
|
;;
|
|
public|wan)
|
|
ip=$(get_public_ip)
|
|
log "Relay mode 'public': using public IP $ip"
|
|
;;
|
|
auto|*)
|
|
ip=$(get_public_ip)
|
|
if is_private_ip "$ip"; then
|
|
warn "Auto-detected private/loopback IP: $ip"
|
|
warn "Internet clients may fail relay connections. Set --relay-servers YOUR.PUBLIC.IP or use --relay-mode local for LAN-only."
|
|
fi
|
|
;;
|
|
esac
|
|
|
|
# Include relay port when only an IP was detected.
|
|
case "$ip" in
|
|
*:*) echo "$ip" ;;
|
|
*) echo "${ip}:21117" ;;
|
|
esac
|
|
}
|
|
|
|
fetch_url_to_file() {
|
|
local url="$1"
|
|
local dest="$2"
|
|
local min_bytes="${3:-512}"
|
|
local max_bytes="${4:-1048576}"
|
|
|
|
if ! curl -fsSL --max-time 120 --proto '=https' --tlsv1.2 -o "$dest" "$url"; then
|
|
die "Failed to download: $url"
|
|
fi
|
|
|
|
local size
|
|
size=$(stat -c%s "$dest" 2>/dev/null || wc -c <"$dest")
|
|
if [ "${size:-0}" -lt "$min_bytes" ] || [ "${size:-0}" -gt "$max_bytes" ]; then
|
|
die "Downloaded file has unexpected size (${size} bytes): $url"
|
|
fi
|
|
}
|
|
|
|
validate_compose_quick() {
|
|
local file="$1"
|
|
local layout="${2:-single}"
|
|
grep -q 'services:' "$file" || die "Invalid compose file (missing services:)"
|
|
if [ "$layout" = "split" ]; then
|
|
grep -q 'ghcr.io/unitronix/betterdesk-server' "$file" || die "Invalid compose file (unexpected content)"
|
|
grep -q 'ghcr.io/unitronix/betterdesk-console' "$file" || die "Invalid compose file (unexpected content)"
|
|
else
|
|
grep -q 'ghcr.io/unitronix/betterdesk:' "$file" || die "Invalid compose file (unexpected content)"
|
|
grep -q 'BETTERDESK_DOCKER_LAYOUT=single' "$file" || die "Invalid compose file (missing single layout marker)"
|
|
fi
|
|
}
|
|
|
|
# ── Docker helpers ────────────────────────────────────────────────────────────
|
|
|
|
check_docker() {
|
|
command -v docker >/dev/null 2>&1 || return 1
|
|
docker info >/dev/null 2>&1 || return 2
|
|
return 0
|
|
}
|
|
|
|
check_docker_compose() {
|
|
if docker compose version >/dev/null 2>&1; then
|
|
COMPOSE_CMD=(docker compose)
|
|
return 0
|
|
fi
|
|
if command -v docker-compose >/dev/null 2>&1; then
|
|
COMPOSE_CMD=(docker-compose)
|
|
return 0
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
install_docker_engine() {
|
|
log "Installing Docker..."
|
|
require_command curl
|
|
|
|
if command -v apt-get >/dev/null 2>&1; then
|
|
apt-get update -qq
|
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq ca-certificates curl
|
|
install -m 0755 -d /etc/apt/keyrings
|
|
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc 2>/dev/null \
|
|
|| curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
|
|
chmod a+r /etc/apt/keyrings/docker.asc
|
|
|
|
local distro codename
|
|
if [ -f /etc/os-release ]; then
|
|
# shellcheck disable=SC1091
|
|
. /etc/os-release
|
|
distro="${ID:-ubuntu}"
|
|
codename="${VERSION_CODENAME:-$(lsb_release -cs 2>/dev/null || echo bookworm)}"
|
|
else
|
|
distro="ubuntu"
|
|
codename="jammy"
|
|
fi
|
|
|
|
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/${distro} ${codename} stable" \
|
|
> /etc/apt/sources.list.d/docker.list
|
|
apt-get update -qq
|
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
|
elif command -v dnf >/dev/null 2>&1; then
|
|
dnf install -y -q docker docker-compose-plugin
|
|
elif command -v yum >/dev/null 2>&1; then
|
|
yum install -y -q docker docker-compose-plugin
|
|
else
|
|
die "Unsupported package manager. Install Docker manually: https://docs.docker.com/engine/install/"
|
|
fi
|
|
|
|
systemctl enable docker >/dev/null 2>&1 || true
|
|
systemctl start docker
|
|
ok "Docker installed"
|
|
}
|
|
|
|
configure_firewall() {
|
|
[ "$SKIP_FIREWALL" = true ] && { log "Skipping firewall configuration"; return 0; }
|
|
|
|
local port created=0
|
|
local docker_ports="$DOCKER_PORTS_SINGLE"
|
|
if [ "$DOCKER_LAYOUT" = "split" ]; then
|
|
docker_ports="$DOCKER_PORTS_SPLIT"
|
|
fi
|
|
|
|
log "Configuring firewall (if active)..."
|
|
|
|
if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -qi "active"; then
|
|
for port in $docker_ports; do
|
|
if [ "$port" = "21116" ]; then
|
|
ufw allow 21116/tcp comment "BetterDesk signal TCP" >/dev/null 2>&1 && created=$((created + 1)) || true
|
|
ufw allow 21116/udp comment "BetterDesk signal UDP" >/dev/null 2>&1 && created=$((created + 1)) || true
|
|
elif ! ufw status 2>/dev/null | grep -qE "^${port}[/ ]"; then
|
|
ufw allow "${port}/tcp" comment "BetterDesk port ${port}" >/dev/null 2>&1 && created=$((created + 1)) || true
|
|
fi
|
|
done
|
|
ufw reload >/dev/null 2>&1 || true
|
|
elif command -v firewall-cmd >/dev/null 2>&1 && systemctl is-active --quiet firewalld 2>/dev/null; then
|
|
for port in $docker_ports; do
|
|
if [ "$port" = "21116" ]; then
|
|
firewall-cmd --permanent --add-port=21116/tcp >/dev/null 2>&1 && created=$((created + 1)) || true
|
|
firewall-cmd --permanent --add-port=21116/udp >/dev/null 2>&1 && created=$((created + 1)) || true
|
|
else
|
|
firewall-cmd --permanent --add-port="${port}/tcp" >/dev/null 2>&1 && created=$((created + 1)) || true
|
|
fi
|
|
done
|
|
firewall-cmd --reload >/dev/null 2>&1 || true
|
|
else
|
|
log "No active UFW/firewalld detected — skipping firewall rules"
|
|
return 0
|
|
fi
|
|
|
|
if [ "$created" -gt 0 ]; then
|
|
ok "Created $created firewall rule(s)"
|
|
else
|
|
ok "Firewall rules already present"
|
|
fi
|
|
}
|
|
|
|
wait_for_http() {
|
|
local url="$1"
|
|
local label="$2"
|
|
local attempts="${3:-60}"
|
|
local i
|
|
|
|
for i in $(seq 1 "$attempts"); do
|
|
if curl -fsS --max-time 3 "$url" >/dev/null 2>&1; then
|
|
ok "$label is ready"
|
|
return 0
|
|
fi
|
|
sleep 2
|
|
done
|
|
warn "$label did not become ready in time ($url)"
|
|
return 1
|
|
}
|
|
|
|
fetch_admin_credentials() {
|
|
# Prefer helper (#195); fall back to cat as betterdesk when image lacks the binary (#299).
|
|
local service="$1"
|
|
local compose_file="$INSTALL_DIR/docker/docker-compose.yml"
|
|
local out=""
|
|
|
|
out=$("${COMPOSE_CMD[@]}" -f "$compose_file" exec -T "$service" \
|
|
betterdesk-show-admin-credentials 2>/dev/null || true)
|
|
if [ -n "$out" ]; then
|
|
printf '%s\n' "$out"
|
|
return 0
|
|
fi
|
|
|
|
out=$("${COMPOSE_CMD[@]}" -f "$compose_file" exec -T -u betterdesk "$service" \
|
|
sh -c 'cat /opt/rustdesk/.admin_credentials 2>/dev/null || cat /app/data/.admin_credentials 2>/dev/null' \
|
|
2>/dev/null || true)
|
|
if [ -n "$out" ]; then
|
|
printf '%s\n' "$out"
|
|
return 0
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
print_docker_summary() {
|
|
local relay="$1"
|
|
local host_ip="${relay%%:*}"
|
|
local creds=""
|
|
local pubkey=""
|
|
local api_port="21121"
|
|
local exec_service="betterdesk"
|
|
|
|
if [ "$DOCKER_LAYOUT" = "split" ]; then
|
|
api_port="21114"
|
|
exec_service="console"
|
|
creds=$(fetch_admin_credentials console || true)
|
|
pubkey=$("${COMPOSE_CMD[@]}" -f "$INSTALL_DIR/docker/docker-compose.yml" exec -T server \
|
|
sh -c 'cat /opt/rustdesk/id_ed25519.pub 2>/dev/null' 2>/dev/null || true)
|
|
else
|
|
creds=$(fetch_admin_credentials betterdesk || true)
|
|
pubkey=$("${COMPOSE_CMD[@]}" -f "$INSTALL_DIR/docker/docker-compose.yml" exec -T betterdesk \
|
|
sh -c 'cat /opt/rustdesk/id_ed25519.pub 2>/dev/null' 2>/dev/null || true)
|
|
fi
|
|
|
|
echo ""
|
|
echo -e "${C_CYAN}╔══════════════════════════════════════════════════════════════╗${C_RESET}"
|
|
echo -e "${C_CYAN}║${C_RESET} ${C_BOLD}BetterDesk Docker installation complete${C_RESET} ${C_CYAN}║${C_RESET}"
|
|
echo -e "${C_CYAN}╠══════════════════════════════════════════════════════════════╣${C_RESET}"
|
|
echo -e "${C_CYAN}║${C_RESET} Layout: ${C_WHITE}${DOCKER_LAYOUT} container(s)${C_RESET}"
|
|
echo -e "${C_CYAN}║${C_RESET} Web panel: ${C_WHITE}http://${host_ip}:5000${C_RESET}"
|
|
echo -e "${C_CYAN}║${C_RESET} API health: ${C_WHITE}http://${host_ip}:${api_port}/api/health${C_RESET}"
|
|
echo -e "${C_CYAN}║${C_RESET} Relay: ${C_WHITE}${relay}${C_RESET}"
|
|
echo -e "${C_CYAN}║${C_RESET} Install dir: ${C_WHITE}${INSTALL_DIR}/docker${C_RESET}"
|
|
echo -e "${C_CYAN}║${C_RESET} Image tag: ${C_WHITE}${BETTERDESK_VERSION}${C_RESET}"
|
|
if [ -n "$pubkey" ]; then
|
|
echo -e "${C_CYAN}║${C_RESET} Public key: ${C_WHITE}${pubkey:0:32}...${C_RESET}"
|
|
fi
|
|
echo -e "${C_CYAN}╠══════════════════════════════════════════════════════════════╣${C_RESET}"
|
|
echo -e "${C_CYAN}║${C_RESET} ${C_YELLOW}RustDesk client settings:${C_RESET}"
|
|
echo -e "${C_CYAN}║${C_RESET} ID server: ${C_WHITE}${host_ip}:21116${C_RESET}"
|
|
echo -e "${C_CYAN}║${C_RESET} Relay server: ${C_WHITE}${relay}${C_RESET}"
|
|
echo -e "${C_CYAN}║${C_RESET} API server: ${C_WHITE}http://${host_ip}:${api_port}${C_RESET}"
|
|
echo -e "${C_CYAN}╠══════════════════════════════════════════════════════════════╣${C_RESET}"
|
|
if [ -n "$creds" ]; then
|
|
echo -e "${C_CYAN}║${C_RESET} ${C_BOLD}Admin credentials:${C_RESET}"
|
|
while IFS= read -r line; do
|
|
[ -n "$line" ] && echo -e "${C_CYAN}║${C_RESET} ${C_WHITE}${line}${C_RESET}"
|
|
done <<< "$creds"
|
|
else
|
|
echo -e "${C_CYAN}║${C_RESET} Admin creds: ${C_DIM}docker compose -f ${INSTALL_DIR}/docker/docker-compose.yml exec ${exec_service} betterdesk-show-admin-credentials${C_RESET}"
|
|
fi
|
|
echo -e "${C_CYAN}╚══════════════════════════════════════════════════════════════╝${C_RESET}"
|
|
echo ""
|
|
log "Manage: cd ${INSTALL_DIR}/docker && ${COMPOSE_CMD[*]} ps|logs|restart"
|
|
}
|
|
|
|
install_docker_mode() {
|
|
local compose_dir="${INSTALL_DIR}/docker"
|
|
local compose_file="${compose_dir}/docker-compose.yml"
|
|
local env_file="${compose_dir}/.env"
|
|
local relay tmp_compose compose_src layout_label api_health_url
|
|
|
|
if [ "$DOCKER_LAYOUT" = "split" ]; then
|
|
compose_src="docker-compose.quick.yml"
|
|
layout_label="split (legacy)"
|
|
api_health_url="http://127.0.0.1:21114/api/health"
|
|
else
|
|
compose_src="docker-compose.quick.single.yml"
|
|
layout_label="single (official)"
|
|
api_health_url="http://127.0.0.1:21121/api/health"
|
|
fi
|
|
|
|
log "BetterDesk Docker installer v${VERSION} (${layout_label}, images: ${BETTERDESK_VERSION})"
|
|
|
|
if ! check_docker; then
|
|
if [ "$SKIP_DOCKER_INSTALL" = true ]; then
|
|
die "Docker is not available. Install Docker or remove --skip-docker-install"
|
|
fi
|
|
install_docker_engine
|
|
fi
|
|
check_docker || die "Docker daemon is not running"
|
|
check_docker_compose || die "Docker Compose plugin is not available"
|
|
|
|
mkdir -p "$compose_dir"
|
|
relay=$(resolve_relay_address)
|
|
|
|
log "Downloading ${compose_src}..."
|
|
tmp_compose=$(mktemp)
|
|
trap 'rm -f "$tmp_compose"' RETURN
|
|
fetch_url_to_file "${BETTERDESK_RAW_BASE}/${compose_src}" "$tmp_compose" 1024 131072
|
|
validate_compose_quick "$tmp_compose" "$DOCKER_LAYOUT"
|
|
install -m 0644 "$tmp_compose" "$compose_file"
|
|
|
|
log "Writing ${env_file}..."
|
|
cat > "$env_file" <<EOF
|
|
# Generated by BetterDesk install.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
BETTERDESK_IMAGE_TAG=${BETTERDESK_VERSION}
|
|
BETTERDESK_DOCKER_LAYOUT=${DOCKER_LAYOUT}
|
|
RELAY_SERVERS=${relay}
|
|
BETTERDESK_CLIENT_REPO=${BETTERDESK_CLIENT_REPO:-UNITRONIX/BetterDesk-Client}
|
|
BETTERDESK_CLIENT_RELEASE_TAG=${BETTERDESK_CLIENT_RELEASE_TAG:-}
|
|
BETTERDESK_GITHUB_TOKEN=${BETTERDESK_GITHUB_TOKEN:-}
|
|
BETTERDESK_CUSTOM_CLIENT_SIGNING_SEED=${BETTERDESK_CUSTOM_CLIENT_SIGNING_SEED:-}
|
|
AGENT_BUILD_WORKER=${AGENT_BUILD_WORKER:-on}
|
|
AGENT_BUILD_POLL_MS=${AGENT_BUILD_POLL_MS:-5000}
|
|
AGENT_BUILD_COOLDOWN_MS=${AGENT_BUILD_COOLDOWN_MS:-1000}
|
|
EOF
|
|
if [ -n "$ADMIN_PASSWORD" ]; then
|
|
printf 'ADMIN_PASSWORD=%s\n' "$ADMIN_PASSWORD" >> "$env_file"
|
|
fi
|
|
chmod 0600 "$env_file"
|
|
|
|
log "Pulling images..."
|
|
(cd "$compose_dir" && "${COMPOSE_CMD[@]}" --env-file .env pull)
|
|
|
|
log "Starting containers..."
|
|
(cd "$compose_dir" && "${COMPOSE_CMD[@]}" --env-file .env up -d)
|
|
|
|
configure_firewall
|
|
|
|
log "Waiting for services..."
|
|
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"
|
|
}
|
|
|
|
rescue_docker_mode() {
|
|
local rescue_dir="${INSTALL_DIR}/rescue"
|
|
local rescue_script="${rescue_dir}/betterdesk-docker.sh"
|
|
local rescue_url="${BETTERDESK_RAW_BASE}/betterdesk-docker.sh"
|
|
local rescue_flag
|
|
|
|
case "$RESCUE_ACTION" in
|
|
diagnose) rescue_flag="--diagnose" ;;
|
|
repair-permissions) rescue_flag="--repair-permissions" ;;
|
|
rescue|*) rescue_flag="--rescue" ;;
|
|
esac
|
|
|
|
log "Preparing BetterDesk Docker rescue toolkit..."
|
|
require_command curl
|
|
if [ "$RESCUE_ACTION" != "diagnose" ]; then
|
|
check_docker || die "Docker is not available or the daemon is not running"
|
|
check_docker_compose || die "Docker Compose plugin is not available"
|
|
fi
|
|
|
|
mkdir -p "$rescue_dir"
|
|
fetch_url_to_file "$rescue_url" "$rescue_script.tmp" 4096 524288
|
|
install -m 0755 "$rescue_script.tmp" "$rescue_script"
|
|
rm -f "$rescue_script.tmp"
|
|
ok "Rescue script ready: $rescue_script"
|
|
|
|
log "Running Docker rescue action: $RESCUE_ACTION"
|
|
DATA_DIR="${DATA_DIR:-}" COMPOSE_FILE="${COMPOSE_FILE:-}" \
|
|
"$rescue_script" "$rescue_flag"
|
|
}
|
|
|
|
uninstall_docker_mode() {
|
|
local compose_dir="${INSTALL_DIR}/docker"
|
|
local compose_file="${compose_dir}/docker-compose.yml"
|
|
|
|
require_root
|
|
check_docker_compose || die "Docker Compose not available"
|
|
|
|
if [ ! -f "$compose_file" ]; then
|
|
die "No Docker installation found at ${compose_dir}"
|
|
fi
|
|
|
|
log "Stopping BetterDesk Docker stack..."
|
|
if [ "$DO_PURGE" = true ]; then
|
|
(cd "$compose_dir" && "${COMPOSE_CMD[@]}" --env-file .env down -v)
|
|
ok "Containers and volumes removed"
|
|
else
|
|
(cd "$compose_dir" && "${COMPOSE_CMD[@]}" --env-file .env down)
|
|
ok "Containers stopped (volumes preserved — re-run with --purge to delete data)"
|
|
fi
|
|
}
|
|
|
|
# ── Native install ────────────────────────────────────────────────────────────
|
|
|
|
native_backup_source() {
|
|
local source_dir="$1"
|
|
local backup_dir="${source_dir}.backup.$(date +%Y%m%d%H%M%S).$$"
|
|
|
|
while [ -e "$backup_dir" ]; do
|
|
backup_dir="${source_dir}.backup.$(date +%Y%m%d%H%M%S).${RANDOM}"
|
|
done
|
|
if ! mv "$source_dir" "$backup_dir"; then
|
|
print_error "Could not preserve existing native source at ${source_dir}"
|
|
return 1
|
|
fi
|
|
printf '%s\n' "$backup_dir"
|
|
}
|
|
|
|
native_clone_source() {
|
|
local repo_dir="$1"
|
|
local backup_dir="${2:-}"
|
|
local staging_parent
|
|
local staging_repo
|
|
|
|
mkdir -p "$INSTALL_DIR"
|
|
staging_parent=$(mktemp -d "${INSTALL_DIR}/.source-clone.XXXXXX")
|
|
staging_repo="${staging_parent}/source"
|
|
|
|
if ! git clone --depth 1 --branch "$BETTERDESK_BRANCH" \
|
|
"https://github.com/${BETTERDESK_REPO}.git" "$staging_repo"; then
|
|
rm -rf "$staging_parent"
|
|
if [ -n "$backup_dir" ] && [ ! -e "$repo_dir" ]; then
|
|
mv "$backup_dir" "$repo_dir" 2>/dev/null || true
|
|
fi
|
|
return 1
|
|
fi
|
|
|
|
# The native update path keeps local Go runtime data beside the source.
|
|
# Carry it forward without replacing anything created by the fresh clone.
|
|
if [ -n "$backup_dir" ] \
|
|
&& [ -d "$backup_dir/betterdesk-server/data" ] \
|
|
&& [ ! -e "$staging_repo/betterdesk-server/data" ]; then
|
|
if ! cp -a "$backup_dir/betterdesk-server/data" "$staging_repo/betterdesk-server/"; then
|
|
rm -rf "$staging_parent"
|
|
if [ -n "$backup_dir" ]; then
|
|
mv "$backup_dir" "$repo_dir" 2>/dev/null || true
|
|
fi
|
|
return 1
|
|
fi
|
|
fi
|
|
|
|
if ! mv "$staging_repo" "$repo_dir"; then
|
|
rm -rf "$staging_parent"
|
|
if [ -n "$backup_dir" ]; then
|
|
mv "$backup_dir" "$repo_dir" 2>/dev/null || true
|
|
fi
|
|
return 1
|
|
fi
|
|
rm -rf "$staging_parent"
|
|
return 0
|
|
}
|
|
|
|
install_native_mode() {
|
|
local repo_dir="${INSTALL_DIR}/source"
|
|
local relay
|
|
local commit_sha
|
|
local source_backup=""
|
|
local checkout_error=""
|
|
|
|
log "BetterDesk native installer v${VERSION}"
|
|
log "Native install branch: ${BETTERDESK_BRANCH} (override with --branch)"
|
|
|
|
require_command git
|
|
relay=$(resolve_relay_address)
|
|
|
|
if [ -d "$repo_dir/.git" ]; then
|
|
log "Updating existing clone in ${repo_dir} to ${BETTERDESK_BRANCH}..."
|
|
git -C "$repo_dir" remote set-url origin "https://github.com/${BETTERDESK_REPO}.git" || true
|
|
git -C "$repo_dir" fetch --depth 1 origin "$BETTERDESK_BRANCH"
|
|
# Shallow clones previously pinned to another branch need a branch switch.
|
|
checkout_error=$(mktemp)
|
|
if git -C "$repo_dir" checkout -B "$BETTERDESK_BRANCH" "FETCH_HEAD" 2>"$checkout_error"; then
|
|
rm -f "$checkout_error"
|
|
else
|
|
warn "Could not switch existing clone to ${BETTERDESK_BRANCH}."
|
|
if [ -n "$(git -C "$repo_dir" status --short 2>/dev/null || true)" ]; then
|
|
warn "The existing clone contains local changes; preserving it before recloning."
|
|
cat "$checkout_error" >&2 || true
|
|
else
|
|
warn "Git checkout failed; preserving the existing clone before recloning."
|
|
cat "$checkout_error" >&2 || true
|
|
fi
|
|
rm -f "$checkout_error"
|
|
if ! source_backup=$(native_backup_source "$repo_dir"); then
|
|
die "Cannot preserve ${repo_dir}; refusing to remove the existing native source"
|
|
fi
|
|
if ! native_clone_source "$repo_dir" "$source_backup"; then
|
|
die "Could not clone ${BETTERDESK_REPO}@${BETTERDESK_BRANCH}; original source preserved at ${source_backup}"
|
|
fi
|
|
warn "Previous native source preserved at ${source_backup}"
|
|
fi
|
|
else
|
|
log "Cloning ${BETTERDESK_REPO} (${BETTERDESK_BRANCH})..."
|
|
if [ -e "$repo_dir" ]; then
|
|
if ! source_backup=$(native_backup_source "$repo_dir"); then
|
|
die "Cannot preserve ${repo_dir}; refusing to remove the existing native source"
|
|
fi
|
|
warn "Existing non-Git source preserved at ${source_backup}"
|
|
fi
|
|
if ! native_clone_source "$repo_dir" "$source_backup"; then
|
|
die "Could not clone ${BETTERDESK_REPO}@${BETTERDESK_BRANCH}; original source preserved at ${source_backup:-none}"
|
|
fi
|
|
if [ -n "$source_backup" ]; then
|
|
warn "Previous native source preserved at ${source_backup}"
|
|
fi
|
|
fi
|
|
|
|
commit_sha=$(git -C "$repo_dir" rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
|
log "Using ${BETTERDESK_REPO}@${BETTERDESK_BRANCH} (${commit_sha})"
|
|
|
|
chmod +x "${repo_dir}/betterdesk.sh"
|
|
|
|
log "Running betterdesk.sh --auto..."
|
|
export RELAY_MODE RELAY_SERVERS="$relay"
|
|
if [ -n "$ADMIN_PASSWORD" ]; then
|
|
export ADMIN_PASSWORD
|
|
fi
|
|
(cd "$repo_dir" && ./betterdesk.sh --auto --relay-servers "$relay")
|
|
|
|
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"
|
|
|
|
if [ "$RESCUE_ACTION" = "repair-permissions" ] && [ -x "$repo_dir/betterdesk.sh" ]; then
|
|
"$repo_dir/betterdesk.sh" --repair-permissions
|
|
return $?
|
|
fi
|
|
warn "Native rescue is handled by betterdesk.sh on the installed host."
|
|
if [ -x "$repo_dir/betterdesk.sh" ]; then
|
|
echo "Run the interactive native repair toolkit with:"
|
|
echo " sudo ${repo_dir}/betterdesk.sh"
|
|
echo "Then choose: Repair Installation or Diagnostics."
|
|
return 0
|
|
fi
|
|
|
|
die "Native checkout not found at ${repo_dir}. Re-run without --rescue to install, or use --docker for Docker rescue."
|
|
}
|
|
|
|
run_native_management_action() {
|
|
local repo_dir="${INSTALL_DIR}/source"
|
|
local native_installer="${repo_dir}/betterdesk.sh"
|
|
[ -x "$native_installer" ] || die "Native BetterDesk manager not found at ${native_installer}"
|
|
local args=()
|
|
[ "$CHECK_CAPABILITIES" = true ] && args+=(--check-permissions)
|
|
[ "$REPAIR_PERMISSIONS" = true ] && args+=(--repair-permissions)
|
|
local change
|
|
for change in "${MANAGED_CONFIG_CHANGES[@]}"; do
|
|
args+=(--set-config "$change")
|
|
done
|
|
"$native_installer" "${args[@]}"
|
|
}
|
|
|
|
run_docker_management_action() {
|
|
local manager="${INSTALL_DIR}/docker/betterdesk-docker.sh"
|
|
[ -x "$manager" ] || die "Docker BetterDesk manager not found at ${manager}"
|
|
local args=()
|
|
[ "$CHECK_CAPABILITIES" = true ] && args+=(--check-permissions)
|
|
[ "$REPAIR_PERMISSIONS" = true ] && args+=(--repair-permissions)
|
|
local change
|
|
for change in "${MANAGED_CONFIG_CHANGES[@]}"; do
|
|
args+=(--set-config "$change")
|
|
done
|
|
"$manager" "${args[@]}"
|
|
}
|
|
|
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
|
|
|
main() {
|
|
echo ""
|
|
echo -e "${C_BOLD}BetterDesk installer v${VERSION}${C_RESET}"
|
|
echo ""
|
|
|
|
if [ "$DO_UNINSTALL" = true ]; then
|
|
case "$INSTALL_MODE" in
|
|
docker) uninstall_docker_mode ;;
|
|
native) uninstall_native_mode ;;
|
|
*) die "Unknown install mode: $INSTALL_MODE" ;;
|
|
esac
|
|
exit 0
|
|
fi
|
|
|
|
require_root
|
|
|
|
if [ "$CHECK_CAPABILITIES" = true ] || [ "${#MANAGED_CONFIG_CHANGES[@]}" -gt 0 ]; then
|
|
case "$INSTALL_MODE" in
|
|
docker) run_docker_management_action ;;
|
|
native) run_native_management_action ;;
|
|
*) die "Unknown install mode: $INSTALL_MODE" ;;
|
|
esac
|
|
exit $?
|
|
fi
|
|
|
|
if [ "$DO_RESCUE" = true ]; then
|
|
case "$INSTALL_MODE" in
|
|
docker) rescue_docker_mode ;;
|
|
native) rescue_native_mode ;;
|
|
*) die "Unknown install mode: $INSTALL_MODE" ;;
|
|
esac
|
|
exit 0
|
|
fi
|
|
|
|
case "$INSTALL_MODE" in
|
|
docker) install_docker_mode ;;
|
|
native) install_native_mode ;;
|
|
*) die "Unknown install mode: $INSTALL_MODE" ;;
|
|
esac
|
|
}
|
|
|
|
# BASH_SOURCE[0] is unset when this script is piped into `bash` (the
|
|
# documented `curl ... | sudo bash` path), so default it to $0 under nounset.
|
|
if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then
|
|
main "$@"
|
|
fi
|