#!/bin/bash #=============================================================================== # # BetterDesk Console Manager v3.5.92 # All-in-One Interactive Tool for Docker # # Features: # - Fresh installation with Docker Compose # - Update containers # - Repair/rebuild containers # - Validate installation # - Backup & restore volumes # - Reset admin password # - Build custom images # - Full diagnostics # - Migrate from existing RustDesk Docker # - PostgreSQL database support # - SQLite to PostgreSQL migration # - CDAP (Custom Device API Protocol) support # # Usage: ./betterdesk-docker.sh # ./betterdesk-docker.sh --rescue # ./betterdesk-docker.sh --diagnose # ./betterdesk-docker.sh --repair-permissions # #=============================================================================== set -e # Version VERSION="3.5.92" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Default paths (can be overridden by environment variables) DATA_DIR="${DATA_DIR:-}" BACKUP_DIR="${BACKUP_DIR:-/opt/betterdesk-backups}" COMPOSE_FILE="${COMPOSE_FILE:-$SCRIPT_DIR/docker-compose.yml}" # Database configuration USE_POSTGRESQL="${USE_POSTGRESQL:-false}" DB_TYPE="${DB_TYPE:-sqlite}" POSTGRESQL_URI="${POSTGRESQL_URI:-}" POSTGRESQL_USER="${POSTGRESQL_USER:-betterdesk}" POSTGRESQL_PASS="${POSTGRESQL_PASS:-}" POSTGRESQL_DB="${POSTGRESQL_DB:-betterdesk}" POSTGRESQL_HOST="${POSTGRESQL_HOST:-postgres}" # Container name as host POSTGRESQL_PORT="${POSTGRESQL_PORT:-5432}" STORE_ADMIN_CREDENTIALS="${STORE_ADMIN_CREDENTIALS:-false}" # Relay server configuration # auto - detect public IP (default, best for internet-facing servers) # local - use the host's LAN IP (best for LAN-only deployments) # public - force public IP detection # RELAY_SERVERS env var always overrides this with a fixed value. RELAY_MODE="${RELAY_MODE:-auto}" RELAY_SERVERS="${RELAY_SERVERS:-}" # Common data directory paths to search COMMON_DATA_PATHS=( "/opt/betterdesk-data" "/var/lib/betterdesk" "/opt/rustdesk-data" "/var/lib/rustdesk" "$HOME/betterdesk-data" ) # Container names SERVER_CONTAINER="betterdesk-server" CONSOLE_CONTAINER="betterdesk-console" AIO_CONTAINER="betterdesk" DOCKER_LAYOUT="${DOCKER_LAYOUT:-single}" # Set by update_docker_from_github; consumed after container rebuild (#192) LAST_UPDATE_REMOTE_SHA="" # Legacy aliases for backwards compatibility in detect functions HBBS_CONTAINER="$SERVER_CONTAINER" HBBR_CONTAINER="$SERVER_CONTAINER" # Colors RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' MAGENTA='\033[0;35m' WHITE='\033[1;37m' NC='\033[0m' BOLD='\033[1m' DIM='\033[2m' # Logging LOG_FILE="/tmp/betterdesk_docker_$(date +%Y%m%d_%H%M%S).log" CLI_ACTION="" NONINTERACTIVE=false #=============================================================================== # SELinux / Volume Helper Functions #=============================================================================== # Create directory with proper permissions for Docker volumes # Handles SELinux context on RHEL-based systems (AlmaLinux, CentOS, Rocky) create_data_directory() { local dir_path="$1" mkdir -p "$dir_path" || { print_error "Failed to create directory: $dir_path" return 1 } # Set proper ownership (root or current user) chmod 755 "$dir_path" # Handle SELinux on RHEL-based systems if command -v getenforce &> /dev/null; then if [ "$(getenforce 2>/dev/null)" = "Enforcing" ]; then # Apply SVirt sandbox context for Docker if command -v chcon &> /dev/null; then chcon -Rt svirt_sandbox_file_t "$dir_path" 2>/dev/null || true log "SELinux: Applied svirt_sandbox_file_t context to $dir_path" fi fi fi return 0 } #=============================================================================== # Helper Functions #=============================================================================== log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE" } print_header() { clear 2>/dev/null || true echo -e "${CYAN}" echo "╔══════════════════════════════════════════════════════════════════╗" echo "║ ║" echo "║ ██████╗ ███████╗████████╗████████╗███████╗██████╗ ║" echo "║ ██╔══██╗██╔════╝╚══██╔══╝╚══██╔══╝██╔════╝██╔══██╗ ║" echo "║ ██████╔╝█████╗ ██║ ██║ █████╗ ██████╔╝ ║" echo "║ ██╔══██╗██╔══╝ ██║ ██║ ██╔══╝ ██╔══██╗ ║" echo "║ ██████╔╝███████╗ ██║ ██║ ███████╗██║ ██║ ║" echo "║ ╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ ║" echo "║ ██████╗ ███████╗███████╗██╗ ██╗ ║" echo "║ ██╔══██╗██╔════╝██╔════╝██║ ██╔╝ ║" echo "║ ██║ ██║█████╗ ███████╗█████╔╝ ║" echo "║ ██║ ██║██╔══╝ ╚════██║██╔═██╗ ║" echo "║ ██████╔╝███████╗███████║██║ ██╗ ║" echo "║ ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═╝ ║" echo "║ ║" echo "║ Console Manager v${VERSION} (Docker) ║" echo "╚══════════════════════════════════════════════════════════════════╝" echo -e "${NC}" } print_success() { echo -e "${GREEN}✓${NC} $1"; log "SUCCESS: $1"; } print_error() { echo -e "${RED}✗${NC} $1"; log "ERROR: $1"; } print_warning() { echo -e "${YELLOW}!${NC} $1"; log "WARNING: $1"; } print_info() { echo -e "${BLUE}ℹ${NC} $1"; log "INFO: $1"; } print_step() { echo -e "${MAGENTA}▶${NC} $1"; log "STEP: $1"; } usage() { sed -n '4,22p' "$0" | sed 's/^# \?//' cat <<'EOF' Non-interactive rescue options: --rescue Safe repair: permissions, restart containers, health checks --diagnose Read-only diagnostics without interactive prompts --repair-permissions Only repair Docker bind/volume permissions and SELinux context --help Show this help EOF } parse_cli_args() { while [ $# -gt 0 ]; do case "$1" in --rescue) CLI_ACTION="rescue" NONINTERACTIVE=true shift ;; --diagnose|--diagnostics) CLI_ACTION="diagnose" NONINTERACTIVE=true shift ;; --repair-permissions) CLI_ACTION="repair-permissions" NONINTERACTIVE=true shift ;; -h|--help) usage exit 0 ;; *) print_error "Unknown option: $1" usage exit 1 ;; esac done } press_enter() { if [ "$NONINTERACTIVE" = true ]; then return 0 fi echo "" echo -e "${CYAN}Press Enter to continue...${NC}" read -r } confirm() { local prompt="${1:-Continue?}" echo -e "${YELLOW}${prompt} [y/N]${NC} " read -r response [[ "$response" =~ ^[TtYy]$ ]] } #=============================================================================== # Interactive TUI (arrow-key navigable menu) — pure bash, no dependencies #=============================================================================== # Result of tui_select() is returned in the global TUI_RESULT. TUI_RESULT="" tui_available() { [ "${BETTERDESK_CLASSIC_MENU:-0}" = "1" ] && return 1 [ -t 0 ] && [ -t 1 ] || return 1 return 0 } _tui_restore() { printf '\033[?25h' 2>/dev/null; stty echo 2>/dev/null; } # tui_select "Title" "Subtitle" item1 item2 ... # Each item may embed a description after a literal $'\t' (tab). # Navigation: Up/Down or k/j to move, Enter/Right to choose, q/Esc/0 to cancel. tui_select() { local title="$1"; shift local subtitle="$1"; shift local items=("$@") local count=${#items[@]} local sel=0 key rest if ! tui_available || [ "$count" -eq 0 ]; then TUI_RESULT="" return 1 fi printf '\033[?25l' trap '_tui_restore' INT TERM clear while true; do # Build the whole frame in a single buffer, then emit it with one write # to avoid renderers (notably the VS Code integrated terminal with GPU # acceleration) dropping individual glyphs on full-screen redraws. local buf="" buf+="\033[H" buf+="${CYAN}${BOLD}+--------------------------------------------------------------+${NC}\033[K\n" buf+="$(printf "${CYAN}${BOLD}|${NC} ${WHITE}${BOLD}%-60s${NC} ${CYAN}${BOLD}|${NC}" "$title")\033[K\n" if [ -n "$subtitle" ]; then buf+="$(printf "${CYAN}${BOLD}|${NC} ${DIM}%-60s${NC} ${CYAN}${BOLD}|${NC}" "$subtitle")\033[K\n" fi buf+="${CYAN}${BOLD}+--------------------------------------------------------------+${NC}\033[K\n" buf+="\033[K\n" local i label desc pad line for i in "${!items[@]}"; do label="${items[$i]%%$'\t'*}" desc="" [[ "${items[$i]}" == *$'\t'* ]] && desc="${items[$i]#*$'\t'}" pad=$(( 32 - ${#label} )) [ "$pad" -lt 1 ] && pad=1 if [ "$i" -eq "$sel" ]; then line="$(printf " ${GREEN}${BOLD}>${NC} ${GREEN}${BOLD}%s${NC}%*s${DIM}%s${NC}" "$label" "$pad" "" "$desc")" else line="$(printf " ${WHITE}%s${NC}%*s${DIM}%s${NC}" "$label" "$pad" "" "$desc")" fi buf+="${line}\033[K\n" done buf+="\033[K\n" buf+=" ${DIM}Up/Down navigate Enter select q/Esc back${NC}\033[K\n" buf+="\033[J" printf '%b' "$buf" IFS= read -rsn1 key 2>/dev/null if [[ "$key" == $'\033' ]]; then read -rsn2 -t 0.05 rest 2>/dev/null key+="$rest" fi case "$key" in $'\033[A'|'k') sel=$(( (sel - 1 + count) % count )) ;; $'\033[B'|'j') sel=$(( (sel + 1) % count )) ;; ''|$'\033[C') TUI_RESULT="$sel"; _tui_restore; trap - INT TERM; return 0 ;; 'q'|'Q'|'0'|$'\033') TUI_RESULT=""; _tui_restore; trap - INT TERM; return 2 ;; [1-9]) local idx=$(( key - 1 )) if [ "$idx" -lt "$count" ]; then TUI_RESULT="$idx"; _tui_restore; trap - INT TERM; return 0 fi ;; esac done } #=============================================================================== # Modern UI helpers shared by every sub-menu #=============================================================================== ui_panel_header() { local title="$1" subtitle="$2" clear 2>/dev/null || true local buf="" buf+="${CYAN}${BOLD}+--------------------------------------------------------------+${NC}\n" buf+="$(printf "${CYAN}${BOLD}|${NC} ${WHITE}${BOLD}%-60s${NC} ${CYAN}${BOLD}|${NC}" "$title")\n" if [ -n "$subtitle" ]; then buf+="$(printf "${CYAN}${BOLD}|${NC} ${DIM}%-60s${NC} ${CYAN}${BOLD}|${NC}" "$subtitle")\n" fi buf+="${CYAN}${BOLD}+--------------------------------------------------------------+${NC}\n" printf '%b' "$buf" echo "" } # menu_choose "Title" "Subtitle" # Caller pre-populates parallel arrays _menu_items (Label\tDescription) and # _menu_returns. The chosen value lands in MENU_CHOICE; on cancel the last # entry's value is returned. Arrow-key TUI when available, styled numeric else. MENU_CHOICE="" menu_choose() { local title="$1" subtitle="$2" MENU_CHOICE="" local last_idx=$(( ${#_menu_returns[@]} - 1 )) [ "$last_idx" -lt 0 ] && last_idx=0 if tui_available; then tui_select "$title" "$subtitle" "${_menu_items[@]}" local rc=$? if [ "$rc" -eq 0 ] && [ -n "$TUI_RESULT" ]; then MENU_CHOICE="${_menu_returns[$TUI_RESULT]}" else MENU_CHOICE="${_menu_returns[$last_idx]}" fi return 0 fi ui_panel_header "$title" "$subtitle" local i label desc for i in "${!_menu_items[@]}"; do label="${_menu_items[$i]%%$'\t'*}" desc="" [[ "${_menu_items[$i]}" == *$'\t'* ]] && desc="${_menu_items[$i]#*$'\t'}" printf " ${GREEN}${BOLD}%2s${NC}) ${WHITE}%-28s${NC} ${DIM}%s${NC}\n" \ "${_menu_returns[$i]}" "$label" "$desc" done echo "" echo -ne " ${CYAN}Select option:${NC} " read -r MENU_CHOICE } get_public_ip() { local ip ip=$(curl -4 -s --max-time 5 ifconfig.me 2>/dev/null) && [ -n "$ip" ] && echo "$ip" && return ip=$(curl -4 -s --max-time 5 icanhazip.com 2>/dev/null) && [ -n "$ip" ] && echo "$ip" && return ip=$(curl -s --max-time 5 ifconfig.me 2>/dev/null) && [ -n "$ip" ] && echo "$ip" && return ip=$(curl -s --max-time 5 icanhazip.com 2>/dev/null) && [ -n "$ip" ] && echo "$ip" && return echo "127.0.0.1" } # Detect the host's primary LAN/private IPv4 address (for LAN-only deployments). 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" } # Resolve the relay server address according to RELAY_MODE / RELAY_SERVERS. # Prints the address to stdout; warnings/info go to stderr. # auto - detect public IP (default) # local - use the host's LAN IP (LAN-only deployments) # public - force public IP detection # RELAY_SERVERS env var always overrides this with a fixed value. resolve_relay_ip() { if [ -n "$RELAY_SERVERS" ]; then echo "Using fixed relay address (RELAY_SERVERS): $RELAY_SERVERS" >&2 echo "$RELAY_SERVERS" return fi local ip case "${RELAY_MODE:-auto}" in local|lan) ip=$(get_local_ip) echo "Relay mode 'local': using LAN IP $ip (LAN-only deployment)" >&2 ;; public|wan) ip=$(get_public_ip) echo "Relay mode 'public': using public IP $ip" >&2 ;; auto|*) ip=$(get_public_ip) if [ "$ip" = "127.0.0.1" ] || [[ "$ip" == 10.* ]] || [[ "$ip" == 192.168.* ]] || [[ "$ip" == 172.1[6-9].* ]] || [[ "$ip" == 172.2[0-9].* ]] || [[ "$ip" == 172.3[0-1].* ]]; then echo "WARNING: Auto-detected private/loopback IP: $ip" >&2 echo "WARNING: Remote (internet) clients will NOT connect via relay with this address." >&2 echo " For internet access set RELAY_SERVERS=YOUR.PUBLIC.IP, or for LAN-only" >&2 echo " deployments set RELAY_MODE=local to silence this warning." >&2 fi ;; esac echo "$ip" } sql_escape_literal() { local value="$1" printf "%s" "${value//\'/\'\'}" } #=============================================================================== # Detection Functions #=============================================================================== check_docker() { if ! command -v docker &> /dev/null; then return 1 fi if ! docker info &> /dev/null; then return 2 fi return 0 } check_docker_compose() { if docker compose version &> /dev/null; then COMPOSE_CMD="docker compose" return 0 elif docker-compose --version &> /dev/null; then COMPOSE_CMD="docker-compose" return 0 fi return 1 } # Auto-detect data directory auto_detect_docker_paths() { local found=false # If DATA_DIR is already set (via env var), validate it if [ -n "$DATA_DIR" ]; then if [ -d "$DATA_DIR" ] && [ -f "$DATA_DIR/db_v2.sqlite3" ]; then print_info "Using configured data path: $DATA_DIR" found=true else print_warning "Configured DATA_DIR ($DATA_DIR) is invalid or empty" DATA_DIR="" fi fi # Auto-detect if not found if [ -z "$DATA_DIR" ]; then for path in "${COMMON_DATA_PATHS[@]}"; do if [ -d "$path" ] && [ -f "$path/db_v2.sqlite3" ]; then DATA_DIR="$path" print_success "Detected data directory: $DATA_DIR" found=true break fi done fi # If still not found, use default for new installations if [ -z "$DATA_DIR" ]; then DATA_DIR="/opt/betterdesk-data" print_info "No data found. Default path: $DATA_DIR" fi # Check docker-compose.yml if [ -n "$COMPOSE_FILE" ] && [ -f "$COMPOSE_FILE" ]; then print_info "Using compose file: $COMPOSE_FILE" else # Try to find docker-compose.yml if [ -f "$SCRIPT_DIR/docker-compose.yml" ]; then COMPOSE_FILE="$SCRIPT_DIR/docker-compose.yml" elif [ -f "./docker-compose.yml" ]; then COMPOSE_FILE="./docker-compose.yml" fi fi return 0 } # Interactive path configuration for Docker configure_docker_paths() { local subtitle="data: ${DATA_DIR:-unset} | backup: ${BACKUP_DIR:-unset}" local _menu_items=( $'Auto-detect data directory\tProbe for an existing deployment' $'Set data directory\tEnter the data path manually' $'Set backup directory\tEnter the backup path manually' $'Set docker-compose.yml path\tPoint at a specific compose file' $'Reset to defaults\tRestore the default paths' $'Back\tReturn to the main menu' ) local _menu_returns=( 1 2 3 4 5 0 ) menu_choose "Docker Path Configuration" "$subtitle" local choice="$MENU_CHOICE" case $choice in 1) DATA_DIR="" auto_detect_docker_paths press_enter configure_docker_paths ;; 2) echo "" echo -n "Enter data directory path (e.g., /opt/betterdesk-data): " read -r new_path if [ -n "$new_path" ]; then if [ -d "$new_path" ]; then DATA_DIR="$new_path" print_success "Data directory set to: $DATA_DIR" else print_warning "Directory does not exist: $new_path" if confirm "Create this directory?"; then mkdir -p "$new_path" DATA_DIR="$new_path" print_success "Created and set data directory: $DATA_DIR" fi fi fi press_enter configure_docker_paths ;; 3) echo "" echo -n "Enter backup directory path: " read -r new_path if [ -n "$new_path" ]; then if [ -d "$new_path" ]; then BACKUP_DIR="$new_path" print_success "Backup directory set to: $BACKUP_DIR" else print_warning "Directory does not exist: $new_path" if confirm "Create this directory?"; then mkdir -p "$new_path" BACKUP_DIR="$new_path" print_success "Created and set backup directory: $BACKUP_DIR" fi fi fi press_enter configure_docker_paths ;; 4) echo "" echo -n "Enter docker-compose.yml path: " read -r new_path if [ -n "$new_path" ]; then if [ -f "$new_path" ]; then COMPOSE_FILE="$new_path" print_success "Compose file set to: $COMPOSE_FILE" else print_error "File does not exist: $new_path" fi fi press_enter configure_docker_paths ;; 5) DATA_DIR="/opt/betterdesk-data" BACKUP_DIR="/opt/betterdesk-backups" COMPOSE_FILE="$SCRIPT_DIR/docker-compose.yml" print_success "Paths reset to defaults" press_enter configure_docker_paths ;; 0|"") return ;; *) print_error "Invalid option" press_enter configure_docker_paths ;; esac } detect_installation() { INSTALL_STATUS="none" SERVER_RUNNING=false HBBS_RUNNING=false HBBR_RUNNING=false CONSOLE_RUNNING=false AIO_RUNNING=false IMAGES_BUILT=false DATA_EXISTS=false if docker ps --format '{{.Names}}' | grep -q "^${AIO_CONTAINER}$"; then AIO_RUNNING=true SERVER_RUNNING=true CONSOLE_RUNNING=true DOCKER_LAYOUT="single" fi # Check if images exist (split or all-in-one) if docker images | grep -qE "betterdesk-server|betterdesk-console|ghcr.io/unitronix/betterdesk|betterdesk:local"; then IMAGES_BUILT=true INSTALL_STATUS="partial" fi # Check data directory if [ -d "$DATA_DIR" ]; then DATA_EXISTS=true fi # Check split containers if docker ps --format '{{.Names}}' | grep -q "$SERVER_CONTAINER"; then SERVER_RUNNING=true HBBS_RUNNING=true # Alias for legacy checks HBBR_RUNNING=true # Go server includes relay DOCKER_LAYOUT="split" fi if docker ps --format '{{.Names}}' | grep -q "$CONSOLE_CONTAINER"; then CONSOLE_RUNNING=true DOCKER_LAYOUT="split" fi if [ "$AIO_RUNNING" = true ] && [ "$DATA_EXISTS" = true ]; then INSTALL_STATUS="complete" elif [ "$IMAGES_BUILT" = true ] && [ "$DATA_EXISTS" = true ] && \ [ "$SERVER_RUNNING" = true ] && [ "$CONSOLE_RUNNING" = true ]; then INSTALL_STATUS="complete" fi } print_status() { detect_installation echo "" echo -e "${WHITE}${BOLD}═══ Status Docker ═══${NC}" echo "" # Docker status if check_docker; then echo -e " Docker: ${GREEN}✓ Installed and running${NC}" else echo -e " Docker: ${RED}✗ Not running${NC}" fi if check_docker_compose; then echo -e " Docker Compose: ${GREEN}✓ Available${NC}" else echo -e " Docker Compose: ${RED}✗ Not found${NC}" fi echo "" echo -e "${WHITE}${BOLD}═══ Image Status ═══${NC}" echo "" for image in "betterdesk" "betterdesk-server" "betterdesk-console"; do if docker images --format '{{.Repository}}' | grep -qE "^${image}$|^ghcr.io/unitronix/${image}$"; then local size=$(docker images --format '{{.Size}}' "$image:latest" 2>/dev/null) echo -e " $image: ${GREEN}✓ Built${NC} ($size)" else echo -e " $image: ${RED}✗ Not found${NC}" fi done echo "" echo -e "${WHITE}${BOLD}═══ Container Status ═══${NC}" echo "" if [ "$AIO_RUNNING" = true ]; then echo -e " All-in-One: ${GREEN}● Running${NC} (server + console)" elif [ "$SERVER_RUNNING" = true ]; then echo -e " Server (Go): ${GREEN}● Running${NC} (signal + relay + API)" else echo -e " Server (Go): ${RED}○ Stopped${NC}" fi if [ "$AIO_RUNNING" = true ]; then echo -e " Web Console: ${GREEN}● Running${NC} (inside all-in-one)" elif [ "$CONSOLE_RUNNING" = true ]; then echo -e " Web Console: ${GREEN}● Running${NC}" else echo -e " Web Console: ${RED}○ Stopped${NC}" fi echo "" echo -e "${WHITE}${BOLD}═══ Configured Paths ═══${NC}" echo "" echo -e " Data directory: ${CYAN}$DATA_DIR${NC}" echo -e " Backup directory: ${CYAN}$BACKUP_DIR${NC}" echo -e " Compose file: ${CYAN}$COMPOSE_FILE${NC}" echo "" echo -e "${WHITE}${BOLD}═══ Data Status ═══${NC}" echo "" if [ "$DATA_EXISTS" = true ]; then echo -e " Database: ${GREEN}✓ Found in $DATA_DIR${NC}" else echo -e " Database: ${YELLOW}! Not found${NC}" fi echo "" } #=============================================================================== # Installation Functions #=============================================================================== install_docker() { print_step "Installing Docker..." if command -v apt-get &> /dev/null; then apt-get update -qq apt-get install -y -qq docker.io docker-compose-plugin elif command -v dnf &> /dev/null; then dnf install -y -q docker docker-compose-plugin elif command -v yum &> /dev/null; then yum install -y -q docker docker-compose-plugin else print_error "Unsupported system. Install Docker manually." return 1 fi systemctl enable docker systemctl start docker print_success "Docker installed" } choose_docker_layout() { if [ "$AUTO_MODE" = true ]; then print_info "Using ${DOCKER_LAYOUT} container layout (auto mode)" return fi echo "" local _menu_items=( $'Single container\tOfficial all-in-one image (recommended)' $'Split containers\tLegacy server + console images' ) local _menu_returns=( 1 2 ) menu_choose "Select Docker Layout" "Single container is easier to update (one image pull)" local layout_choice="$MENU_CHOICE" case "$layout_choice" in 2) DOCKER_LAYOUT="split" print_info "Split container layout selected (legacy)" ;; *) DOCKER_LAYOUT="single" print_info "Single container layout selected (recommended)" ;; esac } choose_database_type() { if [ "$AUTO_MODE" = true ]; then if [ "$USE_POSTGRESQL" = true ]; then print_info "Using PostgreSQL (auto mode)" DB_TYPE="postgresql" else print_info "Using SQLite (auto mode default)" DB_TYPE="sqlite" fi return fi echo "" local _menu_items=( $'SQLite\tDefault, simple, no extra setup' $'PostgreSQL\tRecommended for production (Docker container)' ) local _menu_returns=( 1 2 ) menu_choose "Select Database Type" "SQLite is recommended for most installs" local db_choice="$MENU_CHOICE" case "$db_choice" in 2) DB_TYPE="postgresql" print_info "PostgreSQL selected" # Get PostgreSQL credentials echo "" read -p "PostgreSQL password for 'betterdesk' user [betterdesk123]: " pg_pass POSTGRESQL_PASS="${pg_pass:-betterdesk123}" ;; *) DB_TYPE="sqlite" print_info "SQLite selected" ;; esac } preserve_compose_database_config() { # Keep existing database mode/credentials when regenerating compose file # during update/repair so we do not accidentally switch backends. DB_TYPE="sqlite" if [ -f "$COMPOSE_FILE" ]; then if grep -qE '^\s*-\s*DB_TYPE=postgresql|^\s*POSTGRES_USER:' "$COMPOSE_FILE"; then DB_TYPE="postgresql" local detected_user detected_pass detected_db detected_uri detected_user=$(grep -E '^\s*POSTGRES_USER:' "$COMPOSE_FILE" | head -1 | sed -E 's/^\s*POSTGRES_USER:\s*//') detected_pass=$(grep -E '^\s*POSTGRES_PASSWORD:' "$COMPOSE_FILE" | head -1 | sed -E 's/^\s*POSTGRES_PASSWORD:\s*//') detected_db=$(grep -E '^\s*POSTGRES_DB:' "$COMPOSE_FILE" | head -1 | sed -E 's/^\s*POSTGRES_DB:\s*//') detected_uri=$(grep -E '^\s*-\s*DATABASE_URL=postgres' "$COMPOSE_FILE" | head -1 | sed -E 's/^\s*-\s*DATABASE_URL=//') [ -n "$detected_user" ] && POSTGRESQL_USER="$detected_user" [ -n "$detected_pass" ] && POSTGRESQL_PASS="$detected_pass" [ -n "$detected_db" ] && POSTGRESQL_DB="$detected_db" [ -n "$detected_uri" ] && POSTGRESQL_URI="$detected_uri" fi elif [ "$USE_POSTGRESQL" = "true" ]; then DB_TYPE="postgresql" fi if [ "$DB_TYPE" = "postgresql" ]; then print_info "Preserved database mode: PostgreSQL" else print_info "Preserved database mode: SQLite" fi } create_compose_file_single() { print_step "Creating docker-compose.yml (single container)..." local admin_password signal_rate_limit server_ip admin_password="${DOCKER_ADMIN_PASSWORD:-}" if [ -z "$admin_password" ]; then if [ -n "$ADMIN_PASSWORD" ]; then admin_password="$ADMIN_PASSWORD" else admin_password=$(openssl rand -hex 16) fi fi DOCKER_ADMIN_PASSWORD="$admin_password" server_ip=$(resolve_relay_ip) signal_rate_limit="${SIGNAL_RATE_LIMIT_PER_IP:-20}" cat > "$COMPOSE_FILE" << EOF version: '3.8' services: betterdesk: container_name: $AIO_CONTAINER build: context: . dockerfile: Dockerfile image: betterdesk:local pull_policy: never ports: - "5000:5000" - "21115:21115" - "21116:21116" - "21116:21116/udp" - "21117:21117" - "21118:21118" - "21119:21119" - "21121:21121" volumes: - $DATA_DIR:/opt/rustdesk - console_data:/app/data environment: - NODE_ENV=production - PORT=5000 - HOST=0.0.0.0 - API_HOST=0.0.0.0 - API_ENABLED=false - ENCRYPTED_ONLY=1 - SERVER_BACKEND=betterdesk - HBBS_API_URL=http://127.0.0.1:21121/api - BETTERDESK_API_URL=http://127.0.0.1:21121/api - RUSTDESK_PATH=/opt/rustdesk - DATA_DIR=/app/data - DB_PATH=/opt/rustdesk/db_v2.sqlite3 - PUB_KEY_PATH=/opt/rustdesk/id_ed25519.pub - API_KEY_PATH=/opt/rustdesk/.api_key - DOCKER=true - BETTERDESK_UPDATE_MODE=image - BETTERDESK_DOCKER_LAYOUT=single - RELAY_SERVERS=$server_ip - SIGNAL_RATE_LIMIT_PER_IP=$signal_rate_limit - AUTH_DB_PATH=/app/data/auth.db - INIT_ADMIN_USER=admin - INIT_ADMIN_PASS=$admin_password - DEFAULT_ADMIN_USERNAME=admin - DEFAULT_ADMIN_PASSWORD=$admin_password healthcheck: test: ["CMD-SHELL", "curl -sf http://localhost:21121/api/health && curl -sf http://localhost:5000/health"] interval: 30s timeout: 10s retries: 3 start_period: 45s restart: unless-stopped networks: - betterdesk networks: betterdesk: driver: bridge volumes: console_data: EOF print_success "docker-compose.yml created (single container)" } create_compose_file() { if [ "$DOCKER_LAYOUT" = "single" ]; then create_compose_file_single return fi print_step "Creating docker-compose.yml..." # Start composing docker-compose.yml cat > "$COMPOSE_FILE" << EOF version: '3.8' services: EOF # Add PostgreSQL service if selected if [ "$DB_TYPE" = "postgresql" ]; then cat >> "$COMPOSE_FILE" << EOF postgres: container_name: betterdesk-postgres image: postgres:16-alpine environment: POSTGRES_USER: $POSTGRESQL_USER POSTGRES_PASSWORD: $POSTGRESQL_PASS POSTGRES_DB: $POSTGRESQL_DB volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U $POSTGRESQL_USER -d $POSTGRESQL_DB"] interval: 10s timeout: 5s retries: 5 restart: unless-stopped networks: - betterdesk EOF fi # Generate or preserve shared API key for Node.js <-> Go server communication local api_key if [ -f "$DATA_DIR/.api_key" ] && [ -s "$DATA_DIR/.api_key" ]; then api_key=$(cat "$DATA_DIR/.api_key") print_info "Preserved existing API key" else api_key=$(openssl rand -hex 32) echo "$api_key" > "$DATA_DIR/.api_key" chmod 600 "$DATA_DIR/.api_key" print_info "Generated API key for console <-> server communication" fi # Generate or preserve admin password (shared between Go server and Node.js console) local admin_password if [ -f "$DATA_DIR/.admin_credentials" ] && [ -s "$DATA_DIR/.admin_credentials" ]; then # Support both old format (admin:pass) and new format (Admin Password: pass) admin_password=$(grep -m1 '^Admin Password:' "$DATA_DIR/.admin_credentials" 2>/dev/null | sed 's/^Admin Password:[[:space:]]*//') if [ -z "$admin_password" ]; then admin_password=$(cut -d: -f2 "$DATA_DIR/.admin_credentials" 2>/dev/null) fi fi if [ -z "$admin_password" ]; then # Respect user-provided ADMIN_PASSWORD env var if set if [ -n "$ADMIN_PASSWORD" ]; then admin_password="$ADMIN_PASSWORD" print_info "Using custom admin password from ADMIN_PASSWORD env var" else # SECURITY (audit fix M-05, 2026-04-10): full hex entropy admin_password=$(openssl rand -hex 16) fi # Only clean auth.db on FRESH install (no existing credentials) if docker volume inspect "${PROJECT_NAME:-betterdesk}_console_data" >/dev/null 2>&1; then print_info "Cleaning old auth database from console_data volume..." docker run --rm -v "${PROJECT_NAME:-betterdesk}_console_data:/data" alpine \ sh -c "rm -f /data/auth.db /data/auth.db-wal /data/auth.db-shm" 2>/dev/null || true fi else print_info "Preserved existing admin password" fi DOCKER_ADMIN_PASSWORD="$admin_password" # Get relay server address according to RELAY_MODE / RELAY_SERVERS local server_ip server_ip=$(resolve_relay_ip) local signal_rate_limit="${SIGNAL_RATE_LIMIT_PER_IP:-20}" if ! [[ "$signal_rate_limit" =~ ^[0-9]+$ ]]; then print_warning "Invalid SIGNAL_RATE_LIMIT_PER_IP='$signal_rate_limit'; using 20" signal_rate_limit="20" fi # Add BetterDesk server (Go single binary - signal + relay + API) cat >> "$COMPOSE_FILE" << EOF server: container_name: $SERVER_CONTAINER build: context: . dockerfile: Dockerfile.server pull_policy: never command: ["/usr/local/bin/betterdesk-server", "-mode", "all", "-api-port", "21121", "-key-file", "/opt/rustdesk/id_ed25519"] ports: - "21121:21121" - "21115:21115" - "21116:21116" - "21116:21116/udp" - "21117:21117" - "21118:21118" - "21119:21119" volumes: - $DATA_DIR:/opt/rustdesk EOF if [ "$DB_TYPE" != "postgresql" ]; then cat >> "$COMPOSE_FILE" << EOF - console_data:/app/data:ro EOF fi cat >> "$COMPOSE_FILE" << EOF environment: - RELAY_SERVERS=$server_ip - INIT_ADMIN_PASS=$admin_password - SIGNAL_RATE_LIMIT_PER_IP=$signal_rate_limit EOF if [ "$DB_TYPE" != "postgresql" ]; then cat >> "$COMPOSE_FILE" << EOF - AUTH_DB_PATH=/app/data/auth.db EOF fi if [ "$DB_TYPE" = "postgresql" ]; then cat >> "$COMPOSE_FILE" << EOF - DB_URL=postgres://$POSTGRESQL_USER:$POSTGRESQL_PASS@postgres:5432/$POSTGRESQL_DB?sslmode=disable depends_on: postgres: condition: service_healthy EOF fi cat >> "$COMPOSE_FILE" << EOF healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:21121/api/health"] interval: 30s timeout: 10s retries: 3 start_period: 15s restart: unless-stopped networks: - betterdesk console: container_name: $CONSOLE_CONTAINER build: context: . dockerfile: Dockerfile.console pull_policy: never ports: - "5000:5000" volumes: - $DATA_DIR:/opt/rustdesk:ro - console_data:/app/data environment: - NODE_ENV=production - PORT=5000 - HOST=0.0.0.0 - API_HOST=0.0.0.0 - API_ENABLED=false - RUSTDESK_PATH=/opt/rustdesk - HBBS_API_URL=http://$SERVER_CONTAINER:21121/api - BETTERDESK_API_URL=http://$SERVER_CONTAINER:21121/api - SERVER_BACKEND=betterdesk - DATA_DIR=/app/data - DB_PATH=/opt/rustdesk/db_v2.sqlite3 - PUB_KEY_PATH=/opt/rustdesk/id_ed25519.pub - API_KEY_PATH=/opt/rustdesk/.api_key - KEYS_PATH=/opt/rustdesk - DEFAULT_ADMIN_PASSWORD=$admin_password - FORCE_PASSWORD_UPDATE=true - WS_HBBS_HOST=$SERVER_CONTAINER - WS_HBBS_PORT=21116 - WS_HBBR_HOST=$SERVER_CONTAINER - WS_HBBR_PORT=21117 - DOCKER=true EOF if [ "$DB_TYPE" = "postgresql" ]; then cat >> "$COMPOSE_FILE" << EOF - DB_TYPE=postgresql - DATABASE_URL=postgres://$POSTGRESQL_USER:$POSTGRESQL_PASS@postgres:5432/$POSTGRESQL_DB?sslmode=disable depends_on: postgres: condition: service_healthy server: condition: service_healthy EOF else cat >> "$COMPOSE_FILE" << EOF depends_on: server: condition: service_started EOF fi cat >> "$COMPOSE_FILE" << EOF healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:5000/health"] interval: 30s timeout: 10s retries: 3 start_period: 30s restart: unless-stopped networks: - betterdesk networks: betterdesk: driver: bridge EOF # Add volumes if [ "$DB_TYPE" = "postgresql" ]; then cat >> "$COMPOSE_FILE" << EOF volumes: console_data: postgres_data: EOF else cat >> "$COMPOSE_FILE" << EOF volumes: console_data: EOF fi print_success "docker-compose.yml created" } build_images() { print_step "Building Docker images..." cd "$SCRIPT_DIR" $COMPOSE_CMD build --no-cache print_success "Images built" } start_containers() { print_step "Starting containers..." cd "$SCRIPT_DIR" $COMPOSE_CMD up -d sleep 5 # Inject shared API key into Go server database for Node.js <-> Go communication local api_key_file="$DATA_DIR/.api_key" if [ -f "$api_key_file" ]; then local api_key api_key=$(cat "$api_key_file") local api_key_sql api_key_sql=$(sql_escape_literal "$api_key") # Use sqlite3 inside the server container to insert the API key local exec_container="$SERVER_CONTAINER" if [ "$DOCKER_LAYOUT" = "single" ] || docker ps --format '{{.Names}}' | grep -q "^${AIO_CONTAINER}$"; then exec_container="$AIO_CONTAINER" fi docker exec "$exec_container" sh -c " if command -v sqlite3 >/dev/null 2>&1; then sqlite3 /opt/rustdesk/db_v2.sqlite3 \"INSERT OR REPLACE INTO server_config (key, value) VALUES ('api_key', '$api_key_sql');\" 2>/dev/null fi " 2>/dev/null || true # Also try from host if sqlite3 is available if [ -f "$DATA_DIR/db_v2.sqlite3" ] && command -v sqlite3 &>/dev/null; then sqlite3 "$DATA_DIR/db_v2.sqlite3" "INSERT OR REPLACE INTO server_config (key, value) VALUES ('api_key', '$api_key_sql');" 2>/dev/null || true fi print_info "API key synced to Go server database" fi detect_installation if [ "$AIO_RUNNING" = true ]; then print_success "All-in-one container running" elif [ "$SERVER_RUNNING" = true ] && [ "$CONSOLE_RUNNING" = true ]; then print_success "All containers running" else 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() { print_step "Stopping containers..." cd "$SCRIPT_DIR" $COMPOSE_CMD down 2>/dev/null || true print_success "Containers stopped" } # Resolve the container that runs the Node.js panel (single AIO vs split console). resolve_panel_container() { if [ "$DOCKER_LAYOUT" = "single" ] || docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^${AIO_CONTAINER}$"; then echo "$AIO_CONTAINER" return 0 fi echo "$CONSOLE_CONTAINER" } create_admin_user() { print_step "Creating admin user..." # Use the password generated during compose file creation local admin_password="${DOCKER_ADMIN_PASSWORD}" if [ -z "$admin_password" ]; then # M-05: full hex entropy admin_password=$(openssl rand -hex 16) fi # Wait for database to be created sleep 3 # Node.js console auto-creates admin user on startup if no users exist # We use the reset-password script to set a secure password # Arguments: [username] — password first, then optional username local target_container target_container=$(resolve_panel_container) 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}" echo -e "${GREEN}║ PANEL LOGIN CREDENTIALS ║${NC}" echo -e "${GREEN}╠════════════════════════════════════════════════════════╣${NC}" echo -e "${GREEN}║ Login: ${WHITE}admin${GREEN} ║${NC}" echo -e "${GREEN}║ Password: ${WHITE}${admin_password}${GREEN} ║${NC}" echo -e "${GREEN}╚════════════════════════════════════════════════════════╝${NC}" echo "" # Save credentials mkdir -p "$DATA_DIR" if [ "$STORE_ADMIN_CREDENTIALS" = "true" ]; then cat > "$DATA_DIR/.admin_credentials" << CREDEOF Admin Username: admin Admin Password: $admin_password Generated by: BetterDesk Docker installer Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ) CREDEOF chmod 600 "$DATA_DIR/.admin_credentials" print_info "Credentials saved in: $DATA_DIR/.admin_credentials" else print_warning "Credentials are not persisted by default (security hardening)." fi } do_install() { print_header echo -e "${WHITE}${BOLD}══════════ FRESH DOCKER INSTALLATION ══════════${NC}" echo "" # Check Docker if ! check_docker; then print_warning "Docker is not installed or not running" if confirm "Do you want to install Docker?"; then install_docker else press_enter return fi fi if ! check_docker_compose; then print_error "Docker Compose is not available!" press_enter return fi detect_installation if [ "$INSTALL_STATUS" = "complete" ]; then print_warning "BetterDesk Docker is already installed!" if ! confirm "Do you want to reinstall?"; then return fi do_backup_silent stop_containers fi # Choose Docker layout (single recommended) choose_docker_layout # Choose database type (SQLite or PostgreSQL) choose_database_type # Create data directory with proper permissions (handles SELinux) print_step "Creating data directories..." create_data_directory "$DATA_DIR" || { print_error "Failed to create data directory: $DATA_DIR" print_info "If you're on SELinux-enabled system (AlmaLinux, RHEL, CentOS):" print_info " sudo setenforce 0 # Temporarily disable" print_info " # Or: sudo chcon -Rt svirt_sandbox_file_t $DATA_DIR" press_enter return } create_data_directory "$BACKUP_DIR" || true # Always recreate compose file to include database configuration create_compose_file build_images start_containers create_admin_user # Configure firewall rules print_step "Configuring firewall rules..." configure_firewall_rules echo "" print_success "Docker installation completed successfully!" echo "" local server_ip server_ip=$(get_public_ip) local public_key="" if [ -f "$DATA_DIR/id_ed25519.pub" ]; then public_key=$(cat "$DATA_DIR/id_ed25519.pub" 2>/dev/null) fi local db_type_info="SQLite" if [ "$DB_TYPE" = "postgresql" ]; then db_type_info="PostgreSQL (Docker container)" fi echo -e "${CYAN}╔════════════════════════════════════════════════════════════╗${NC}" echo -e "${CYAN}║ INSTALLATION INFO ║${NC}" echo -e "${CYAN}╠════════════════════════════════════════════════════════════╣${NC}" echo -e "${CYAN}║ Web Panel: ${WHITE}http://$server_ip:5000${NC}" echo -e "${CYAN}║ Server ID: ${WHITE}$server_ip${NC}" echo -e "${CYAN}║ Database: ${WHITE}$db_type_info${NC}" echo -e "${CYAN}║ Data: ${WHITE}$DATA_DIR${NC}" if [ -n "$public_key" ]; then echo -e "${CYAN}║ Key: ${WHITE}${public_key:0:20}...${NC}" fi echo -e "${CYAN}╠════════════════════════════════════════════════════════════╣${NC}" echo -e "${CYAN}║ ${WHITE}Required ports: 21115-21117 (TCP+UDP), 5000, 21121${NC}" echo -e "${CYAN}╠════════════════════════════════════════════════════════════╣${NC}" echo -e "${CYAN}║ ${YELLOW}RustDesk Client configuration:${NC}" echo -e "${CYAN}║ ID Server: ${WHITE}$server_ip${NC}" echo -e "${CYAN}║ Relay Server: ${WHITE}$server_ip${NC}" echo -e "${CYAN}║ Key: ${WHITE}${public_key:-}${NC}" echo -e "${CYAN}╚════════════════════════════════════════════════════════════╝${NC}" # Offer HTTPS Enterprise configuration for fresh installs echo "" print_info "🔒 Enterprise TLS enables full HTTPS on ALL ports (panel, signal, relay, API)" print_info " Recommended for production. Requires RustDesk client >= 1.3.x" echo "" if confirm "Would you like to configure HTTPS Enterprise now? (Option 5 in SSL menu)"; then do_configure_ssl fi press_enter } #=============================================================================== # Update Functions #=============================================================================== # GitHub repository configuration for online updates UPDATE_GITHUB_OWNER="${UPDATE_GITHUB_OWNER:-UNITRONIX}" UPDATE_GITHUB_REPO="${UPDATE_GITHUB_REPO:-BetterDesk}" UPDATE_GITHUB_BRANCH="${UPDATE_GITHUB_BRANCH:-main}" docker_update_env_file() { if [ -f "$SCRIPT_DIR/web-nodejs/.env" ]; then echo "$SCRIPT_DIR/web-nodejs/.env" elif [ -f "$SCRIPT_DIR/.env" ]; then echo "$SCRIPT_DIR/.env" else echo "$SCRIPT_DIR/web-nodejs/.env" fi } # Resolve container app UID/GID (PUID/PGID). Prefer process env, then compose .env, else 10001. resolve_docker_puid_pgid() { local env_file val DOCKER_PUID="${PUID:-}" DOCKER_PGID="${PGID:-}" env_file=$(docker_update_env_file) if [ -z "$DOCKER_PUID" ] && [ -f "$SCRIPT_DIR/.env" ]; then val=$(grep -E '^PUID=' "$SCRIPT_DIR/.env" 2>/dev/null | tail -1 | cut -d= -f2- | tr -d '\r"') [ -n "$val" ] && DOCKER_PUID="$val" fi if [ -z "$DOCKER_PGID" ] && [ -f "$SCRIPT_DIR/.env" ]; then val=$(grep -E '^PGID=' "$SCRIPT_DIR/.env" 2>/dev/null | tail -1 | cut -d= -f2- | tr -d '\r"') [ -n "$val" ] && DOCKER_PGID="$val" fi if [ -z "$DOCKER_PUID" ] && [ -f "$env_file" ]; then val=$(grep -E '^PUID=' "$env_file" 2>/dev/null | tail -1 | cut -d= -f2- | tr -d '\r"') [ -n "$val" ] && DOCKER_PUID="$val" fi if [ -z "$DOCKER_PGID" ] && [ -f "$env_file" ]; then val=$(grep -E '^PGID=' "$env_file" 2>/dev/null | tail -1 | cut -d= -f2- | tr -d '\r"') [ -n "$val" ] && DOCKER_PGID="$val" fi DOCKER_PUID="${DOCKER_PUID:-10001}" DOCKER_PGID="${DOCKER_PGID:-10001}" export DOCKER_PUID DOCKER_PGID } read_update_github_branch_from_env() { local env_file env_file=$(docker_update_env_file) if [ -f "$env_file" ]; then local val val=$(grep -E '^UPDATE_GITHUB_BRANCH=' "$env_file" 2>/dev/null | tail -1 | cut -d= -f2- | tr -d '\r"') if [ -n "$val" ]; then UPDATE_GITHUB_BRANCH="$val" export UPDATE_GITHUB_BRANCH fi fi } write_update_github_branch_to_env() { local branch="$1" local env_file env_file=$(docker_update_env_file) local env_dir env_dir=$(dirname "$env_file") mkdir -p "$env_dir" if [ ! -f "$env_file" ]; then touch "$env_file" fi if grep -qE '^UPDATE_GITHUB_BRANCH=' "$env_file" 2>/dev/null; then sed -i "s/^UPDATE_GITHUB_BRANCH=.*/UPDATE_GITHUB_BRANCH=${branch}/" "$env_file" else printf '\nUPDATE_GITHUB_BRANCH=%s\n' "$branch" >> "$env_file" fi UPDATE_GITHUB_BRANCH="$branch" export UPDATE_GITHUB_BRANCH print_success "Update channel saved (GitHub branch: $branch)" } switch_update_channel() { print_header echo -e "${WHITE}${BOLD}══════════ UPDATE CHANNEL ══════════${NC}" echo "" detect_installation if [ "$INSTALL_STATUS" = "none" ]; then print_error "BetterDesk Docker is not installed!" press_enter return fi read_update_github_branch_from_env print_info "Current GitHub branch: $UPDATE_GITHUB_BRANCH" echo "" local _menu_items=( $'Stable (main)\tProduction releases from the main branch' $'Development (dev)\tLatest work-in-progress from the dev branch' $'Back\tReturn without changes' ) local _menu_returns=( main dev 0 ) menu_choose "Update Channel" "Stable is recommended for production deployments" case "${MENU_CHOICE:-main}" in 0) return ;; dev) print_warning "Development channel may include unstable changes." write_update_github_branch_to_env "dev" ;; *) write_update_github_branch_to_env "main" ;; esac print_info "Use Online update from GitHub to pull from the selected branch." press_enter } # Pull latest project files from GitHub before rebuilding Docker images. # This ensures the Dockerfiles, compose files, and source code (Go server, # Node.js console) are up-to-date before docker compose build. update_docker_from_github() { local clone_dir="/tmp/betterdesk-docker-update-$$" rm -rf "$clone_dir" read_update_github_branch_from_env print_step "Downloading latest BetterDesk from GitHub..." if command -v git &>/dev/null; then local repo_url="https://github.com/${UPDATE_GITHUB_OWNER}/${UPDATE_GITHUB_REPO}.git" if ! git clone --depth 1 --single-branch --branch "$UPDATE_GITHUB_BRANCH" "$repo_url" "$clone_dir" 2>/dev/null; then print_error "git clone failed" rm -rf "$clone_dir" return 1 fi print_success "Repository cloned (branch: $UPDATE_GITHUB_BRANCH)" else local tarball_url="https://github.com/${UPDATE_GITHUB_OWNER}/${UPDATE_GITHUB_REPO}/archive/refs/heads/${UPDATE_GITHUB_BRANCH}.tar.gz" local tarball_path="/tmp/betterdesk-docker-update-$$.tar.gz" print_info "git not available, downloading tarball..." if ! curl -fsSL --connect-timeout 15 --max-time 120 -o "$tarball_path" "$tarball_url"; then print_error "Download failed. Check internet connection." rm -f "$tarball_path" return 1 fi mkdir -p "$clone_dir" if ! tar -xzf "$tarball_path" -C "$clone_dir" --strip-components=1; then print_error "Failed to extract update archive" rm -f "$tarball_path" && rm -rf "$clone_dir" return 1 fi rm -f "$tarball_path" print_success "Source downloaded and extracted" fi # Validate if [ ! -f "$clone_dir/betterdesk-server/go.mod" ] || [ ! -f "$clone_dir/web-nodejs/server.js" ]; then print_error "Downloaded source is incomplete or invalid" rm -rf "$clone_dir" return 1 fi local remote_version="" if [ -f "$clone_dir/VERSION" ]; then remote_version=$(cat "$clone_dir/VERSION" | tr -d '[:space:]') print_info "Remote version: $remote_version" fi # Update project files that Docker build needs print_step "Updating project files..." local files_updated=0 # Update Go server source if [ -d "$SCRIPT_DIR/betterdesk-server" ]; then rm -rf "$SCRIPT_DIR/betterdesk-server.pre-update" 2>/dev/null || true mv "$SCRIPT_DIR/betterdesk-server" "$SCRIPT_DIR/betterdesk-server.pre-update" 2>/dev/null || true fi # Copy *contents* into a guaranteed dir. Copying the directory itself would # nest the new tree inside an existing betterdesk-server/ if the rename # above failed (e.g. a locked file), leaving inconsistent source that breaks # the Docker build with "undefined" Go errors (issue #158). mkdir -p "$SCRIPT_DIR/betterdesk-server" cp -rf "$clone_dir/betterdesk-server/." "$SCRIPT_DIR/betterdesk-server/" files_updated=$((files_updated + 1)) # Update Node.js console source if [ -d "$SCRIPT_DIR/web-nodejs" ]; then # Preserve data/ and node_modules/ if they exist locally local preserve_dirs=("data" "node_modules") for pd in "${preserve_dirs[@]}"; do if [ -d "$SCRIPT_DIR/web-nodejs/$pd" ]; then mv "$SCRIPT_DIR/web-nodejs/$pd" "/tmp/betterdesk-docker-preserve-$$-$pd" 2>/dev/null || true fi done rm -rf "$SCRIPT_DIR/web-nodejs.pre-update" 2>/dev/null || true mv "$SCRIPT_DIR/web-nodejs" "$SCRIPT_DIR/web-nodejs.pre-update" 2>/dev/null || true fi # Copy *contents* into a guaranteed dir (see issue #158 note above). mkdir -p "$SCRIPT_DIR/web-nodejs" cp -rf "$clone_dir/web-nodejs/." "$SCRIPT_DIR/web-nodejs/" # Restore preserved directories for pd in "${preserve_dirs[@]}"; do if [ -d "/tmp/betterdesk-docker-preserve-$$-$pd" ]; then mv "/tmp/betterdesk-docker-preserve-$$-$pd" "$SCRIPT_DIR/web-nodejs/$pd" 2>/dev/null || true fi done rm -rf "$SCRIPT_DIR/web-nodejs.pre-update" 2>/dev/null || true rm -rf "$SCRIPT_DIR/betterdesk-server.pre-update" 2>/dev/null || true files_updated=$((files_updated + 1)) # Update Dockerfiles and compose files for df in Dockerfile Dockerfile.server Dockerfile.console \ docker-compose.yml docker-compose.single.yml docker-compose.quick.yml \ docker-compose.quick.single.yml docker-compose.quick.single.macvlan.yml \ docker/entrypoint.sh docker/supervisord.conf docker/server-entrypoint.sh docker/console-entrypoint.sh \ betterdesk-docker.sh betterdesk.sh betterdesk.ps1 VERSION; do if [ -f "$clone_dir/$df" ]; then mkdir -p "$(dirname "$SCRIPT_DIR/$df")" cp "$clone_dir/$df" "$SCRIPT_DIR/$df" if [[ "$df" == *.sh ]]; then chmod +x "$SCRIPT_DIR/$df" 2>/dev/null || true; fi files_updated=$((files_updated + 1)) fi done print_success "$files_updated project components updated from GitHub" # ---- Update SHA tracking for in-app updater (#192) ---- LAST_UPDATE_REMOTE_SHA="" if command -v git &>/dev/null && [ -d "$clone_dir/.git" ]; then LAST_UPDATE_REMOTE_SHA=$(git -C "$clone_dir" rev-parse HEAD 2>/dev/null || true) if [ -n "$LAST_UPDATE_REMOTE_SHA" ]; then mkdir -p "$SCRIPT_DIR/web-nodejs/data" echo "$LAST_UPDATE_REMOTE_SHA" > "$SCRIPT_DIR/web-nodejs/data/.update_sha" echo "$LAST_UPDATE_REMOTE_SHA" > "$SCRIPT_DIR/web-nodejs/data/.agent_source_sha" rm -f "$SCRIPT_DIR/web-nodejs/data/.last_update_result.json" print_info "SHA tracking updated: ${LAST_UPDATE_REMOTE_SHA:0:7}" fi fi rm -rf "$clone_dir" return 0 } # Push SHA baseline into the running console volume after container rebuild (#192). sync_console_update_tracking_in_container() { local remote_sha="${1:-}" [ -n "$remote_sha" ] || return 0 if ! docker ps --format '{{.Names}}' | grep -q "^${CONSOLE_CONTAINER}$"; then return 0 fi docker exec "$CONSOLE_CONTAINER" sh -c \ "mkdir -p /app/data && printf '%s\n' '$remote_sha' > /app/data/.update_sha && rm -f /app/data/.last_update_result.json" \ 2>/dev/null || true } do_update() { print_header echo -e "${WHITE}${BOLD}══════════ DOCKER UPDATE ══════════${NC}" echo "" detect_installation if [ "$INSTALL_STATUS" = "none" ]; then print_error "BetterDesk Docker is not installed!" print_info "Use 'Fresh Installation' option" press_enter return fi local _menu_items=( $'Online update from GitHub\tDownload latest code + rebuild images' $'Local rebuild\tRebuild images from current local files' $'Switch update channel\tChoose stable (main) or development (dev) branch' $'Back\tReturn to the main menu' ) local _menu_returns=( 1 2 3 0 ) menu_choose "Update Method" "Online GitHub update is recommended" local update_method="${MENU_CHOICE:-1}" case "$update_method" in 0) return ;; 3) switch_update_channel return ;; 2) # Legacy local rebuild print_info "Creating backup before update..." do_backup_silent preserve_compose_database_config create_compose_file stop_containers build_images start_containers print_success "Local update completed!" press_enter return ;; esac # ---- GitHub Pull + Docker Rebuild ---- print_info "Creating backup before update..." do_backup_silent if ! update_docker_from_github; then print_error "GitHub download failed. Try option 2 (local rebuild) instead." press_enter return fi preserve_compose_database_config print_info "Regenerating docker-compose.yml with latest template..." create_compose_file stop_containers build_images start_containers sync_console_update_tracking_in_container "${LAST_UPDATE_REMOTE_SHA:-}" print_success "Docker update completed!" press_enter } #=============================================================================== # Repair Functions #=============================================================================== do_repair() { detect_installation local _menu_items=( $'Rebuild images\tRecreate the Docker images' $'Restart containers\tStop and start the stack' $'Repair database\tRun database repair routines' $'Repair permissions\tFix Docker bind/volume ownership and SELinux context' $'Clean Docker\tPrune images and volumes' $'Full repair\tDo everything above' $'Back\tReturn to the main menu' ) local _menu_returns=( 1 2 3 4 5 6 0 ) menu_choose "Docker Repair" "Choose what to repair" local repair_choice="$MENU_CHOICE" case $repair_choice in 1) preserve_compose_database_config create_compose_file stop_containers build_images start_containers ;; 2) stop_containers start_containers ;; 3) repair_database_docker ;; 4) repair_docker_permissions ;; 5) if confirm "Are you sure you want to clean up unused Docker resources?"; then docker system prune -f print_success "Docker cleaned" fi ;; 6) preserve_compose_database_config create_compose_file stop_containers docker system prune -f build_images start_containers repair_database_docker repair_docker_permissions print_success "Full repair completed!" ;; 0) return ;; esac press_enter } repair_database_docker() { print_step "Repair database..." # Node.js console auto-initializes tables on startup. # Restarting the console container triggers full table check. docker restart "$CONSOLE_CONTAINER" 2>/dev/null || { print_warning "Console container is not running" return } sleep 5 # Verify via health endpoint if docker exec "$CONSOLE_CONTAINER" curl -sf http://localhost:5000/health >/dev/null 2>&1; then print_success "Database repaired (console restarted, tables verified)" else print_warning "Console restarted but health check failed" fi } repair_named_volume_permissions() { local volume="$1" [ -n "$volume" ] || return 0 if ! docker volume inspect "$volume" >/dev/null 2>&1; then return 0 fi resolve_docker_puid_pgid print_info "Repairing Docker volume permissions: $volume (uid=${DOCKER_PUID} gid=${DOCKER_PGID})" docker run --rm -v "$volume:/target" -e "PUID=${DOCKER_PUID}" -e "PGID=${DOCKER_PGID}" alpine:3.22 sh -c ' set -e mkdir -p /target chown -R "${PUID}:${PGID}" /target chmod -R u+rwX,g+rwX /target ' >/dev/null 2>&1 || { print_warning "Could not repair volume $volume (Docker may need to pull alpine:3.22)" return 1 } } repair_docker_permissions() { print_step "Repairing Docker data permissions..." auto_detect_docker_paths resolve_docker_puid_pgid if [ -n "$DATA_DIR" ]; then create_data_directory "$DATA_DIR" || { print_error "Failed to repair data directory: $DATA_DIR" return 1 } # Containers drop to PUID/PGID (default 10001). Keep secrets readable to the # container user without making them world-readable. chown -R "${DOCKER_PUID}:${DOCKER_PGID}" "$DATA_DIR" 2>/dev/null || true chmod 755 "$DATA_DIR" 2>/dev/null || true for secret in ".api_key" ".admin_credentials" "id_ed25519"; do if [ -f "$DATA_DIR/$secret" ]; then chmod 600 "$DATA_DIR/$secret" 2>/dev/null || true fi done if [ -f "$DATA_DIR/id_ed25519.pub" ]; then chmod 644 "$DATA_DIR/id_ed25519.pub" 2>/dev/null || true fi print_success "Host data directory permissions repaired: $DATA_DIR (uid=${DOCKER_PUID} gid=${DOCKER_PGID})" fi local repaired_volumes=0 local volume while IFS= read -r volume; do [ -n "$volume" ] || continue if repair_named_volume_permissions "$volume"; then repaired_volumes=$((repaired_volumes + 1)) fi done < <(docker volume ls --format '{{.Name}}' 2>/dev/null | grep -E '(^|_)(betterdesk-data|console-data|console_data|betterdesk.*data)$' || true) if [ "$repaired_volumes" -gt 0 ]; then print_success "Repaired $repaired_volumes Docker volume(s)" else print_info "No BetterDesk named Docker volumes found to repair" fi } run_compose_safe() { local action="$1" local compose_dir compose_name if [ ! -f "$COMPOSE_FILE" ]; then print_warning "Compose file not found: $COMPOSE_FILE" return 1 fi compose_dir="$(cd "$(dirname "$COMPOSE_FILE")" && pwd)" compose_name="$(basename "$COMPOSE_FILE")" (cd "$compose_dir" && $COMPOSE_CMD -f "$compose_name" "$action") } restart_containers_safe() { print_step "Restarting BetterDesk containers safely..." if [ ! -f "$COMPOSE_FILE" ]; then print_warning "Compose file not found; trying docker restart for known containers" docker restart "$SERVER_CONTAINER" "$CONSOLE_CONTAINER" >/dev/null 2>&1 || { print_warning "Could not restart known containers" return 1 } return 0 fi local compose_dir compose_name compose_dir="$(cd "$(dirname "$COMPOSE_FILE")" && pwd)" compose_name="$(basename "$COMPOSE_FILE")" (cd "$compose_dir" && $COMPOSE_CMD -f "$compose_name" up -d) || { print_warning "docker compose up -d failed" return 1 } } check_local_http() { local label="$1" local url="$2" printf " %-28s " "$label:" if curl -sfo /dev/null --connect-timeout 3 "$url" 2>/dev/null; then echo -e "${GREEN}OK${NC} ($url)" return 0 fi echo -e "${YELLOW}UNREACHABLE${NC} ($url)" return 1 } print_rescue_diagnostics() { echo -e "${WHITE}${BOLD}══════════ DOCKER RESCUE DIAGNOSTICS ══════════${NC}" echo "" auto_detect_docker_paths if ! command -v docker >/dev/null 2>&1; then print_warning "Docker CLI is not installed or not in PATH" echo " Compose file: $COMPOSE_FILE" echo " DATA_DIR: $DATA_DIR" echo " Next step: install Docker, then rerun --rescue" return 0 fi if ! check_docker; then print_warning "Docker daemon is not running or not accessible" echo " Compose file: $COMPOSE_FILE" echo " DATA_DIR: $DATA_DIR" echo " Next step: start Docker, then rerun --rescue" return 0 fi detect_installation print_status echo "" echo -e "${WHITE}${BOLD}═══ Compose and volumes ═══${NC}" echo " Compose file: $COMPOSE_FILE" if [ -f "$COMPOSE_FILE" ]; then echo -e " Compose file exists: ${GREEN}yes${NC}" else echo -e " Compose file exists: ${YELLOW}no${NC}" fi echo " DATA_DIR: $DATA_DIR" if [ -d "$DATA_DIR" ]; then echo -e " DATA_DIR writable: $(test -w "$DATA_DIR" && echo "${GREEN}yes${NC}" || echo "${YELLOW}no${NC}")" fi echo " Docker volumes:" docker volume ls --format ' - {{.Name}}' 2>/dev/null | grep -E 'betterdesk|console' || echo " none detected" echo "" echo -e "${WHITE}${BOLD}═══ Container logs (last 15 lines) ═══${NC}" for container in "$SERVER_CONTAINER" "$CONSOLE_CONTAINER"; do echo "" echo -e "${CYAN}--- $container ---${NC}" docker logs --tail 15 "$container" 2>&1 || echo "Container does not exist" done echo "" echo -e "${WHITE}${BOLD}═══ Local health checks ═══${NC}" check_local_http "Go API 21114" "http://127.0.0.1:21114/api/health" || true check_local_http "Go API 21121" "http://127.0.0.1:21121/api/health" || true check_local_http "Web console" "http://127.0.0.1:5000/health" || true echo "" echo -e "${WHITE}${BOLD}═══ Port listeners ═══${NC}" for port in 21114 21115 21116 21117 21121 5000; do printf " Port %-5s " "$port" if ss -tulnp 2>/dev/null | grep -q ":${port} "; then echo -e "${GREEN}LISTENING${NC}" else echo -e "${YELLOW}NOT LISTENING${NC}" fi done } do_safe_rescue() { print_header echo -e "${WHITE}${BOLD}══════════ SAFE DOCKER RESCUE ══════════${NC}" echo "" check_docker || { print_error "Docker is not installed or the daemon is not running" return 1 } check_docker_compose || { print_error "Docker Compose is not available" return 1 } repair_docker_permissions || true restart_containers_safe || true sleep 3 repair_database_docker || true echo "" print_rescue_diagnostics echo "" print_success "Safe rescue completed. No data or volumes were deleted." } #=============================================================================== # Validation Functions #=============================================================================== do_validate() { print_header echo -e "${WHITE}${BOLD}══════════ DOCKER VALIDATION ══════════${NC}" echo "" local errors=0 local warnings=0 # Check Docker echo -e "${WHITE}Checking Docker...${NC}" echo "" echo -n " Docker daemon: " if check_docker; then echo -e "${GREEN}✓ Running${NC}" else echo -e "${RED}✗ Not running${NC}" errors=$((errors + 1)) fi echo -n " Docker Compose: " if check_docker_compose; then echo -e "${GREEN}✓ Available${NC}" else echo -e "${RED}✗ Not found${NC}" errors=$((errors + 1)) fi # Check images echo "" echo -e "${WHITE}Checking images...${NC}" echo "" for image in "betterdesk-server" "betterdesk-console"; do echo -n " $image: " if docker images --format '{{.Repository}}' | grep -q "^$image$"; then echo -e "${GREEN}✓ Built${NC}" else echo -e "${RED}✗ Not found${NC}" errors=$((errors + 1)) fi done # Check containers echo "" echo -e "${WHITE}Checking containers...${NC}" echo "" detect_installation echo -n " Server (Go): " if [ "$SERVER_RUNNING" = true ]; then echo -e "${GREEN}● Running${NC}" else echo -e "${RED}○ Stopped${NC}" errors=$((errors + 1)) fi echo -n " Console: " if [ "$CONSOLE_RUNNING" = true ]; then echo -e "${GREEN}● Running${NC}" else echo -e "${RED}○ Stopped${NC}" errors=$((errors + 1)) fi # Check data echo "" echo -e "${WHITE}Checking data...${NC}" echo "" echo -n " Data directory: " if [ -d "$DATA_DIR" ]; then echo -e "${GREEN}✓ Exists${NC}" else echo -e "${RED}✗ Not found${NC}" errors=$((errors + 1)) fi echo -n " Database: " if [ -f "$DATA_DIR/db_v2.sqlite3" ]; then echo -e "${GREEN}✓ Exists${NC}" else echo -e "${YELLOW}! Will be created on first start${NC}" warnings=$((warnings + 1)) fi # Check ports echo "" echo -e "${WHITE}Checking ports...${NC}" echo "" for port in 21115 21116 21117 5000 21121; do echo -n " Port $port: " if ss -tlnp 2>/dev/null | grep -q ":$port " || netstat -tlnp 2>/dev/null | grep -q ":$port "; then echo -e "${GREEN}● Listening${NC}" else echo -e "${YELLOW}○ Free${NC}" warnings=$((warnings + 1)) fi done # Summary echo "" echo -e "${WHITE}═══════════════════════════════════════${NC}" if [ $errors -eq 0 ] && [ $warnings -eq 0 ]; then echo -e "${GREEN}✓ Installation correct - no problems found${NC}" elif [ $errors -eq 0 ]; then echo -e "${YELLOW}! Found $warnings warnings${NC}" else echo -e "${RED}✗ Found $errors errors and $warnings warnings${NC}" echo "" echo -e "${CYAN}Use 'Repair' option to fix problems${NC}" fi press_enter } #=============================================================================== # Backup Functions #=============================================================================== do_backup() { print_header echo -e "${WHITE}${BOLD}══════════ DOCKER BACKUP ══════════${NC}" echo "" do_backup_silent print_success "Backup completed!" press_enter } do_backup_silent() { local backup_name="betterdesk_docker_backup_$(date +%Y%m%d_%H%M%S)" local backup_path="$BACKUP_DIR/$backup_name" mkdir -p "$backup_path" print_step "Creating backup: $backup_name" # Backup data directory if [ -d "$DATA_DIR" ]; then cp -r "$DATA_DIR"/* "$backup_path/" 2>/dev/null || true print_info " - Dane ($DATA_DIR)" fi # Backup compose file if [ -f "$COMPOSE_FILE" ]; then cp "$COMPOSE_FILE" "$backup_path/" print_info " - docker-compose.yml" fi # Create archive cd "$BACKUP_DIR" tar -czf "$backup_name.tar.gz" "$backup_name" rm -rf "$backup_name" print_success "Backup saved: $BACKUP_DIR/$backup_name.tar.gz" } #=============================================================================== # Password Reset Functions #=============================================================================== do_reset_password() { detect_installation if [ "$CONSOLE_RUNNING" != true ]; then print_error "Console container is not running!" press_enter return fi local _menu_items=( $'Generate random password\tCreate a new strong password' $'Set custom password\tType the password yourself' $'Back\tReturn to the main menu' ) local _menu_returns=( 1 2 0 ) menu_choose "Admin Password Reset" "Console container: running" local pw_choice="$MENU_CHOICE" local new_password case $pw_choice in 1) # M-05: full hex entropy new_password=$(openssl rand -hex 16) ;; 2) echo "" read -sp "Enter new password (min. 8 characters): " new_password echo "" if [ ${#new_password} -lt 8 ]; then print_error "Password must be at least 8 characters!" press_enter return fi ;; 0) return ;; *) return ;; esac # Update password using reset-password.js (supports both SQLite and PostgreSQL) # Arguments: [username] — password first, then optional username # Single-container layout uses "betterdesk", not "betterdesk-console" (#299). local panel_container panel_container=$(resolve_panel_container) # Run as betterdesk: auth.db is mode 0600 / owned by PUID (default 10001); root lacks CAP_DAC_OVERRIDE (#299). docker exec -u betterdesk "$panel_container" node /app/scripts/reset-password.js "$new_password" admin 2>/dev/null || { print_warning "reset-password.js failed, trying inline fallback..." docker exec -u betterdesk -e RESET_ADMIN_PASSWORD="$new_password" "$panel_container" node -e " const bcrypt = require('bcrypt'); const Database = require('better-sqlite3'); const path = require('path'); const dataDir = process.env.DATA_DIR || '/app/data'; const dbPath = path.join(dataDir, 'auth.db'); const resetPassword = process.env.RESET_ADMIN_PASSWORD || ''; const db = new Database(dbPath); const hash = bcrypt.hashSync(resetPassword, 10); const update = db.prepare('UPDATE users SET password_hash = ? WHERE username = ?'); const result = update.run(hash, 'admin'); if (result.changes === 0) { const insert = db.prepare('INSERT INTO users (username, password_hash, role, is_active, created_at) VALUES (?, ?, ?, 1, datetime(\"now\"))'); insert.run('admin', hash, 'admin'); console.log('Admin user created'); } else { console.log('Password updated'); } db.close(); " } echo "" echo -e "${GREEN}╔════════════════════════════════════════════════════════╗${NC}" echo -e "${GREEN}║ NEW LOGIN CREDENTIALS ║${NC}" echo -e "${GREEN}╠════════════════════════════════════════════════════════╣${NC}" echo -e "${GREEN}║ Login: ${WHITE}admin${GREEN} ║${NC}" echo -e "${GREEN}║ Password: ${WHITE}${new_password}${GREEN} ║${NC}" echo -e "${GREEN}╚════════════════════════════════════════════════════════╝${NC}" # Save credentials if [ "$STORE_ADMIN_CREDENTIALS" = "true" ]; then cat > "$DATA_DIR/.admin_credentials" << CREDEOF Admin Username: admin Admin Password: $new_password Generated by: BetterDesk Docker password reset Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ) CREDEOF chmod 600 "$DATA_DIR/.admin_credentials" fi press_enter } #=============================================================================== # Build Functions #=============================================================================== do_build() { local _menu_items=( $'Rebuild all images\tServer + console containers' $'Rebuild server (Go)\tOnly the Go server image' $'Rebuild console (Node.js)\tOnly the console image' $'Back\tReturn to the main menu' ) local _menu_returns=( 1 2 3 0 ) menu_choose "Build Images" "Rebuild Docker images" local build_choice="$MENU_CHOICE" cd "$SCRIPT_DIR" case $build_choice in 1) print_step "Building all images..." $COMPOSE_CMD build --no-cache ;; 2) print_step "Building Server (Go)..." $COMPOSE_CMD build --no-cache server ;; 3) print_step "Building Console (Node.js)..." $COMPOSE_CMD build --no-cache console ;; 0) return ;; esac print_success "Build completed!" if confirm "Do you want to restart containers?"; then stop_containers start_containers fi press_enter } #=============================================================================== # Firewall Functions #=============================================================================== configure_firewall_rules() { local required_ports="21115 21116 21117 5000 21121" local created=0 local total=0 if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -q "active"; then print_info "Configuring UFW firewall rules..." for port in $required_ports; do total=$((total + 1)) if ! ufw status 2>/dev/null | grep -qE "^${port}[/ ]"; then if [ "$port" = "21116" ]; then ufw allow 21116/tcp comment "BetterDesk ID Server TCP" 2>/dev/null && created=$((created + 1)) ufw allow 21116/udp comment "BetterDesk ID Server UDP" 2>/dev/null && created=$((created + 1)) total=$((total + 1)) else ufw allow "${port}/tcp" comment "BetterDesk port ${port}" 2>/dev/null && created=$((created + 1)) fi fi done ufw reload 2>/dev/null elif command -v firewall-cmd &>/dev/null && systemctl is-active --quiet firewalld 2>/dev/null; then print_info "Configuring firewalld rules..." for port in $required_ports; do total=$((total + 1)) local open_ports=$(firewall-cmd --list-ports 2>/dev/null) if ! echo "$open_ports" | grep -qE "${port}/tcp"; then if [ "$port" = "21116" ]; then firewall-cmd --permanent --add-port=21116/tcp 2>/dev/null && created=$((created + 1)) firewall-cmd --permanent --add-port=21116/udp 2>/dev/null && created=$((created + 1)) total=$((total + 1)) else firewall-cmd --permanent --add-port="${port}/tcp" 2>/dev/null && created=$((created + 1)) fi fi done firewall-cmd --reload 2>/dev/null elif command -v iptables &>/dev/null; then print_info "Configuring iptables rules..." for port in $required_ports; do total=$((total + 1)) if ! iptables -L INPUT -n 2>/dev/null | grep -qE "dpt:${port}\b"; then if [ "$port" = "21116" ]; then iptables -A INPUT -p tcp --dport 21116 -j ACCEPT 2>/dev/null && created=$((created + 1)) iptables -A INPUT -p udp --dport 21116 -j ACCEPT 2>/dev/null && created=$((created + 1)) total=$((total + 1)) else iptables -A INPUT -p tcp --dport "$port" -j ACCEPT 2>/dev/null && created=$((created + 1)) fi fi done if command -v iptables-save &>/dev/null; then iptables-save > /etc/iptables/rules.v4 2>/dev/null || \ iptables-save > /etc/sysconfig/iptables 2>/dev/null || true fi else print_info "No active firewall detected — no rules to configure" return 0 fi if [ $created -gt 0 ]; then print_success "Created $created firewall rule(s)" else print_success "All firewall rules already configured" fi return 0 } #=============================================================================== # Diagnostics Functions #=============================================================================== do_diagnostics() { print_header echo -e "${WHITE}${BOLD}══════════ DOCKER DIAGNOSTICS ══════════${NC}" echo "" detect_installation print_status echo "" echo -e "${WHITE}${BOLD}═══ Container logs (last 15 lines) ═══${NC}" echo "" for container in "$SERVER_CONTAINER" "$CONSOLE_CONTAINER"; do echo -e "${CYAN}--- $container ---${NC}" docker logs --tail 15 "$container" 2>&1 || echo "Container does not exist" echo "" done echo -e "${WHITE}${BOLD}═══ Resource usage ═══${NC}" echo "" docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}" 2>/dev/null | grep -E "NAME|betterdesk" || echo "No running containers found" echo "" echo -e "${WHITE}${BOLD}═══ Database statistics ═══${NC}" echo "" if [ "$CONSOLE_RUNNING" = true ]; then docker exec "$CONSOLE_CONTAINER" sh -c ' STATS=$(curl -sf http://localhost:5000/health 2>/dev/null) if [ -n "$STATS" ]; then echo " Console: healthy" else echo " Console: health check failed" fi ' if [ "$SERVER_RUNNING" = true ]; then docker exec "$SERVER_CONTAINER" sh -c ' RESP=$(curl -sf http://localhost:21121/api/peers 2>/dev/null) if [ -n "$RESP" ]; then echo " Server API: responding" else echo " Server API: not responding" fi ' 2>/dev/null || echo " Server API: container not accessible" fi else echo " Console container is not running" fi # --- Port diagnostics --- echo "" echo -e "${WHITE}${BOLD}═══ Port diagnostics ═══${NC}" echo "" local port_issues=0 local port_defs=( "21115:TCP:betterdesk-server:NAT Test" "21116:TCP:betterdesk-server:ID Server (TCP)" "21116:UDP:betterdesk-server:ID Server (UDP)" "21117:TCP:betterdesk-server:Relay Server" "5000:TCP:betterdesk-console:Web Console" "21121:TCP:betterdesk-server:HTTP API (client + REST, WAN)" ) for entry in "${port_defs[@]}"; do IFS=':' read -r port proto expected desc <<< "$entry" local proc_info="" if [ "$proto" = "TCP" ]; then proc_info=$(ss -tlnp 2>/dev/null | grep ":${port} " | head -1) [ -z "$proc_info" ] && proc_info=$(netstat -tlnp 2>/dev/null | grep ":${port} " | head -1) else proc_info=$(ss -ulnp 2>/dev/null | grep ":${port} " | head -1) [ -z "$proc_info" ] && proc_info=$(netstat -ulnp 2>/dev/null | grep ":${port} " | head -1) fi printf " Port %s/%s (%-18s): " "$port" "$proto" "$desc" if [ -n "$proc_info" ]; then local process_name=$(echo "$proc_info" | grep -oP 'users:\(\("\K[^"]+' 2>/dev/null || \ echo "$proc_info" | awk '{print $NF}') if echo "$process_name" | grep -qiE "docker|$expected"; then echo -e "${GREEN}OK${NC}" else echo -e "${RED}CONFLICT - used by $process_name${NC}" port_issues=$((port_issues + 1)) fi else echo -e "${YELLOW}NOT LISTENING${NC}" fi done if [ $port_issues -gt 0 ]; then echo "" print_warning "$port_issues port conflict(s) detected!" echo -e " ${YELLOW}Tip: Stop conflicting processes or change Docker port mappings${NC}" fi # --- Firewall diagnostics --- echo "" echo -e "${WHITE}${BOLD}═══ Firewall status ═══${NC}" echo "" local fw_type="none" local missing_rules=0 local required_ports="21115 21116 21117 5000 21121" if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -q "active"; then fw_type="ufw" echo -e " Firewall: ${YELLOW}UFW (active)${NC}" echo "" for port in $required_ports; do local status_line=$(ufw status 2>/dev/null | grep -E "^${port}[/ ]") printf " Port %-5s: " "$port" if [ -n "$status_line" ]; then echo -e "${GREEN}ALLOWED${NC}" else echo -e "${RED}NO RULE${NC}" missing_rules=$((missing_rules + 1)) fi done elif command -v firewall-cmd &>/dev/null && systemctl is-active --quiet firewalld 2>/dev/null; then fw_type="firewalld" echo -e " Firewall: ${YELLOW}firewalld (active)${NC}" echo "" local open_ports=$(firewall-cmd --list-ports 2>/dev/null) for port in $required_ports; do printf " Port %-5s: " "$port" if echo "$open_ports" | grep -qE "${port}/tcp|${port}/udp"; then echo -e "${GREEN}ALLOWED${NC}" else echo -e "${RED}NO RULE${NC}" missing_rules=$((missing_rules + 1)) fi done elif iptables -L INPUT -n 2>/dev/null | grep -q "ACCEPT"; then fw_type="iptables" echo -e " Firewall: ${YELLOW}iptables${NC}" echo "" for port in $required_ports; do printf " Port %-5s: " "$port" if iptables -L INPUT -n 2>/dev/null | grep -qE "dpt:${port}\b"; then echo -e "${GREEN}ALLOWED${NC}" else echo -e "${RED}NO RULE / CHECK MANUALLY${NC}" missing_rules=$((missing_rules + 1)) fi done else echo -e " Firewall: ${GREEN}No active firewall detected (all ports open)${NC}" fi if [ $missing_rules -gt 0 ]; then echo "" print_warning "$missing_rules firewall rule(s) missing!" echo -e " ${YELLOW}Use option 'F' below to auto-configure firewall${NC}" fi # --- API connectivity test --- echo "" echo -e "${WHITE}${BOLD}═══ API connectivity ═══${NC}" echo "" printf " Server API (21121): " if curl -sfo /dev/null --connect-timeout 3 "http://127.0.0.1:21121/api/server-info" 2>/dev/null; then echo -e "${GREEN}OK${NC}" else echo -e "${RED}UNREACHABLE${NC}" fi printf " Web Console (5000): " if curl -sfo /dev/null --connect-timeout 3 "http://127.0.0.1:5000/health" 2>/dev/null; then echo -e "${GREEN}OK${NC}" else echo -e "${RED}UNREACHABLE${NC}" fi # --- Diagnostics sub-menu --- echo "" local _menu_items=( $'Configure firewall rules\tAuto-create any missing rules' $'Test port connectivity\tProbe ports from outside' $'Back\tReturn to the main menu' ) local _menu_returns=( F P 0 ) menu_choose "Diagnostics Actions" "Optional follow-up checks" local sub_choice="$MENU_CHOICE" case "$sub_choice" in [Ff]) echo "" configure_firewall_rules press_enter ;; [Pp]) echo "" echo -e "${WHITE}${BOLD}═══ External port test ═══${NC}" echo "" local server_ip=$(get_public_ip) print_info "Public IP: $server_ip" print_info "Testing external port accessibility..." echo "" for port in 21115 21116 21117; do printf " Port %s: " "$port" if timeout 3 bash -c "echo >/dev/tcp/$server_ip/$port" 2>/dev/null; then echo -e "${GREEN}REACHABLE${NC}" else echo -e "${RED}BLOCKED/UNREACHABLE${NC}" fi done press_enter ;; *) return ;; esac } #=============================================================================== # Uninstall Functions #=============================================================================== do_uninstall() { print_header echo -e "${RED}${BOLD}══════════ UNINSTALL DOCKER ══════════${NC}" echo "" print_warning "This operation will remove BetterDesk Docker!" echo "" if ! confirm "Are you sure you want to continue?"; then return fi if confirm "Create backup before uninstall?"; then do_backup_silent fi print_step "Stopping containers..." cd "$SCRIPT_DIR" 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 print_info "Images removed" fi if confirm "Remove data ($DATA_DIR)?"; then rm -rf "$DATA_DIR" print_info "Removed: $DATA_DIR" fi print_success "BetterDesk Docker has been uninstalled" press_enter } #=============================================================================== # Migration Functions #=============================================================================== # Detect existing standard RustDesk Docker installation detect_existing_rustdesk() { EXISTING_FOUND=false EXISTING_CONTAINERS=() EXISTING_DATA_DIR="" EXISTING_COMPOSE_FILE="" EXISTING_KEY_FILE="" EXISTING_DB_FILE="" print_step "Scanning for existing RustDesk Docker installations..." echo "" # 1. Search for RustDesk containers (common naming patterns) local container_patterns=("hbbs" "hbbr" "rustdesk" "s6") local found_containers=() for pattern in "${container_patterns[@]}"; do while IFS= read -r line; do [ -z "$line" ] && continue # Skip BetterDesk containers if [[ "$line" == *"betterdesk"* ]]; then continue fi found_containers+=("$line") done < <(docker ps -a --format '{{.Names}}' 2>/dev/null | grep -i "$pattern" || true) done # Deduplicate local unique_containers=() for c in "${found_containers[@]}"; do local is_dup=false for u in "${unique_containers[@]}"; do if [ "$c" = "$u" ]; then is_dup=true break fi done if [ "$is_dup" = false ]; then unique_containers+=("$c") fi done EXISTING_CONTAINERS=("${unique_containers[@]}") if [ ${#EXISTING_CONTAINERS[@]} -gt 0 ]; then print_info "Found RustDesk containers:" for c in "${EXISTING_CONTAINERS[@]}"; do local status status=$(docker inspect --format '{{.State.Status}}' "$c" 2>/dev/null || echo "unknown") echo -e " ${CYAN}•${NC} $c (${status})" done echo "" fi # 2. Try to find data directory from container mounts for c in "${EXISTING_CONTAINERS[@]}"; do local mounts mounts=$(docker inspect --format '{{range .Mounts}}{{.Source}}:{{.Destination}} {{end}}' "$c" 2>/dev/null || true) for mount in $mounts; do local src="${mount%%:*}" local dst="${mount##*:}" # Look for RustDesk data mounts (typically /root or /data or /opt/rustdesk) if [[ "$dst" == "/root" ]] || [[ "$dst" == "/data" ]] || [[ "$dst" == "/opt/rustdesk" ]]; then if [ -d "$src" ]; then # Check for key files if [ -f "$src/id_ed25519" ] || [ -f "$src/id_ed25519.pub" ] || [ -f "$src/db_v2.sqlite3" ]; then EXISTING_DATA_DIR="$src" break 2 fi fi fi done done # 3. If no data dir from mounts, search common locations if [ -z "$EXISTING_DATA_DIR" ]; then local search_paths=( "./data" "./rustdesk-data" "/opt/rustdesk" "/opt/rustdesk-data" "$HOME/rustdesk" "$HOME/data" ) for path in "${search_paths[@]}"; do if [ -d "$path" ] && [ -f "$path/id_ed25519" ]; then EXISTING_DATA_DIR="$path" break fi done fi # 4. Search for existing docker-compose files local compose_search_paths=( "." "$HOME" "/opt/rustdesk" "/opt" ) for base in "${compose_search_paths[@]}"; do for fname in "docker-compose.yml" "docker-compose.yaml" "compose.yml" "compose.yaml"; do local candidate="$base/$fname" if [ -f "$candidate" ] && grep -qi "rustdesk\|hbbs\|hbbr" "$candidate" 2>/dev/null; then # Skip BetterDesk's own compose file if grep -qi "betterdesk" "$candidate" 2>/dev/null; then continue fi EXISTING_COMPOSE_FILE="$candidate" break 2 fi done done # 5. Verify found data if [ -n "$EXISTING_DATA_DIR" ]; then [ -f "$EXISTING_DATA_DIR/id_ed25519" ] && EXISTING_KEY_FILE="$EXISTING_DATA_DIR/id_ed25519" [ -f "$EXISTING_DATA_DIR/db_v2.sqlite3" ] && EXISTING_DB_FILE="$EXISTING_DATA_DIR/db_v2.sqlite3" fi # Determine if we found anything useful if [ ${#EXISTING_CONTAINERS[@]} -gt 0 ] || [ -n "$EXISTING_DATA_DIR" ]; then EXISTING_FOUND=true fi } do_migrate() { print_header echo -e "${WHITE}${BOLD}══════════ MIGRATE FROM EXISTING RUSTDESK ══════════${NC}" echo "" echo -e "${CYAN}This wizard will migrate your existing RustDesk Docker installation${NC}" echo -e "${CYAN}to BetterDesk Console with enhanced features and web management.${NC}" echo "" # Check Docker if ! check_docker; then print_error "Docker is not available!" press_enter return fi if ! check_docker_compose; then print_error "Docker Compose is not available!" press_enter return fi # Detect existing installation detect_existing_rustdesk if [ "$EXISTING_FOUND" = false ]; then echo "" print_warning "No existing RustDesk Docker installation detected automatically." echo "" echo "You can specify the data directory manually." echo -e "${CYAN}The data directory should contain files like: id_ed25519, id_ed25519.pub, db_v2.sqlite3${NC}" echo "" read -p "Enter path to existing RustDesk data directory (or press Enter to cancel): " manual_path if [ -z "$manual_path" ]; then press_enter return fi if [ ! -d "$manual_path" ]; then print_error "Directory not found: $manual_path" press_enter return fi EXISTING_DATA_DIR="$manual_path" [ -f "$EXISTING_DATA_DIR/id_ed25519" ] && EXISTING_KEY_FILE="$EXISTING_DATA_DIR/id_ed25519" [ -f "$EXISTING_DATA_DIR/db_v2.sqlite3" ] && EXISTING_DB_FILE="$EXISTING_DATA_DIR/db_v2.sqlite3" fi # Show migration summary echo "" echo -e "${WHITE}${BOLD}═══ Migration Summary ═══${NC}" echo "" if [ ${#EXISTING_CONTAINERS[@]} -gt 0 ]; then echo -e " ${CYAN}Containers found:${NC}" for c in "${EXISTING_CONTAINERS[@]}"; do echo " • $c" done fi if [ -n "$EXISTING_DATA_DIR" ]; then echo -e " ${CYAN}Data directory:${NC} $EXISTING_DATA_DIR" fi if [ -n "$EXISTING_COMPOSE_FILE" ]; then echo -e " ${CYAN}Compose file:${NC} $EXISTING_COMPOSE_FILE" fi echo "" echo -e " ${CYAN}Key files found:${NC}" local key_found=false if [ -n "$EXISTING_KEY_FILE" ]; then echo -e " ${GREEN}✓${NC} id_ed25519 (encryption key)" key_found=true else echo -e " ${RED}✗${NC} id_ed25519 (not found)" fi if [ -f "$EXISTING_DATA_DIR/id_ed25519.pub" ]; then echo -e " ${GREEN}✓${NC} id_ed25519.pub (public key)" else echo -e " ${YELLOW}!${NC} id_ed25519.pub (not found - will be regenerated)" fi if [ -n "$EXISTING_DB_FILE" ]; then local peer_count peer_count=$(sqlite3 "$EXISTING_DB_FILE" "SELECT COUNT(*) FROM peers;" 2>/dev/null || \ sqlite3 "$EXISTING_DB_FILE" "SELECT COUNT(*) FROM peer;" 2>/dev/null || echo "?") echo -e " ${GREEN}✓${NC} db_v2.sqlite3 (${peer_count} devices)" else echo -e " ${YELLOW}!${NC} db_v2.sqlite3 (not found - new DB will be created)" fi echo "" if [ "$key_found" = false ]; then print_warning "No encryption key found! Without the key, existing clients" print_warning "will need to be reconfigured. Continue anyway?" echo "" fi echo -e "${YELLOW}${BOLD}IMPORTANT:${NC} This will:" echo " 1. Create a backup of existing data" echo " 2. Stop existing RustDesk containers (if found)" echo " 3. Copy data to BetterDesk data directory" echo " 4. Build and start BetterDesk containers" echo " 5. Create a web admin account" echo "" echo -e "${CYAN}Your existing RustDesk data will NOT be deleted.${NC}" echo "" if ! confirm "Do you want to proceed with the migration?"; then press_enter return fi echo "" # === Step 1: Backup existing data === print_step "[1/6] Backing up existing data..." local migration_backup="$BACKUP_DIR/pre_migration_$(date +%Y%m%d_%H%M%S)" mkdir -p "$migration_backup" if [ -n "$EXISTING_DATA_DIR" ] && [ -d "$EXISTING_DATA_DIR" ]; then cp -r "$EXISTING_DATA_DIR"/* "$migration_backup/" 2>/dev/null || true print_success " Backup saved to: $migration_backup" fi if [ -n "$EXISTING_COMPOSE_FILE" ] && [ -f "$EXISTING_COMPOSE_FILE" ]; then cp "$EXISTING_COMPOSE_FILE" "$migration_backup/old_docker-compose.yml" 2>/dev/null || true print_info " Old compose file backed up" fi # === Step 2: Stop existing containers === print_step "[2/6] Stopping existing RustDesk containers..." if [ ${#EXISTING_CONTAINERS[@]} -gt 0 ]; then for c in "${EXISTING_CONTAINERS[@]}"; do docker stop "$c" 2>/dev/null && print_info " Stopped: $c" || true done else print_info " No containers to stop" fi # === Step 3: Prepare BetterDesk data directory === print_step "[3/6] Preparing BetterDesk data directory..." if [ -z "$DATA_DIR" ]; then DATA_DIR="/opt/betterdesk-data" fi # Create directories with proper permissions (handles SELinux) create_data_directory "$DATA_DIR" || { print_error "Failed to create data directory: $DATA_DIR" print_info "If you're on SELinux-enabled system (AlmaLinux, RHEL, CentOS):" print_info " sudo setenforce 0 # Temporarily disable" print_info " # Or: sudo chcon -Rt svirt_sandbox_file_t $DATA_DIR" return } create_data_directory "$BACKUP_DIR" || true # Copy key files if [ -n "$EXISTING_DATA_DIR" ] && [ "$EXISTING_DATA_DIR" != "$DATA_DIR" ]; then # Copy encryption keys (critical) for keyfile in id_ed25519 id_ed25519.pub; do if [ -f "$EXISTING_DATA_DIR/$keyfile" ]; then cp "$EXISTING_DATA_DIR/$keyfile" "$DATA_DIR/" print_success " Copied: $keyfile" fi done # Copy database if [ -f "$EXISTING_DATA_DIR/db_v2.sqlite3" ]; then cp "$EXISTING_DATA_DIR/db_v2.sqlite3" "$DATA_DIR/" print_success " Copied: db_v2.sqlite3" fi # Copy any other relevant files (.api_key etc.) for extra in .api_key; do if [ -f "$EXISTING_DATA_DIR/$extra" ]; then cp "$EXISTING_DATA_DIR/$extra" "$DATA_DIR/" print_info " Copied: $extra" fi done elif [ "$EXISTING_DATA_DIR" = "$DATA_DIR" ]; then print_info " Data already in target directory: $DATA_DIR" else print_warning " No source data to copy" fi # === Step 4: Create BetterDesk compose file === print_step "[4/6] Creating BetterDesk Docker Compose configuration..." if [ ! -f "$COMPOSE_FILE" ]; then create_compose_file else print_info " Compose file already exists: $COMPOSE_FILE" fi # === Step 5: Build and start === print_step "[5/6] Building BetterDesk Docker images..." build_images start_containers # === Step 6: Create admin user === print_step "[6/6] Setting up BetterDesk web console..." create_admin_user # === Migration complete === echo "" echo -e "${GREEN}╔══════════════════════════════════════════════════════════════════╗${NC}" echo -e "${GREEN}║ MIGRATION COMPLETED SUCCESSFULLY ║${NC}" echo -e "${GREEN}╠══════════════════════════════════════════════════════════════════╣${NC}" local server_ip server_ip=$(get_public_ip) echo -e "${GREEN}║ ║${NC}" echo -e "${GREEN}║ Web Panel: ${WHITE}http://$server_ip:5000${GREEN} ║${NC}" echo -e "${GREEN}║ Data Dir: ${WHITE}$DATA_DIR${GREEN} ║${NC}" echo -e "${GREEN}║ Backup: ${WHITE}$migration_backup${GREEN} ║${NC}" echo -e "${GREEN}║ ║${NC}" echo -e "${GREEN}╚══════════════════════════════════════════════════════════════════╝${NC}" echo "" if [ -n "$EXISTING_KEY_FILE" ]; then print_success "Encryption key preserved - existing clients will continue to work!" else print_warning "No key was migrated - existing clients may need reconfiguration." fi echo "" print_info "Your old data is preserved in: $migration_backup" print_info "Old containers are stopped but not removed." echo "" echo -e "${CYAN}To remove old containers later, run:${NC}" for c in "${EXISTING_CONTAINERS[@]}"; do echo " docker rm $c" done echo "" press_enter } #=============================================================================== # SQLite to PostgreSQL Migration #=============================================================================== do_migrate_postgresql() { print_header echo -e "${WHITE}${BOLD}══════════ SQLite → PostgreSQL MIGRATION ══════════${NC}" echo "" # Check if containers are running detect_installation if [ "$INSTALL_STATUS" = "none" ]; then print_error "BetterDesk Docker is not installed!" press_enter return fi # Check if SQLite database exists local sqlite_db="$DATA_DIR/db_v2.sqlite3" if [ ! -f "$sqlite_db" ]; then print_error "SQLite database not found: $sqlite_db" press_enter return fi print_info "Found SQLite database: $sqlite_db" # Get device count local device_count # Go server uses 'peers' table; legacy Rust uses 'peer' device_count=$(docker exec "$CONSOLE_CONTAINER" sqlite3 "$sqlite_db" "SELECT COUNT(*) FROM peers;" 2>/dev/null || \ docker exec "$CONSOLE_CONTAINER" sqlite3 "$sqlite_db" "SELECT COUNT(*) FROM peer;" 2>/dev/null || echo "0") print_info "Devices in database: $device_count" echo "" if ! confirm "Migrate to PostgreSQL? This will modify docker-compose.yml"; then return fi # Get PostgreSQL password echo "" read -p "PostgreSQL password for 'betterdesk' user [betterdesk123]: " pg_pass POSTGRESQL_PASS="${pg_pass:-betterdesk123}" # Backup current setup print_step "Creating backup..." do_backup_silent # Stop containers print_step "Stopping containers..." stop_containers # Set database type DB_TYPE="postgresql" # Recreate compose file with PostgreSQL create_compose_file # Build and start with PostgreSQL print_step "Starting containers with PostgreSQL..." build_images start_containers # Wait for PostgreSQL to be ready print_step "Waiting for PostgreSQL to be ready..." sleep 10 # Check if migration tool is available local migrate_tool="" local arch=$(uname -m) case "$arch" in x86_64) migrate_tool="./betterdesk-server/tools/migrate/migrate-linux-amd64" ;; aarch64|arm64) migrate_tool="./betterdesk-server/tools/migrate/migrate-linux-arm64" ;; esac if [ ! -f "$migrate_tool" ]; then print_warning "Migration tool not found: $migrate_tool" print_info "You can migrate manually using:" echo " $migrate_tool sqlite2pg --sqlite \"$sqlite_db\" --pg \"postgres://$POSTGRESQL_USER:$POSTGRESQL_PASS@localhost:5432/$POSTGRESQL_DB?sslmode=disable\"" echo "" print_info "PostgreSQL container is running. You can connect to it with:" echo " docker exec -it betterdesk-postgres psql -U $POSTGRESQL_USER -d $POSTGRESQL_DB" press_enter return fi # Run migration print_step "Migrating data from SQLite to PostgreSQL..." chmod +x "$migrate_tool" # PostgreSQL is inside Docker, so we need to use host network or port mapping # Default docker-compose doesn't expose PostgreSQL port, so we connect via Docker network # For migration, we need to temporarily expose PostgreSQL or copy data # Copy SQLite database to migrate tool location cp "$sqlite_db" "/tmp/betterdesk_migrate.sqlite3" # Since PostgreSQL is inside Docker, we need to connect via localhost:5432 if exposed # or use docker exec. Let's use docker exec approach: print_step "Creating PostgreSQL schema..." docker exec betterdesk-postgres psql -U "$POSTGRESQL_USER" -d "$POSTGRESQL_DB" << 'EOSQL' CREATE TABLE IF NOT EXISTS peers ( guid TEXT PRIMARY KEY, id TEXT UNIQUE NOT NULL, uuid TEXT, pk BYTEA, created_at TIMESTAMPTZ DEFAULT NOW(), last_online TIMESTAMPTZ, info TEXT, hostname TEXT, username TEXT, os TEXT, version TEXT, cpu TEXT, memory TEXT, is_banned BOOLEAN DEFAULT FALSE, ban_reason TEXT, banned_at TIMESTAMPTZ, banned_until TIMESTAMPTZ, deleted BOOLEAN DEFAULT FALSE ); CREATE TABLE IF NOT EXISTS users ( id BIGSERIAL PRIMARY KEY, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, role TEXT DEFAULT 'viewer', totp_secret TEXT, totp_enabled BOOLEAN DEFAULT FALSE, is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMPTZ DEFAULT NOW(), last_login TIMESTAMPTZ ); CREATE TABLE IF NOT EXISTS address_books ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL, ab_name TEXT NOT NULL, ab_data TEXT, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS api_keys ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, key_hash TEXT NOT NULL, permissions TEXT, created_at TIMESTAMPTZ DEFAULT NOW(), last_used TIMESTAMPTZ, is_active BOOLEAN DEFAULT TRUE ); CREATE TABLE IF NOT EXISTS server_config ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TIMESTAMPTZ DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS peer_tags ( peer_guid TEXT NOT NULL, tag TEXT NOT NULL, PRIMARY KEY (peer_guid, tag) ); CREATE TABLE IF NOT EXISTS id_history ( id BIGSERIAL PRIMARY KEY, peer_guid TEXT NOT NULL, old_id TEXT NOT NULL, new_id TEXT NOT NULL, changed_at TIMESTAMPTZ DEFAULT NOW(), changed_by TEXT ); CREATE TABLE IF NOT EXISTS audit_log ( id BIGSERIAL PRIMARY KEY, timestamp TIMESTAMPTZ DEFAULT NOW(), event_type TEXT NOT NULL, actor TEXT, target TEXT, details TEXT, ip_address TEXT ); CREATE INDEX IF NOT EXISTS idx_peers_id ON peers(id); CREATE INDEX IF NOT EXISTS idx_peers_last_online ON peers(last_online); CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp); EOSQL print_success "PostgreSQL schema created" # Export data from SQLite and import to PostgreSQL print_step "Migrating peer data..." # Extract data from SQLite (try 'peers' table first, fall back to 'peer' for legacy Rust) docker exec "$CONSOLE_CONTAINER" sqlite3 "/opt/rustdesk/db_v2.sqlite3" -csv \ "SELECT guid, id, uuid, pk, created_at, last_online, info, hostname, username, os, version, cpu, memory, is_banned, ban_reason, banned_at, banned_until, coalesce(deleted, 0) FROM peers;" \ > /tmp/peers_export.csv 2>/dev/null || \ docker exec "$CONSOLE_CONTAINER" sqlite3 "/opt/rustdesk/db_v2.sqlite3" -csv \ "SELECT guid, id, uuid, pk, created_at, last_online, info, hostname, username, os, version, cpu, memory, is_banned, ban_reason, banned_at, banned_until, coalesce(deleted, 0) FROM peer;" \ > /tmp/peers_export.csv 2>/dev/null || true if [ -f /tmp/peers_export.csv ] && [ -s /tmp/peers_export.csv ]; then # Copy to PostgreSQL container docker cp /tmp/peers_export.csv betterdesk-postgres:/tmp/peers_export.csv # Import with proper type conversion docker exec betterdesk-postgres psql -U "$POSTGRESQL_USER" -d "$POSTGRESQL_DB" << 'EOSQL' CREATE TEMP TABLE peers_import ( guid TEXT, id TEXT, uuid TEXT, pk TEXT, created_at TEXT, last_online TEXT, info TEXT, hostname TEXT, username TEXT, os TEXT, version TEXT, cpu TEXT, memory TEXT, is_banned TEXT, ban_reason TEXT, banned_at TEXT, banned_until TEXT, deleted TEXT ); \copy peers_import FROM '/tmp/peers_export.csv' WITH (FORMAT csv); INSERT INTO peers (guid, id, uuid, pk, created_at, last_online, info, hostname, username, os, version, cpu, memory, is_banned, ban_reason, banned_at, banned_until, deleted) SELECT guid, id, uuid, decode(pk, 'hex'), CASE WHEN created_at != '' THEN created_at::timestamptz ELSE NOW() END, CASE WHEN last_online != '' THEN last_online::timestamptz END, info, hostname, username, os, version, cpu, memory, CASE WHEN is_banned = '1' THEN TRUE ELSE FALSE END, ban_reason, CASE WHEN banned_at != '' THEN banned_at::timestamptz END, CASE WHEN banned_until != '' THEN banned_until::timestamptz END, CASE WHEN deleted = '1' THEN TRUE ELSE FALSE END FROM peers_import ON CONFLICT (id) DO NOTHING; DROP TABLE peers_import; EOSQL rm -f /tmp/peers_export.csv print_success "Peer data migrated" else print_warning "No peer data to migrate or export failed" fi # Migrate users if they exist print_step "Migrating users..." docker exec "$CONSOLE_CONTAINER" sqlite3 "/opt/rustdesk/db_v2.sqlite3" -csv \ "SELECT username, password_hash, role, coalesce(is_active, 1), created_at, last_login FROM users;" \ > /tmp/users_export.csv 2>/dev/null || true if [ -f /tmp/users_export.csv ] && [ -s /tmp/users_export.csv ]; then docker cp /tmp/users_export.csv betterdesk-postgres:/tmp/users_export.csv docker exec betterdesk-postgres psql -U "$POSTGRESQL_USER" -d "$POSTGRESQL_DB" << 'EOSQL' CREATE TEMP TABLE users_import ( username TEXT, password_hash TEXT, role TEXT, is_active TEXT, created_at TEXT, last_login TEXT ); \copy users_import FROM '/tmp/users_export.csv' WITH (FORMAT csv); INSERT INTO users (username, password_hash, role, is_active, created_at, last_login) SELECT username, password_hash, role, CASE WHEN is_active = '1' THEN TRUE ELSE FALSE END, CASE WHEN created_at != '' THEN created_at::timestamptz ELSE NOW() END, CASE WHEN last_login != '' THEN last_login::timestamptz END FROM users_import ON CONFLICT (username) DO NOTHING; DROP TABLE users_import; EOSQL rm -f /tmp/users_export.csv print_success "Users migrated" fi # Verify migration print_step "Verifying migration..." local pg_count pg_count=$(docker exec betterdesk-postgres psql -U "$POSTGRESQL_USER" -d "$POSTGRESQL_DB" -t -c "SELECT COUNT(*) FROM peers;" | tr -d ' ') echo "" echo -e "${GREEN}╔══════════════════════════════════════════════════════════════════╗${NC}" echo -e "${GREEN}║ SQLite → PostgreSQL MIGRATION COMPLETE ║${NC}" echo -e "${GREEN}╠══════════════════════════════════════════════════════════════════╣${NC}" echo -e "${GREEN}║ SQLite devices: ${WHITE}$device_count${GREEN} ║${NC}" echo -e "${GREEN}║ PostgreSQL devices: ${WHITE}$pg_count${GREEN} ║${NC}" echo -e "${GREEN}║ Database URL: ${WHITE}postgres://$POSTGRESQL_USER:***@postgres:5432/$POSTGRESQL_DB${GREEN} ║${NC}" echo -e "${GREEN}╚══════════════════════════════════════════════════════════════════╝${NC}" echo "" print_info "Containers have been reconfigured to use PostgreSQL" print_info "SQLite database preserved at: $sqlite_db" press_enter } #=============================================================================== # SSL/TLS Configuration #=============================================================================== do_configure_ssl() { detect_installation if [ "$INSTALL_STATUS" = "none" ]; then print_error "BetterDesk Docker is not installed!" print_info "Use 'Fresh Installation' first (option 1)" press_enter return fi local ssl_dir="$DATA_DIR/ssl" local env_file="$DATA_DIR/.env" local _menu_items=( $'Let'"'"'s Encrypt\tACME certificate with auto-renewal' $'Custom certificate\tProvide your own cert + key files' $'Self-signed certificate\tQuick HTTPS for testing' $'Disable SSL\tRevert the panel back to HTTP' $'Enterprise TLS\tFull HTTPS: panel + signal + relay' $'Back\tReturn to the main menu' ) local _menu_returns=( 1 2 3 4 5 0 ) menu_choose "SSL Certificate Configuration" "Enables HTTPS for the admin panel" local ssl_choice="${MENU_CHOICE:-3}" case "$ssl_choice" in 0) return ;; 1) print_warning "Let's Encrypt for Docker requires additional setup." print_info "Recommended: Use a reverse proxy (nginx/traefik) with Let's Encrypt." print_info "See: https://github.com/UNITRONIX/BetterDesk/wiki/TLS-SSL" press_enter return ;; 2) # Custom certificate echo "" read -p "Path to certificate file (PEM): " cert_path read -p "Path to private key file (PEM): " key_path read -p "Path to CA bundle (optional, press Enter to skip): " ca_path if [ ! -f "$cert_path" ]; then print_error "Certificate file not found: $cert_path" press_enter return fi if [ ! -f "$key_path" ]; then print_error "Key file not found: $key_path" press_enter return fi mkdir -p "$ssl_dir" cp "$cert_path" "$ssl_dir/betterdesk.crt" cp "$key_path" "$ssl_dir/betterdesk.key" [ -n "$ca_path" ] && [ -f "$ca_path" ] && cp "$ca_path" "$ssl_dir/ca.crt" chmod 600 "$ssl_dir/betterdesk.key" configure_docker_ssl "$ssl_dir" false print_success "Custom SSL certificate configured" ;; 3) # Self-signed with full SANs mkdir -p "$ssl_dir" echo "" read -p "Enter domain name (optional, press Enter to skip): " cert_domain # Detect IPs local server_ip server_ip=$(get_public_ip) local lan_ip lan_ip=$(ip -4 addr show scope global | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | head -1 2>/dev/null || \ hostname -I 2>/dev/null | awk '{print $1}' || echo "") # Build SAN list local san_list="IP:$server_ip,IP:127.0.0.1,DNS:localhost" [ -n "$lan_ip" ] && [ "$lan_ip" != "$server_ip" ] && san_list="$san_list,IP:$lan_ip" [ -n "$cert_domain" ] && san_list="DNS:$cert_domain,$san_list" local cn="${cert_domain:-$server_ip}" print_step "Generating self-signed certificate..." print_info "SANs: $san_list" openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ -keyout "$ssl_dir/betterdesk.key" \ -out "$ssl_dir/betterdesk.crt" \ -subj "/CN=$cn/O=BetterDesk/C=PL" \ -addext "subjectAltName=$san_list" 2>&1 || { # Fallback for older openssl openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ -keyout "$ssl_dir/betterdesk.key" \ -out "$ssl_dir/betterdesk.crt" \ -subj "/CN=$cn/O=BetterDesk/C=PL" 2>&1 } chmod 600 "$ssl_dir/betterdesk.key" chmod 644 "$ssl_dir/betterdesk.crt" configure_docker_ssl "$ssl_dir" false print_success "Self-signed certificate generated (valid 10 years)" print_info "Certificate: $ssl_dir/betterdesk.crt" [ -n "$lan_ip" ] && [ "$lan_ip" != "$server_ip" ] && print_info "LAN IP included: $lan_ip" print_warning "Browsers will show security warning. Use Let's Encrypt for public servers." ;; 4) # Disable SSL configure_docker_ssl "" disable print_success "SSL disabled. Running in HTTP mode." ;; 5) # Enterprise TLS - full HTTPS on ALL channels including API print_header echo -e "${YELLOW}${BOLD}══════════ ENTERPRISE TLS CONFIGURATION ══════════${NC}" echo "" print_warning "⚠️ IMPORTANT: Enterprise TLS enables HTTPS on ALL ports including API." print_warning " This requires RustDesk client >= 1.3.x for full compatibility." print_warning " Legacy clients may have connectivity issues." echo "" mkdir -p "$ssl_dir" read -p "Enter domain name (optional, press Enter to skip): " cert_domain # Detect IPs local server_ip server_ip=$(get_public_ip) local lan_ip lan_ip=$(ip -4 addr show scope global | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | head -1 2>/dev/null || \ hostname -I 2>/dev/null | awk '{print $1}' || echo "") # Build comprehensive SAN list local san_list="IP:$server_ip,IP:127.0.0.1,DNS:localhost" [ -n "$lan_ip" ] && [ "$lan_ip" != "$server_ip" ] && san_list="$san_list,IP:$lan_ip" [ -n "$cert_domain" ] && san_list="DNS:$cert_domain,$san_list" local cn="${cert_domain:-$server_ip}" print_step "Generating Enterprise certificate..." print_info "SANs: $san_list" openssl req -x509 -nodes -days 3650 -newkey rsa:4096 \ -keyout "$ssl_dir/betterdesk.key" \ -out "$ssl_dir/betterdesk.crt" \ -subj "/CN=$cn/O=BetterDesk Enterprise/C=PL" \ -addext "subjectAltName=$san_list" 2>&1 || { openssl req -x509 -nodes -days 3650 -newkey rsa:4096 \ -keyout "$ssl_dir/betterdesk.key" \ -out "$ssl_dir/betterdesk.crt" \ -subj "/CN=$cn/O=BetterDesk Enterprise/C=PL" 2>&1 } chmod 600 "$ssl_dir/betterdesk.key" chmod 644 "$ssl_dir/betterdesk.crt" configure_docker_ssl "$ssl_dir" enterprise print_success "Enterprise TLS configured successfully!" echo "" print_info "Certificate: $ssl_dir/betterdesk.crt" print_info "Valid: 10 years (RSA 4096-bit)" [ -n "$lan_ip" ] && [ "$lan_ip" != "$server_ip" ] && print_info "LAN IP: $lan_ip" echo "" print_warning "All connections now use TLS:" print_info " • Panel HTTPS: :5443" print_info " • Signal TLS: :21116" print_info " • Relay TLS: :21117" print_info " • API HTTPS: :21121" echo "" print_warning "For browsers/clients, you may need to import $ssl_dir/betterdesk.crt as trusted CA" ;; *) print_warning "Invalid option" press_enter return ;; esac echo "" if confirm "Restart Docker containers to apply SSL changes?"; then stop_containers start_containers print_success "Docker containers restarted with new SSL configuration" fi press_enter } # Helper function to configure SSL in docker-compose configure_docker_ssl() { local ssl_dir="$1" local mode="${2:-standard}" # standard, enterprise, disable if [ "$mode" = "disable" ]; then # Remove SSL configuration from docker-compose if [ -f "$COMPOSE_FILE" ]; then # Remove TLS flags from server service sed -i 's/ -tls-cert [^ ]*//g' "$COMPOSE_FILE" sed -i 's/ -tls-key [^ ]*//g' "$COMPOSE_FILE" sed -i 's/ -tls-signal//g' "$COMPOSE_FILE" sed -i 's/ -tls-relay//g' "$COMPOSE_FILE" sed -i 's/ -tls-api//g' "$COMPOSE_FILE" # Update console environment sed -i 's/HTTPS_ENABLED=true/HTTPS_ENABLED=false/' "$COMPOSE_FILE" sed -i '/SSL_CERT_PATH/d' "$COMPOSE_FILE" sed -i '/SSL_KEY_PATH/d' "$COMPOSE_FILE" sed -i '/ALLOW_SELF_SIGNED_CERTS/d' "$COMPOSE_FILE" sed -i '/ENTERPRISE_TLS/d' "$COMPOSE_FILE" fi return fi # For standard and enterprise modes, regenerate compose file with SSL settings # Store SSL configuration for compose file generation export SSL_ENABLED=true export SSL_DIR="$ssl_dir" if [ "$mode" = "enterprise" ]; then export ENTERPRISE_TLS=true else export ENTERPRISE_TLS=false fi # Regenerate docker-compose.yml with SSL settings create_compose_file } #=============================================================================== # Main Menu #=============================================================================== show_menu() { print_header print_status echo -e "${WHITE}${BOLD}══════════ MAIN MENU (Docker) ══════════${NC}" echo "" echo " 1. 🚀 Fresh Installation" echo " 2. ⬆️ Update" echo " 3. 🔧 Repair" echo " 4. ✅ Validation" echo " 5. 💾 Backup" echo " 6. 🔐 Reset admin password" echo " 7. 🔨 Build images" echo " 8. 📊 Diagnostics" echo " 9. 🗑️ UNINSTALL" echo "" echo " C. 🔒 Configure SSL/TLS" echo " M. 🔄 Migrate from existing RustDesk" echo " P. 🐘 Migrate SQLite → PostgreSQL" echo " S. ⚙️ Settings (paths)" echo " 0. ❌ Exit" echo "" } main() { # Check root for some operations if [ "$EUID" -ne 0 ]; then print_warning "Some operations may require root privileges (sudo)" fi # Check docker compose. Diagnostics can still return useful evidence without # Compose, but repair actions need it for safe stack restarts. if ! check_docker_compose; then if [ "$CLI_ACTION" = "diagnose" ]; then print_warning "Docker Compose is not available; diagnostics will be partial" COMPOSE_CMD="docker compose" else print_error "Docker Compose is not available!" exit 1 fi fi # Auto-detect paths on startup echo -e "${CYAN}Detecting installation...${NC}" auto_detect_docker_paths echo "" if [ "$NONINTERACTIVE" != true ]; then sleep 1 fi case "$CLI_ACTION" in rescue) do_safe_rescue exit $? ;; diagnose) print_rescue_diagnostics exit $? ;; repair-permissions) repair_docker_permissions exit $? ;; esac # Action tokens map 1:1 to the classic case dispatch below, so both the # arrow-key TUI and the numeric fallback share the exact same handlers. local menu_labels=( $'Fresh installation\tFull Docker install from scratch' $'Update\tUpdate an existing installation' $'Repair\tFix common problems' $'Validate\tCheck correctness' $'Backup\tCreate a backup' $'Reset admin password\tReset the console admin' $'Build images\tRebuild Docker images' $'Diagnostics\tDetailed problem analysis' $'Uninstall\tRemove BetterDesk' $'Configure SSL/TLS\tEnable HTTPS for the panel' $'Migrate from RustDesk\tImport an existing RustDesk deployment' $'SQLite -> PostgreSQL\tMigrate the database backend' $'Settings (paths)\tConfigure install paths' $'Exit\tQuit the manager' ) local menu_actions=( 1 2 3 4 5 6 7 8 9 C M P S 0 ) while true; do local choice="" if tui_available; then local status_line="Docker Compose manager v${VERSION}" [ -n "$DATA_DIR" ] && status_line="$status_line | data: ${DATA_DIR}" if tui_select "BetterDesk Console Manager (Docker)" "$status_line" "${menu_labels[@]}"; then choice="${menu_actions[$TUI_RESULT]}" else choice="0" fi else show_menu read -p "Select option: " choice fi case $choice in 1) do_install ;; 2) do_update ;; 3) do_repair ;; 4) do_validate ;; 5) do_backup ;; 6) do_reset_password ;; 7) do_build ;; 8) do_diagnostics ;; 9) do_uninstall ;; [Cc]) do_configure_ssl ;; [Mm]) do_migrate ;; [Pp]) do_migrate_postgresql ;; [Ss]) configure_docker_paths ;; 0) echo "" print_info "Goodbye!" exit 0 ;; *) print_warning "Invalid option" sleep 1 ;; esac done } # Run parse_cli_args "$@" main "$@"