diff --git a/CHANGELOG.md b/CHANGELOG.md index 2866bf01..2d6dc1fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ ## [Unreleased] ### Changed -- _(none yet)_ +- **Docker admin login bootstrap race (#385):** Split Docker entrypoints now atomically create and share one bootstrap password, including when `.admin_credentials` is mode `0600`. Fresh SQLite authentication remains centralized in `db_v2.sqlite3`; legacy `auth.db` is no longer treated as the default panel store. The Docker startup wait now only applies to explicitly selected legacy mode. --- diff --git a/betterdesk-server/main.go b/betterdesk-server/main.go index 73bb0a3a..039250f7 100644 --- a/betterdesk-server/main.go +++ b/betterdesk-server/main.go @@ -239,8 +239,17 @@ func main() { adminUser = "admin" } adminPass := cfg.InitAdminPass + adminPasswordGenerated := false + adminPasswordFromFile := false if adminPass == "" { - adminPass, _ = auth.GenerateRandomString(16) + dbDir := filepath.Dir(cfg.DBPath) + if existingPass := readBootstrapAdminPassword(dbDir); existingPass != "" { + adminPass = existingPass + adminPasswordFromFile = true + } else { + adminPass, _ = auth.GenerateRandomString(16) + adminPasswordGenerated = true + } } hash, err := auth.HashPassword(adminPass) if err != nil { @@ -257,13 +266,15 @@ func main() { log.Printf("========================================") log.Printf(" INITIAL ADMIN CREDENTIALS") log.Printf(" Username: %s", adminUser) - if cfg.InitAdminPass == "" { + if adminPasswordGenerated { dbDir := filepath.Dir(cfg.DBPath) credsFile, err := writeBootstrapAdminCredentials(dbDir, adminUser, adminPass) if err != nil { log.Fatalf("Failed to write credentials file: %v", err) } log.Printf(" Password: written to %s (mode 0600)", credsFile) + } else if adminPasswordFromFile { + log.Printf(" Password: loaded from existing bootstrap credentials file") } else { log.Printf(" Password: *** (user-provided, not logged)") } @@ -597,12 +608,44 @@ func writeBootstrapAdminCredentials(dbDir, adminUser, adminPass string) (string, "Admin Username: %s\nAdmin Password: %s\n\nChange this password immediately and delete this file!\n", adminUser, adminPass, ) - if err := os.WriteFile(credsFile, []byte(credsContent), 0600); err != nil { + file, err := os.OpenFile(credsFile, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + if os.IsExist(err) { + return credsFile, nil + } + return "", err + } + if _, err := file.WriteString(credsContent); err != nil { + _ = file.Close() + _ = os.Remove(credsFile) + return "", err + } + if err := file.Close(); err != nil { + _ = os.Remove(credsFile) return "", err } return credsFile, nil } +func readBootstrapAdminPassword(dbDir string) string { + if dbDir == "" || dbDir == "." { + dbDir = "." + } + contents, err := os.ReadFile(filepath.Join(dbDir, ".admin_credentials")) + if err != nil { + return "" + } + for _, line := range strings.Split(string(contents), "\n") { + if strings.HasPrefix(line, "Admin Password:") { + password := strings.TrimSpace(strings.TrimPrefix(line, "Admin Password:")) + if password != "" { + return password + } + } + } + return "" +} + func syncAPIKeyToServerConfig(database db.Database, apiKey string) (string, error) { existing, _ := database.GetConfig("api_key") if existing == apiKey { diff --git a/betterdesk-server/main_test.go b/betterdesk-server/main_test.go new file mode 100644 index 00000000..187c71f2 --- /dev/null +++ b/betterdesk-server/main_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestWriteBootstrapAdminCredentialsDoesNotOverwriteExistingFile(t *testing.T) { + dir := t.TempDir() + credentialsPath := filepath.Join(dir, ".admin_credentials") + original := []byte("Admin Username: admin\nAdmin Password: original-password\n") + if err := os.WriteFile(credentialsPath, original, 0600); err != nil { + t.Fatal(err) + } + + gotPath, err := writeBootstrapAdminCredentials(dir, "admin", "replacement-password") + if err != nil { + t.Fatal(err) + } + if gotPath != credentialsPath { + t.Fatalf("credentials path = %q, want %q", gotPath, credentialsPath) + } + + got, err := os.ReadFile(credentialsPath) + if err != nil { + t.Fatal(err) + } + if string(got) != string(original) { + t.Fatalf("existing credentials were overwritten: %q", got) + } +} + +func TestReadBootstrapAdminPassword(t *testing.T) { + dir := t.TempDir() + credentialsPath := filepath.Join(dir, ".admin_credentials") + if err := os.WriteFile(credentialsPath, + []byte("Admin Username: admin\nAdmin Password: shared-password\n"), 0600); err != nil { + t.Fatal(err) + } + + if got := readBootstrapAdminPassword(dir); got != "shared-password" { + t.Fatalf("password = %q, want %q", got, "shared-password") + } +} diff --git a/docker-compose.quick.single.yml b/docker-compose.quick.single.yml index 8b0e3622..7c14ac4b 100644 --- a/docker-compose.quick.single.yml +++ b/docker-compose.quick.single.yml @@ -82,6 +82,7 @@ services: - DB_TYPE=${DB_TYPE:-sqlite} - DATABASE_URL=${DATABASE_URL:-} - DB_URL=${DATABASE_URL:-} + - SQLITE_AUTH_DB_MODE=${SQLITE_AUTH_DB_MODE:-} # Set this when clients are outside the Docker network. Use the host's # public IP/DNS, or the host LAN IP for LAN-only deployments. # Example: RELAY_SERVERS=203.0.113.10:21117 docker compose up -d diff --git a/docker-compose.quick.yml b/docker-compose.quick.yml index 53be3e7a..18f9e167 100644 --- a/docker-compose.quick.yml +++ b/docker-compose.quick.yml @@ -20,8 +20,9 @@ # # Web Console: http://localhost:5000 # RustDesk client API: http://localhost:21114 (Go server — not the console port) -# SQLite: console DB_PATH shares Go peer DB at /opt/rustdesk/db_v2.sqlite3; -# server mounts console auth.db read-only for folder/group sync (issue #138). +# SQLite: both services share the primary database at +# /opt/rustdesk/db_v2.sqlite3. Legacy auth.db is optional and only used for +# unmigrated deployments. # Default credentials are written to the shared credentials file: # docker compose exec console betterdesk-show-admin-credentials # @@ -61,6 +62,7 @@ services: # Admin credentials (first run only; existing users are not overwritten). - INIT_ADMIN_USER=${ADMIN_USERNAME:-admin} - INIT_ADMIN_PASS=${ADMIN_PASSWORD:-} + - SQLITE_AUTH_DB_MODE=${SQLITE_AUTH_DB_MODE:-} # Set this when clients are outside the Docker network. Use the host's # public IP/DNS, or the host LAN IP for LAN-only deployments. # Example: RELAY_SERVERS=203.2.1413.10:21117 docker compose up -d @@ -124,6 +126,7 @@ services: # Admin credentials (first run only; existing users are not overwritten). - DEFAULT_ADMIN_USERNAME=${ADMIN_USERNAME:-admin} - DEFAULT_ADMIN_PASSWORD=${ADMIN_PASSWORD:-} + - SQLITE_AUTH_DB_MODE=${SQLITE_AUTH_DB_MODE:-} - PUB_KEY_PATH=/opt/rustdesk/id_ed25519.pub - API_KEY_PATH=/opt/rustdesk/.api_key - WS_HBBS_HOST=betterdesk-server diff --git a/docker-compose.single.yml b/docker-compose.single.yml index 87d38999..ea54523a 100644 --- a/docker-compose.single.yml +++ b/docker-compose.single.yml @@ -67,6 +67,7 @@ services: - DB_TYPE=${DB_TYPE:-sqlite} - DATABASE_URL=${DATABASE_URL:-} - DB_URL=${DATABASE_URL:-} + - SQLITE_AUTH_DB_MODE=${SQLITE_AUTH_DB_MODE:-} # Relay server address (public IP or domain). # Auto-detected if not set. MUST be set if auto-detection returns # Docker internal IP (172.x.x.x) — remote relay connections will fail otherwise. diff --git a/docker-compose.yml b/docker-compose.yml index b82d8983..2d8a3b6b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,6 +40,7 @@ services: - ENCRYPTED_ONLY=1 - DB_URL=${DB_URL:-/opt/rustdesk/db_v2.sqlite3} - AUTH_DB_PATH=/app/data/auth.db + - SQLITE_AUTH_DB_MODE=${SQLITE_AUTH_DB_MODE:-} - SIGNAL_RATE_LIMIT_PER_IP=${SIGNAL_RATE_LIMIT_PER_IP:-20} - P2P_FIRST=${P2P_FIRST:-Y} - ALWAYS_USE_RELAY=${ALWAYS_USE_RELAY:-N} @@ -91,7 +92,7 @@ services: - "21121:21121" # Backward compat proxy → Go :21114 volumes: - rustdesk-data:/opt/rustdesk # Shared server data (keys, db) — needs write for WAL mode - - console-data:/app/data # Console-specific data (auth.db, sessions) + - console-data:/app/data # Console sessions and optional legacy auth.db environment: - NODE_ENV=production - PORT=5000 @@ -107,6 +108,7 @@ services: - RUSTDESK_PATH=/opt/rustdesk - DATA_DIR=/app/data - DB_PATH=/opt/rustdesk/db_v2.sqlite3 + - SQLITE_AUTH_DB_MODE=${SQLITE_AUTH_DB_MODE:-} - PUB_KEY_PATH=/opt/rustdesk/id_ed25519.pub - API_KEY_PATH=/opt/rustdesk/.api_key - SESSION_SECRET=${SESSION_SECRET:-} diff --git a/docker/bootstrap-admin-credentials.sh b/docker/bootstrap-admin-credentials.sh index 201fa96d..54465e50 100644 --- a/docker/bootstrap-admin-credentials.sh +++ b/docker/bootstrap-admin-credentials.sh @@ -2,10 +2,13 @@ # Pre-bootstrap shared admin credentials for Docker (issue #385). # Go server and Node.js console must use the same password on first start. # Writes /opt/rustdesk/.admin_credentials and exports INIT_ADMIN_* / DEFAULT_ADMIN_*. +# Split images source this script concurrently, so creation is serialized on +# the shared volume. set -e CREDS_DIR="${RUSTDESK_PATH:-/opt/rustdesk}" CREDS_FILE="${CREDS_DIR}/.admin_credentials" +LOCK_DIR="${CREDS_FILE}.lock" ADMIN_USER="${INIT_ADMIN_USER:-${DEFAULT_ADMIN_USERNAME:-${ADMIN_USERNAME:-admin}}}" # Map public ADMIN_* aliases to internal seed vars (same as entrypoints). @@ -28,18 +31,31 @@ sync_exports() { export INIT_ADMIN_USER="${INIT_ADMIN_USER:-$ADMIN_USER}" export DEFAULT_ADMIN_USERNAME="${DEFAULT_ADMIN_USERNAME:-$ADMIN_USER}" if [ -n "${INIT_ADMIN_PASS:-}" ]; then - export DEFAULT_ADMIN_PASSWORD="${DEFAULT_ADMIN_PASSWORD:-$INIT_ADMIN_PASS}" + # INIT_ADMIN_PASS and DEFAULT_ADMIN_PASSWORD feed different + # processes in the split image. Never allow two configured values. + export DEFAULT_ADMIN_PASSWORD="$INIT_ADMIN_PASS" elif [ -n "${DEFAULT_ADMIN_PASSWORD:-}" ]; then export INIT_ADMIN_PASS="${INIT_ADMIN_PASS:-$DEFAULT_ADMIN_PASSWORD}" fi } +run_as_betterdesk() { + if [ "$(id -u)" = "0" ] && command -v su-exec >/dev/null 2>&1; then + su-exec betterdesk "$@" + else + "$@" + fi +} + parse_creds_password() { _file="$1" - if [ ! -f "$_file" ] || [ ! -r "$_file" ]; then + if [ ! -f "$_file" ]; then return 1 fi - _pass=$(grep -m1 '^Admin Password:' "$_file" 2>/dev/null | sed 's/^Admin Password:[[:space:]]*//') + # The file is intentionally mode 0600 and owned by betterdesk. Root in + # hardened containers has no CAP_DAC_OVERRIDE, so read it as the app user. + _line=$(run_as_betterdesk grep -m1 '^Admin Password:' "$_file" 2>/dev/null || true) + _pass=$(printf '%s\n' "$_line" | sed 's/^Admin Password:[[:space:]]*//') if [ -n "$_pass" ]; then printf '%s\n' "$_pass" return 0 @@ -50,54 +66,145 @@ parse_creds_password() { write_as_betterdesk() { _path="$1" _content="$2" - if command -v su-exec >/dev/null 2>&1; then + if [ "$(id -u)" = "0" ] && command -v su-exec >/dev/null 2>&1; then printf '%s' "$_content" | su-exec betterdesk sh -c "umask 077; cat > \"$_path\"" else - printf '%s' "$_content" | su -s /bin/sh betterdesk -c "umask 077; cat > \"$_path\"" + umask 077 + printf '%s' "$_content" > "$_path" fi } +move_as_betterdesk() { + run_as_betterdesk mv "$1" "$2" +} + +export_bootstrap_password() { + _pass="$1" + export INIT_ADMIN_PASS="$_pass" + export DEFAULT_ADMIN_PASSWORD="$_pass" + sync_exports +} + +read_existing_credentials() { + _existing_pass=$(parse_creds_password "$CREDS_FILE" || true) + if [ -n "$_existing_pass" ]; then + export_bootstrap_password "$_existing_pass" + return 0 + fi + return 1 +} + +primary_database_has_users() { + _primary_db="${DB_PATH:-${DB_URL:-${CREDS_DIR}/db_v2.sqlite3}}" + case "$_primary_db" in + postgres://*|postgresql://*) return 1 ;; + esac + if [ ! -f "$_primary_db" ] || ! command -v sqlite3 >/dev/null 2>&1; then + return 1 + fi + _user_count=$(run_as_betterdesk sqlite3 "$_primary_db" \ + "SELECT COUNT(*) FROM users;" 2>/dev/null || true) + case "$_user_count" in + ''|*[!0-9]*) return 1 ;; + esac + [ "$_user_count" -gt 0 ] +} + +cleanup_bootstrap_lock() { + run_as_betterdesk rmdir "$LOCK_DIR" 2>/dev/null || true +} + +wait_for_bootstrap_credentials() { + _waited=0 + _max_wait="${BOOTSTRAP_CREDENTIALS_WAIT_SECONDS:-120}" + while [ "$_waited" -lt "$_max_wait" ]; do + if read_existing_credentials; then + return 0 + fi + if [ ! -d "$LOCK_DIR" ]; then + return 1 + fi + sleep 1 + _waited=$((_waited + 1)) + done + return 1 +} + # Already configured via env — keep Go and Node in sync. if [ -n "${INIT_ADMIN_PASS:-}" ] || [ -n "${DEFAULT_ADMIN_PASSWORD:-}" ]; then sync_exports return 0 2>/dev/null || exit 0 fi -# Reuse existing credentials file on shared volume. -_existing_pass=$(parse_creds_password "$CREDS_FILE" || true) -if [ -z "$_existing_pass" ]; then - _existing_pass=$(parse_creds_password "${DATA_DIR:-/app/data}/.admin_credentials" || true) -fi -if [ -n "$_existing_pass" ]; then - export INIT_ADMIN_PASS="$_existing_pass" - export DEFAULT_ADMIN_PASSWORD="$_existing_pass" - sync_exports +run_as_betterdesk mkdir -p "$CREDS_DIR" 2>/dev/null || true + +# Reuse an existing shared credential before trying to acquire the creation +# lock. The second check after mkdir closes the check-then-create race. +if read_existing_credentials; then return 0 2>/dev/null || exit 0 fi -# Fresh install: generate once before either service starts. -mkdir -p "$CREDS_DIR" 2>/dev/null || true -if command -v openssl >/dev/null 2>&1; then - _new_pass=$(openssl rand -hex 16) -else - _new_pass=$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n') +# A missing credentials file on an existing SQLite installation must not +# create a misleading replacement password. Existing users keep their hash; +# recovery must use the normal password-reset flow. +if primary_database_has_users; then + echo "WARN: ${CREDS_FILE} is missing, but the primary database already has users." >&2 + echo " No replacement bootstrap password was generated; use password reset." >&2 + return 0 2>/dev/null || exit 0 fi -_creds_content="Admin Username: ${ADMIN_USER} +if run_as_betterdesk mkdir "$LOCK_DIR" 2>/dev/null; then + trap cleanup_bootstrap_lock EXIT HUP INT TERM + + if read_existing_credentials; then + cleanup_bootstrap_lock + trap - EXIT HUP INT TERM + return 0 2>/dev/null || exit 0 + fi + if primary_database_has_users; then + cleanup_bootstrap_lock + trap - EXIT HUP INT TERM + echo "WARN: ${CREDS_FILE} is missing, but the primary database already has users." >&2 + echo " No replacement bootstrap password was generated; use password reset." >&2 + return 0 2>/dev/null || exit 0 + fi + if [ -e "$CREDS_FILE" ]; then + cleanup_bootstrap_lock + trap - EXIT HUP INT TERM + echo "ERROR: ${CREDS_FILE} exists but does not contain a readable admin password." >&2 + exit 1 + fi + + # Fresh install: the lock owner generates exactly one password and + # publishes it with an atomic rename. Other containers wait above. + if command -v openssl >/dev/null 2>&1; then + _new_pass=$(openssl rand -hex 16) + else + _new_pass=$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n') + fi + + _creds_content="Admin Username: ${ADMIN_USER} Admin Password: ${_new_pass} Generated by: BetterDesk Docker bootstrap Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ) " + _tmp_file="${CREDS_FILE}.tmp.$$" + write_as_betterdesk "$_tmp_file" "$_creds_content" + move_as_betterdesk "$_tmp_file" "$CREDS_FILE" -if [ "$(id -u)" = "0" ]; then - write_as_betterdesk "$CREDS_FILE" "$_creds_content" -else - umask 077 - printf '%s' "$_creds_content" > "$CREDS_FILE" + export_bootstrap_password "$_new_pass" + cleanup_bootstrap_lock + trap - EXIT HUP INT TERM + echo "Bootstrap admin credentials → ${CREDS_FILE}" + return 0 2>/dev/null || exit 0 fi -export INIT_ADMIN_PASS="$_new_pass" -export DEFAULT_ADMIN_PASSWORD="$_new_pass" -sync_exports +# Another split container owns the lock. Generating a fallback here would +# recreate issue #385, so wait for its atomic publication. +if wait_for_bootstrap_credentials; then + return 0 2>/dev/null || exit 0 +fi -echo "Bootstrap admin credentials → ${CREDS_FILE}" +echo "ERROR: timed out waiting for shared admin credentials at ${CREDS_FILE}" >&2 +echo " Remove the stale ${LOCK_DIR} only after confirming no BetterDesk container is bootstrapping." >&2 +exit 1 diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 335f87fc..4a2db855 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -227,6 +227,7 @@ export NTP_SERVERS="${NTP_SERVERS:-pool.ntp.org,time.google.com,time.cloudflare. 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..." diff --git a/docker/server-entrypoint.sh b/docker/server-entrypoint.sh index c7a9df1b..d0e26614 100644 --- a/docker/server-entrypoint.sh +++ b/docker/server-entrypoint.sh @@ -22,8 +22,9 @@ fi # shellcheck source=/docker/bootstrap-admin-credentials.sh . /docker/bootstrap-admin-credentials.sh -# SQLite Docker: wait for the console to create auth.db (folders/groups ACL). -# Skipped for PostgreSQL — panel sync uses the shared DATABASE_URL instead. +# SQLite Docker: legacy auth.db is optional. Fresh installs keep panel +# identities in the primary db_v2.sqlite3; only explicitly legacy deployments +# need to wait for a separate auth.db. panel_auth_db_ready() { case "${DB_URL:-}" in postgres://*|postgresql://*) return 0 ;; @@ -32,6 +33,10 @@ panel_auth_db_ready() { if [ -z "$auth_path" ]; then return 0 fi + if [ ! -f "$auth_path" ] && [ "${SQLITE_AUTH_DB_MODE:-}" != "legacy" ]; then + echo "Panel auth.db not present — using the primary SQLite database" + return 0 + fi if [ -f "$auth_path" ]; then echo "Panel auth.db ready: $auth_path" return 0 diff --git a/docker/supervisord.conf b/docker/supervisord.conf index 272ca4bd..5be2b5c4 100644 --- a/docker/supervisord.conf +++ b/docker/supervisord.conf @@ -21,7 +21,8 @@ supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface serverurl=unix:///var/run/supervisor.sock ; ---- BetterDesk Node.js Console ---- -; Start before Go so auth.db (folders/groups) exists for panel sync (issue #138). +; Start before Go so optional legacy panel sync can initialize (issue #138). +; Fresh deployments use the consolidated primary database. ; Web console (5000). The RustDesk client API is served by the Go server (21121), ; so the console's own client API listener is disabled (API_ENABLED=false). [program:betterdesk-console] @@ -39,7 +40,7 @@ stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 ; Console serves the admin panel only; the Go server owns the client API. -environment=API_ENABLED="false",HOST="0.0.0.0",DEFAULT_ADMIN_USERNAME="%(ENV_DEFAULT_ADMIN_USERNAME)s",DEFAULT_ADMIN_PASSWORD="%(ENV_DEFAULT_ADMIN_PASSWORD)s" +environment=API_ENABLED="false",HOST="0.0.0.0",DEFAULT_ADMIN_USERNAME="%(ENV_DEFAULT_ADMIN_USERNAME)s",DEFAULT_ADMIN_PASSWORD="%(ENV_DEFAULT_ADMIN_PASSWORD)s",SQLITE_AUTH_DB_MODE="%(ENV_SQLITE_AUTH_DB_MODE)s" priority=100 ; ---- BetterDesk Go Server ---- @@ -60,5 +61,5 @@ stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 -environment=SIGNAL_PORT="21116",SIGNAL_RATE_LIMIT_PER_IP="%(ENV_SIGNAL_RATE_LIMIT_PER_IP)s",ENCRYPTED_ONLY="%(ENV_ENCRYPTED_ONLY)s",DB_URL="%(ENV_DB_URL)s",AUTH_DB_PATH="%(ENV_AUTH_DB_PATH)s",RELAY_SERVERS="%(ENV_RELAY_SERVERS)s",ENROLLMENT_MODE="%(ENV_ENROLLMENT_MODE)s",NTP_SERVERS="%(ENV_NTP_SERVERS)s",BILLING_MAX_CLOCK_SKEW_MS="%(ENV_BILLING_MAX_CLOCK_SKEW_MS)s",BILLING_REQUIRE_SYNCED_CLOCK="%(ENV_BILLING_REQUIRE_SYNCED_CLOCK)s",BILLING_TRUST_OS_NTP="%(ENV_BILLING_TRUST_OS_NTP)s",INIT_ADMIN_USER="%(ENV_INIT_ADMIN_USER)s",INIT_ADMIN_PASS="%(ENV_INIT_ADMIN_PASS)s" +environment=SIGNAL_PORT="21116",SIGNAL_RATE_LIMIT_PER_IP="%(ENV_SIGNAL_RATE_LIMIT_PER_IP)s",ENCRYPTED_ONLY="%(ENV_ENCRYPTED_ONLY)s",DB_URL="%(ENV_DB_URL)s",AUTH_DB_PATH="%(ENV_AUTH_DB_PATH)s",SQLITE_AUTH_DB_MODE="%(ENV_SQLITE_AUTH_DB_MODE)s",RELAY_SERVERS="%(ENV_RELAY_SERVERS)s",ENROLLMENT_MODE="%(ENV_ENROLLMENT_MODE)s",NTP_SERVERS="%(ENV_NTP_SERVERS)s",BILLING_MAX_CLOCK_SKEW_MS="%(ENV_BILLING_MAX_CLOCK_SKEW_MS)s",BILLING_REQUIRE_SYNCED_CLOCK="%(ENV_BILLING_REQUIRE_SYNCED_CLOCK)s",BILLING_TRUST_OS_NTP="%(ENV_BILLING_TRUST_OS_NTP)s",INIT_ADMIN_USER="%(ENV_INIT_ADMIN_USER)s",INIT_ADMIN_PASS="%(ENV_INIT_ADMIN_PASS)s" priority=200 diff --git a/docker/wait-panel-auth-db.sh b/docker/wait-panel-auth-db.sh index 3e09faf8..f1c73e62 100644 --- a/docker/wait-panel-auth-db.sh +++ b/docker/wait-panel-auth-db.sh @@ -1,6 +1,6 @@ #!/bin/sh -# Waits for console auth.db before starting the Go server (SQLite Docker). -# PostgreSQL deployments skip this — panel sync uses DATABASE_URL instead. +# Waits for console auth.db before starting the Go server only in explicit +# legacy mode. Fresh SQLite installs use the primary db_v2.sqlite3 database. set -e case "${DB_URL:-}" in @@ -14,6 +14,11 @@ if [ -z "$auth_path" ]; then exec "$@" fi +if [ ! -f "$auth_path" ] && [ "${SQLITE_AUTH_DB_MODE:-}" != "legacy" ]; then + echo "No legacy auth.db — using the primary SQLite database." + exec "$@" +fi + if [ ! -f "$auth_path" ]; then echo "Waiting for panel auth.db at $auth_path..." retries=0 diff --git a/docs/docker/DOCKER_QUICKSTART.md b/docs/docker/DOCKER_QUICKSTART.md index afe6084a..bcd2edbe 100644 --- a/docs/docker/DOCKER_QUICKSTART.md +++ b/docs/docker/DOCKER_QUICKSTART.md @@ -135,9 +135,10 @@ ADMIN_PASSWORD=YourSecurePass123 docker compose up -d ``` `ADMIN_PASSWORD` only seeds the first admin account. If the container has -already created `auth.db` / the admin user, changing the environment variable on -restart will not overwrite the stored password. Use the panel password reset -flow, or recreate the Docker volumes for a fresh install. +already created the admin user in `db_v2.sqlite3` (or PostgreSQL), changing the +environment variable on restart will not overwrite the stored password. Use +the panel password reset flow, or recreate the Docker volumes for a fresh +install. ### PostgreSQL Instead of SQLite @@ -250,10 +251,10 @@ If you customized an older quick-start file before **3.0.0**, apply these change | Setting | Required in 3.0.0+ | |---------|-------------------| -| `depends_on` | `condition: service_started` — **not** `service_healthy` (avoids deadlock with `auth.db`) | +| `depends_on` | `condition: service_started` — **not** `service_healthy` | | Server healthcheck | Keep enabled, or remove `service_healthy` from `depends_on` | | Console `DB_PATH` | `/app/data/db_v2.sqlite3` | -| Server `AUTH_DB_PATH` | `/app/data/auth.db` | +| Server `AUTH_DB_PATH` | `/app/data/auth.db` only for legacy panel sync | | Server volume | `console-data:/app/data:ro` | | `network_mode: service:server` | Use `127.0.0.1` in `BETTERDESK_API_URL`, `WS_HBBS_HOST`, `WS_HBBR_HOST` (Docker DNS is unavailable) | | Image tag | Pin `BETTERDESK_IMAGE_TAG` (e.g. `3.2.14`), not unversioned `latest` | diff --git a/docs/docker/DOCKER_TROUBLESHOOTING.md b/docs/docker/DOCKER_TROUBLESHOOTING.md index c0f06bb1..4857d5ec 100644 --- a/docs/docker/DOCKER_TROUBLESHOOTING.md +++ b/docs/docker/DOCKER_TROUBLESHOOTING.md @@ -440,26 +440,25 @@ docker compose exec -u betterdesk console sh -c 'cat /opt/rustdesk/.admin_creden **Do not** run `chmod 777` on the credentials file — that makes the bootstrap password world-readable. -If no file is found yet, wait for first boot to finish and check `docker compose logs server` for the bootstrap message. +If no file is found yet, wait for first boot to finish and check `docker compose logs server` for the bootstrap message. On a running installation, the file is only a recovery aid; it is not the database of users. ### Problem: Panel login fails but `/opt/rustdesk/.admin_credentials` looks correct (#385) **Symptom:** Fresh Docker install (`docker-compose.single.yml`, `docker-compose.quick.yml`, or `install.sh`). You read the bootstrap password from `/opt/rustdesk/.admin_credentials` (or `betterdesk-show-admin-credentials`), but the web panel at `:5000` returns **Invalid username or password**. -**Cause (fixed in Development channel):** On first boot without `ADMIN_PASSWORD`, the Go server and Node.js console each generated a *different* random password. The credentials file reflected the Go server's password, while panel login uses the admin account in `/app/data/auth.db` (Node.js / bcrypt). +**Cause:** On first boot without `ADMIN_PASSWORD`, the Go server and Node.js console could each generate a random password. The credentials file then did not match the password hash stored for the admin user. Fresh SQLite installations use the centralized `/opt/rustdesk/db_v2.sqlite3` store (and PostgreSQL installations use the primary PostgreSQL database); `auth.db` is only a legacy migration path. **Workaround (existing broken install):** ```bash -# Panel password may be in the console data volume instead: -docker compose exec -u betterdesk console cat /app/data/.admin_credentials - -# Or reset on a clean volume with a known password: +# For a disposable/test installation, remove both Docker stores: docker compose down -v ADMIN_PASSWORD='YourSecurePassword123' docker compose up -d ``` -**Fix:** Pull/rebuild current `:dev` images (or wait for the next GHCR tag). Entrypoints now run `bootstrap-admin-credentials.sh` before supervisord / server start so both services share one password. Setting `ADMIN_PASSWORD` before first start always worked and still does. +Do not delete only the `/opt/rustdesk` bind mount while keeping `console-data`: that is not a clean reset. Do not use a credentials file to overwrite an existing user's password; use the normal password-reset procedure instead. + +**Fix:** Pull/rebuild current `:dev` images (or wait for the next GHCR tag). The split entrypoints elect one creator for the shared credentials file and both services reuse it. Setting `ADMIN_PASSWORD` before first start remains the deterministic option. ### Problem: `betterdesk-show-admin-credentials: executable file not found` diff --git a/web-nodejs/tests/dbAdapter.singleStore.test.js b/web-nodejs/tests/dbAdapter.singleStore.test.js index e0bc4c02..3909b043 100644 --- a/web-nodejs/tests/dbAdapter.singleStore.test.js +++ b/web-nodejs/tests/dbAdapter.singleStore.test.js @@ -34,6 +34,7 @@ describe('dbAdapter SQLite single-store topology', () => { const main = new Database(process.env.DB_PATH, { readonly: true }); expect(main.prepare(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'users'`).get()).toBeTruthy(); main.close(); + expect(adapter.getSqliteAuthDb()).toBe(adapter.getSqliteMainDb()); await adapter.close(); }); }); diff --git a/web-nodejs/tests/dockerBootstrapCredentials.test.js b/web-nodejs/tests/dockerBootstrapCredentials.test.js new file mode 100644 index 00000000..c0e4d2b9 --- /dev/null +++ b/web-nodejs/tests/dockerBootstrapCredentials.test.js @@ -0,0 +1,76 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawn, spawnSync } = require('child_process'); + +const bootstrapScript = path.resolve(__dirname, '..', '..', 'docker', 'bootstrap-admin-credentials.sh'); +const hasPosixShell = spawnSync('sh', ['-c', 'exit 0'], { stdio: 'ignore' }).status === 0; + +function runBootstrap(env) { + return new Promise((resolve, reject) => { + const child = spawn('sh', ['-c', '. "$BOOTSTRAP_SCRIPT"; printf "%s\\n" "$INIT_ADMIN_PASS" > "$RESULT_FILE"'], { + env: { + ...process.env, + ...env, + BOOTSTRAP_SCRIPT: bootstrapScript, + }, + stdio: 'ignore', + }); + child.on('error', reject); + child.on('close', code => resolve(code)); + }); +} + +(hasPosixShell ? describe : describe.skip)('Docker bootstrap admin credentials', () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'betterdesk-bootstrap-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test('concurrent split entrypoints elect one shared password', async () => { + const jobs = Array.from({ length: 8 }, (_, index) => runBootstrap({ + RUSTDESK_PATH: tempDir, + RESULT_FILE: path.join(tempDir, `result-${index}`), + BOOTSTRAP_CREDENTIALS_WAIT_SECONDS: '10', + INIT_ADMIN_PASS: '', + DEFAULT_ADMIN_PASSWORD: '', + ADMIN_PASSWORD: '', + })); + + const codes = await Promise.all(jobs); + expect(codes).toEqual(Array(8).fill(0)); + + const passwords = codes.map((_, index) => + fs.readFileSync(path.join(tempDir, `result-${index}`), 'utf8').trim()); + expect(new Set(passwords).size).toBe(1); + + const credentials = fs.readFileSync(path.join(tempDir, '.admin_credentials'), 'utf8'); + expect(credentials).toContain(`Admin Password: ${passwords[0]}`); + expect(fs.existsSync(path.join(tempDir, '.admin_credentials.lock'))).toBe(false); + }); + + test('reuses an existing credentials file without replacing it', async () => { + const credentialsPath = path.join(tempDir, '.admin_credentials'); + const original = 'Admin Username: admin\nAdmin Password: existing-password\n'; + fs.writeFileSync(credentialsPath, original, { mode: 0o600 }); + + const code = await runBootstrap({ + RUSTDESK_PATH: tempDir, + RESULT_FILE: path.join(tempDir, 'result'), + INIT_ADMIN_PASS: '', + DEFAULT_ADMIN_PASSWORD: '', + ADMIN_PASSWORD: '', + }); + + expect(code).toBe(0); + expect(fs.readFileSync(path.join(tempDir, 'result'), 'utf8').trim()).toBe('existing-password'); + expect(fs.readFileSync(credentialsPath, 'utf8')).toBe(original); + }); +});