#!/bin/sh # BetterDesk — All-in-One Container Entrypoint # Runs Go server + Node.js console via supervisord set -e # shellcheck source=/dev/null . /ensure-app-user.sh ensure_betterdesk_user echo "========================================" echo " BetterDesk All-in-One Container" echo " Version: ${BETTERDESK_IMAGE_VERSION:-3.5.92}" echo "========================================" echo "" echo "Components:" echo " Go Server: signal + relay + API (ports 21115-21121)" echo " Node.js Console: web panel (port ${PORT:-5000})" echo " Client API: port ${API_PORT:-21121}" echo "" echo "Configuration:" echo " NODE_ENV: ${NODE_ENV:-production}" echo " DB_TYPE: ${DB_TYPE:-sqlite}" echo " ENCRYPTED_ONLY: ${ENCRYPTED_ONLY:-1}" echo " DATA_DIR: ${DATA_DIR:-/app/data}" echo "" # Ensure data directories exist and have correct permissions mkdir -p /opt/rustdesk /app/data /var/log/betterdesk 2>/dev/null || true chown -R betterdesk:betterdesk /opt/rustdesk /app/data /var/log/betterdesk 2>/dev/null || true # Shared bootstrap password for Go + Node before supervisord (issue #385). # shellcheck source=/docker/bootstrap-admin-credentials.sh . /docker/bootstrap-admin-credentials.sh # shellcheck source=/docker/guard-sqlite-auth-split.sh . /docker/guard-sqlite-auth-split.sh guard_sqlite_auth_split # Write a file as betterdesk. Fresh named volumes inherit image ownership # (UID 10001 by default, or PUID after remap); with compose cap_drop:ALL root # has no CAP_DAC_OVERRIDE and cannot create files there (Permission denied on # .api_key, issue #299). write_as_betterdesk() { # usage: write_as_betterdesk _wad_path="$1" _wad_content="$2" if command -v su-exec >/dev/null 2>&1; then printf '%s\n' "$_wad_content" | su-exec betterdesk sh -c "umask 077; cat > \"$_wad_path\"" else printf '%s\n' "$_wad_content" | su -s /bin/sh betterdesk -c "umask 077; cat > \"$_wad_path\"" fi } touch_as_betterdesk() { if command -v su-exec >/dev/null 2>&1; then su-exec betterdesk touch "$1" else su -s /bin/sh betterdesk -c "touch \"$1\"" fi } # Bootstrap API key (shared between Go server and Node.js console) API_KEY_FILE="/opt/rustdesk/.api_key" if [ -z "${API_KEY:-}" ] && [ ! -f "$API_KEY_FILE" ]; then if command -v openssl >/dev/null 2>&1; then API_KEY=$(openssl rand -hex 32) else API_KEY=$(cat /dev/urandom | head -c 32 | od -An -tx1 | tr -d ' \n') fi write_as_betterdesk "$API_KEY_FILE" "$API_KEY" echo "Auto-generated API key → $API_KEY_FILE" elif [ -n "${API_KEY:-}" ] && [ ! -f "$API_KEY_FILE" ]; then write_as_betterdesk "$API_KEY_FILE" "$API_KEY" echo "API key from env → $API_KEY_FILE" fi # Default enrollment policy. # Fresh volumes default to "managed": stock RustDesk clients are queued for # operator approval instead of connecting silently. Pre-existing installs keep # their current behavior (Go default "open", or whatever was set via the panel # and persisted in the database). A volume that already contains a server key # or SQLite database is treated as pre-existing. if [ -z "${ENROLLMENT_MODE:-}" ]; then ENROLLMENT_SENTINEL="/opt/rustdesk/.enrollment_initialized" if [ ! -f "$ENROLLMENT_SENTINEL" ]; then if [ -f /opt/rustdesk/db_v2.sqlite3 ] || [ -f /opt/rustdesk/id_ed25519 ]; then echo "Enrollment: preserving existing policy (pre-existing volume)" else export ENROLLMENT_MODE="managed" echo "Enrollment: managed (fresh install — new devices need approval)" fi touch_as_betterdesk "$ENROLLMENT_SENTINEL" 2>/dev/null || true fi fi # Always export so supervisord's %(ENV_ENROLLMENT_MODE)s interpolation resolves. # An empty value is ignored by the Go server (keeps default/DB-restored mode). export ENROLLMENT_MODE="${ENROLLMENT_MODE:-}" # Verify write access — SQLite WAL mode requires writable directory (Issue #78) _app_uid="${PUID:-10001}" _app_gid="${PGID:-10001}" if ! touch_as_betterdesk /opt/rustdesk/.write_test 2>/dev/null; then echo "" echo "ERROR: /opt/rustdesk is NOT writable by the betterdesk user (UID ${_app_uid})." echo " SQLite WAL mode requires write access to the database directory." echo " If using bind mounts, either set PUID/PGID to the host owner, or run:" echo " chown -R ${_app_uid}:${_app_gid} /path/to/your/data" echo " Or use Docker named volumes instead of bind mounts." echo "" fi rm -f /opt/rustdesk/.write_test 2>/dev/null || true # Fix private key permissions (volume mounts may preserve wrong UID/mode) if [ -f /opt/rustdesk/id_ed25519 ]; then chmod 600 /opt/rustdesk/id_ed25519 2>/dev/null || true chown betterdesk:betterdesk /opt/rustdesk/id_ed25519 2>/dev/null || true fi # BD-2026-007: Warn about weak default secrets if [ -n "${SESSION_SECRET}" ] && [ ${#SESSION_SECRET} -lt 32 ]; then echo "WARNING [SECURITY]: SESSION_SECRET is shorter than 32 characters — generate a stronger secret" fi if [ -n "${ADMIN_PASSWORD}" ] && [ ${#ADMIN_PASSWORD} -lt 12 ]; then echo "WARNING [SECURITY]: ADMIN_PASSWORD is shorter than 12 characters — use a stronger password" fi # Determine database DSN for Go server # DB_URL env var is read by Go server's config.LoadEnv() if [ "${DB_TYPE}" = "postgres" ] || [ "${DB_TYPE}" = "postgresql" ]; then if [ -n "${DATABASE_URL}" ]; then export DB_URL="${DATABASE_URL}" echo "Database: PostgreSQL (${DATABASE_URL%%@*}@***)" else echo "WARNING: DB_TYPE=postgres but DATABASE_URL not set — falling back to SQLite" export DB_URL="/opt/rustdesk/db_v2.sqlite3" fi else export DB_URL="${DB_PATH:-/opt/rustdesk/db_v2.sqlite3}" export AUTH_DB_PATH="${AUTH_DB_PATH:-/app/data/auth.db}" echo "Database: SQLite (${DB_URL})" echo "Legacy panel store: ${AUTH_DB_PATH}" fi # SQLite identity-store consolidation is explicit and runs before either # service starts. It produces candidate/backup snapshots and aborts the # container start on conflict instead of ever creating or overwriting auth.db. if [ "${MIGRATE_SQLITE_AUTH_DB:-N}" = "Y" ] && [ "${DB_TYPE}" != "postgres" ] && [ "${DB_TYPE}" != "postgresql" ]; then if [ ! -f "${AUTH_DB_PATH}" ]; then echo "ERROR: MIGRATE_SQLITE_AUTH_DB=Y but legacy auth.db is missing: ${AUTH_DB_PATH}" exit 1 fi if [ "${MIGRATE_SQLITE_AUTH_DRY_RUN:-N}" = "Y" ]; then echo "Validating legacy SQLite auth migration..." /usr/local/bin/betterdesk-server -migrate-sqlite-auth -migrate-sqlite-auth-dry-run \ -db "${DB_URL}" -migrate-sqlite-auth-backup-dir /app/data/backups else echo "Consolidating legacy auth.db into ${DB_URL}..." /usr/local/bin/betterdesk-server -migrate-sqlite-auth \ -db "${DB_URL}" -migrate-sqlite-auth-backup-dir /app/data/backups fi if [ "${MIGRATE_SQLITE_AUTH_DRY_RUN:-N}" != "Y" ]; then export SQLITE_AUTH_DB_MODE=consolidated fi fi # Wait for PostgreSQL if configured if [ "${DB_TYPE}" = "postgres" ] || [ "${DB_TYPE}" = "postgresql" ]; then if [ -n "${DATABASE_URL}" ]; then echo "Waiting for PostgreSQL..." PG_HOST=$(echo "$DATABASE_URL" | sed -n 's|.*@\([^:/]*\).*|\1|p') PG_PORT=$(echo "$DATABASE_URL" | sed -n 's|.*:\([0-9]*\)/.*|\1|p') PG_PORT=${PG_PORT:-5432} RETRIES=0 MAX_RETRIES=30 while [ "$RETRIES" -lt "$MAX_RETRIES" ]; do if nc -z "$PG_HOST" "$PG_PORT" 2>/dev/null; then echo " PostgreSQL is ready ($PG_HOST:$PG_PORT)" break fi RETRIES=$((RETRIES + 1)) echo " Waiting... ($RETRIES/$MAX_RETRIES)" sleep 2 done if [ "$RETRIES" -ge "$MAX_RETRIES" ]; then echo " WARNING: PostgreSQL not reachable — starting anyway" fi fi fi # Ensure Go server uses correct signal port (not NODE.js PORT) export SIGNAL_PORT="${SIGNAL_PORT:-21116}" export SIGNAL_RATE_LIMIT_PER_IP="${SIGNAL_RATE_LIMIT_PER_IP:-20}" # Ensure Node.js Client API binds to all interfaces (not just localhost) export API_HOST="${API_HOST:-0.0.0.0}" export HOST="${HOST:-0.0.0.0}" # Relay server address: if not explicitly set, try to auto-detect public IP. # Inside Docker, the Go server's own detection may return the container's # internal IP (172.x.x.x) which is unreachable by remote clients. if [ -z "${RELAY_SERVERS:-}" ]; then DETECTED_IP="" if command -v curl >/dev/null 2>&1; then DETECTED_IP=$(curl -4 -sf --max-time 5 https://checkip.amazonaws.com 2>/dev/null \ || curl -4 -sf --max-time 5 https://api.ipify.org 2>/dev/null \ || curl -4 -sf --max-time 5 https://ifconfig.me/ip 2>/dev/null || true) DETECTED_IP=$(echo "$DETECTED_IP" | tr -d '[:space:]') fi if [ -n "$DETECTED_IP" ]; then # Verify it's not a private/Docker IP case "$DETECTED_IP" in 10.*|172.1[6-9].*|172.2[0-9].*|172.3[0-1].*|192.168.*|127.*) echo "WARNING: Auto-detected IP ($DETECTED_IP) is private — relay may fail for remote clients." echo " Set RELAY_SERVERS=YOUR.PUBLIC.IP in docker-compose.single.yml" ;; *) export RELAY_SERVERS="$DETECTED_IP" echo "Relay IP: $DETECTED_IP (auto-detected)" ;; esac else echo "WARNING: Could not auto-detect public IP for relay." echo " Set RELAY_SERVERS=YOUR.PUBLIC.IP in docker-compose.single.yml" fi else echo "Relay IP: $RELAY_SERVERS (from env)" fi export RELAY_SERVERS="${RELAY_SERVERS:-}" # Always export so supervisord's %(ENV_*)s interpolation resolves (#299). # Compose usually sets these; Portainer / bare docker run may omit them. export NTP_SERVERS="${NTP_SERVERS:-pool.ntp.org,time.google.com,time.cloudflare.com}" export BILLING_MAX_CLOCK_SKEW_MS="${BILLING_MAX_CLOCK_SKEW_MS:-2000}" export BILLING_REQUIRE_SYNCED_CLOCK="${BILLING_REQUIRE_SYNCED_CLOCK:-1}" export BILLING_TRUST_OS_NTP="${BILLING_TRUST_OS_NTP:-Y}" export SQLITE_AUTH_DB_MODE="${SQLITE_AUTH_DB_MODE:-}" echo "" echo "Starting services via supervisord..." if [ "${HTTPS_ENABLED:-false}" = "true" ]; then echo " Web Console: https://localhost:${HTTPS_PORT:-5443}" echo " HTTP redirect: http://localhost:${PORT:-5000} → https://…:${HTTPS_PORT:-5443}" else echo " Web Console: http://localhost:${PORT:-5000}" fi echo " Go API: http://localhost:21121/api" echo "========================================" echo "" # Start supervisord (manages both processes) exec /usr/bin/supervisord -c /etc/supervisor/conf.d/betterdesk.conf