mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 01:27:11 +00:00
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:
+1
-1
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:-}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user