fix(docker): make split admin bootstrap atomic

Prevent concurrent Go and console startup from generating mismatched admin passwords, and keep fresh Docker authentication on the centralized SQLite store.

Refs #385

Thanks: INSOLVE (Honorary); Marco Jakobs (@jacotec); MyNameisStitch (@MyNameisStitch); Redspin (@playerumpknow)
This commit is contained in:
UNITRONIX
2026-08-25 02:13:14 +02:00
parent 286a9ad212
commit b3973c4991
16 changed files with 347 additions and 56 deletions
+1 -1
View File
@@ -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.
---
+46 -3
View File
@@ -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 {
+45
View File
@@ -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")
}
}
+1
View File
@@ -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
+5 -2
View File
@@ -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
+1
View File
@@ -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.
+3 -1
View File
@@ -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:-}
+137 -30
View File
@@ -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
+1
View File
@@ -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..."
+7 -2
View File
@@ -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
+4 -3
View File
@@ -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
+7 -2
View File
@@ -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
+6 -5
View File
@@ -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` |
+6 -7
View File
@@ -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`
@@ -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();
});
});
@@ -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);
});
});