diff --git a/.github/workflows/installer-ci.yml b/.github/workflows/installer-ci.yml index a6803c91..ef09c5c3 100644 --- a/.github/workflows/installer-ci.yml +++ b/.github/workflows/installer-ci.yml @@ -10,6 +10,7 @@ on: - 'betterdesk-docker.sh' - 'scripts/test-betterdesk-node-install.sh' - 'scripts/test-postgresql-hba.sh' + - 'scripts/test-docker-hardening.sh' - 'scripts/installer-protocol-check.js' - 'web-nodejs/lib/safePath.js' - 'web-nodejs/services/updateService.js' @@ -28,6 +29,7 @@ on: - 'betterdesk-docker.sh' - 'scripts/test-betterdesk-node-install.sh' - 'scripts/test-postgresql-hba.sh' + - 'scripts/test-docker-hardening.sh' - 'scripts/installer-protocol-check.js' - 'web-nodejs/lib/safePath.js' - 'web-nodejs/services/updateService.js' @@ -65,7 +67,8 @@ jobs: betterdesk-docker.sh \ scripts/test-native-installer.sh \ scripts/test-betterdesk-node-install.sh \ - scripts/test-postgresql-hba.sh) + scripts/test-postgresql-hba.sh \ + scripts/test-docker-hardening.sh) - name: Run ShellCheck run: | shellcheck --severity=error \ @@ -74,7 +77,8 @@ jobs: betterdesk-docker.sh \ scripts/test-native-installer.sh \ scripts/test-betterdesk-node-install.sh \ - scripts/test-postgresql-hba.sh + scripts/test-postgresql-hba.sh \ + scripts/test-docker-hardening.sh - name: Test native Node.js installer recovery run: bash scripts/test-betterdesk-node-install.sh - name: Test native installer watchdog and clone recovery @@ -163,6 +167,18 @@ jobs: docker compose -f "$file" config --quiet done + docker-hardening-smoke: + name: Docker hardened bind-mount smoke + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + BETTERDESK_IMAGE_TAG: ${{ inputs.image_tag || 'latest' }} + steps: + - uses: actions/checkout@v4 + - name: Verify hardened bind mounts and restart behavior + run: bash scripts/test-docker-hardening.sh + docker-runtime-smoke: name: Docker runtime smoke if: github.event_name == 'workflow_dispatch' diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f62e7a7..e80ee4d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ ## [Unreleased] ### Changed -- _(none yet)_ +- **Hardened Docker startup on app-owned bind mounts (#424):** All-in-one and split entrypoints now inspect data files as the `betterdesk` user, so existing `.api_key`, `.admin_credentials`, SQLite databases, and enrollment markers are not mistaken for missing files when `cap_drop: ALL` is used with `0700` bind mounts. Bootstrap also always exports the admin variables required by supervisord, without generating replacement credentials for an existing database. --- diff --git a/docker/bootstrap-admin-credentials.sh b/docker/bootstrap-admin-credentials.sh index d05c2953..3a4b82d7 100644 --- a/docker/bootstrap-admin-credentials.sh +++ b/docker/bootstrap-admin-credentials.sh @@ -27,6 +27,15 @@ fi ADMIN_USER="${INIT_ADMIN_USER:-${DEFAULT_ADMIN_USERNAME:-admin}}" +ensure_admin_exports() { + export INIT_ADMIN_USER="${INIT_ADMIN_USER:-${DEFAULT_ADMIN_USERNAME:-admin}}" + export DEFAULT_ADMIN_USERNAME="${DEFAULT_ADMIN_USERNAME:-$INIT_ADMIN_USER}" + export DEFAULT_ADMIN_PASSWORD="${DEFAULT_ADMIN_PASSWORD:-${INIT_ADMIN_PASS:-}}" + export INIT_ADMIN_PASS="${INIT_ADMIN_PASS:-$DEFAULT_ADMIN_PASSWORD}" +} + +ensure_admin_exports + sync_exports() { export INIT_ADMIN_USER="${INIT_ADMIN_USER:-$ADMIN_USER}" export DEFAULT_ADMIN_USERNAME="${DEFAULT_ADMIN_USERNAME:-$ADMIN_USER}" @@ -47,9 +56,17 @@ run_as_betterdesk() { fi } +file_exists_as_betterdesk() { + run_as_betterdesk test -f "$1" +} + +path_exists_as_betterdesk() { + run_as_betterdesk test -e "$1" +} + parse_creds_password() { _file="$1" - if [ ! -f "$_file" ]; then + if ! file_exists_as_betterdesk "$_file"; then return 1 fi # The file is intentionally mode 0600 and owned by betterdesk. Root in @@ -112,7 +129,7 @@ primary_database_has_users() { case "$_primary_db" in postgres://*|postgresql://*) return 1 ;; esac - if [ ! -f "$_primary_db" ] || ! command -v sqlite3 >/dev/null 2>&1; then + if ! file_exists_as_betterdesk "$_primary_db" || ! command -v sqlite3 >/dev/null 2>&1; then return 1 fi _user_count=$(run_as_betterdesk sqlite3 "$_primary_db" \ @@ -152,7 +169,7 @@ if [ -n "${INIT_ADMIN_PASS:-}" ] || [ -n "${DEFAULT_ADMIN_PASSWORD:-}" ]; then if [ -n "$_env_pass" ]; then echo "Bootstrap: INIT_ADMIN_PASS/DEFAULT_ADMIN_PASSWORD set=yes (password not logged)" run_as_betterdesk mkdir -p "$CREDS_DIR" 2>/dev/null || true - if [ ! -f "$CREDS_FILE" ] && ! primary_database_has_users; then + if ! file_exists_as_betterdesk "$CREDS_FILE" && ! primary_database_has_users; then write_bootstrap_credentials "$_env_pass" " (ADMIN_PASSWORD)" echo "Bootstrap admin credentials → ${CREDS_FILE} (from ADMIN_PASSWORD / INIT_ADMIN_PASS)" elif ! primary_database_has_users; then @@ -200,7 +217,7 @@ if run_as_betterdesk mkdir "$LOCK_DIR" 2>/dev/null; then echo " No replacement bootstrap password was generated; use password reset." >&2 return 0 2>/dev/null || exit 0 fi - if [ -e "$CREDS_FILE" ]; then + if path_exists_as_betterdesk "$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 diff --git a/docker/console-entrypoint.sh b/docker/console-entrypoint.sh index a3ca57ef..0aeb65ae 100644 --- a/docker/console-entrypoint.sh +++ b/docker/console-entrypoint.sh @@ -13,11 +13,11 @@ ensure_betterdesk_user if [ "$(id -u)" = "0" ]; then chown -R betterdesk:betterdesk /app/data 2>/dev/null || true # Fix permissions on sensitive files - if [ -f /app/data/.session_secret ]; then + if file_exists_as_betterdesk /app/data/.session_secret; then chmod 600 /app/data/.session_secret chown betterdesk:betterdesk /app/data/.session_secret fi - if [ -f /app/data/auth.db ]; then + if file_exists_as_betterdesk /app/data/auth.db; then chown betterdesk:betterdesk /app/data/auth.db fi # /opt/rustdesk may be mounted read-only from server volume — only fix if writable diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index ea45fdd3..e1260b74 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -59,7 +59,7 @@ touch_as_betterdesk() { # 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 [ -z "${API_KEY:-}" ] && ! file_exists_as_betterdesk "$API_KEY_FILE"; then if command -v openssl >/dev/null 2>&1; then API_KEY=$(openssl rand -hex 32) else @@ -67,7 +67,7 @@ if [ -z "${API_KEY:-}" ] && [ ! -f "$API_KEY_FILE" ]; then 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 +elif [ -n "${API_KEY:-}" ] && ! file_exists_as_betterdesk "$API_KEY_FILE"; then write_as_betterdesk "$API_KEY_FILE" "$API_KEY" echo "API key from env → $API_KEY_FILE" fi @@ -83,8 +83,9 @@ if [ -n "${ENROLLMENT_MODE:-}" ]; then else export ENROLLMENT_MODE_ENV_OVERRIDE="N" ENROLLMENT_SENTINEL="/opt/rustdesk/.enrollment_initialized" - if [ ! -f "$ENROLLMENT_SENTINEL" ]; then - if [ -f /opt/rustdesk/db_v2.sqlite3 ] || [ -f /opt/rustdesk/id_ed25519 ]; then + if ! file_exists_as_betterdesk "$ENROLLMENT_SENTINEL"; then + if file_exists_as_betterdesk /opt/rustdesk/db_v2.sqlite3 \ + || file_exists_as_betterdesk /opt/rustdesk/id_ed25519; then echo "Enrollment: preserving existing policy (pre-existing volume)" else export ENROLLMENT_MODE="managed" @@ -111,7 +112,7 @@ if ! touch_as_betterdesk /opt/rustdesk/.write_test 2>/dev/null; then 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 +if file_exists_as_betterdesk /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 @@ -145,7 +146,7 @@ fi # 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 + if ! file_exists_as_betterdesk "${AUTH_DB_PATH}"; then echo "ERROR: MIGRATE_SQLITE_AUTH_DB=Y but legacy auth.db is missing: ${AUTH_DB_PATH}" exit 1 fi diff --git a/docker/guard-sqlite-auth-split.sh b/docker/guard-sqlite-auth-split.sh index a942e51b..e993addc 100644 --- a/docker/guard-sqlite-auth-split.sh +++ b/docker/guard-sqlite-auth-split.sh @@ -29,11 +29,11 @@ guard_sqlite_auth_split() { _auth_db="${AUTH_DB_PATH:-${DATA_DIR:-/app/data}/auth.db}" _primary_db="${DB_PATH:-${DB_URL:-/opt/rustdesk/db_v2.sqlite3}}" - [ -f "$_auth_db" ] || return 0 + file_exists_as_betterdesk "$_auth_db" || return 0 command -v sqlite3 >/dev/null 2>&1 || return 0 _has_consolidation_marker=0 - if [ -f "$_primary_db" ]; then + if file_exists_as_betterdesk "$_primary_db"; then if _guard_run_as_betterdesk sqlite3 "$_primary_db" \ "SELECT 1 FROM betterdesk_migrations WHERE name='sqlite_auth_consolidation_v1' AND status='complete' LIMIT 1;" 2>/dev/null | grep -q 1; then _has_consolidation_marker=1 @@ -54,7 +54,7 @@ guard_sqlite_auth_split() { fi _primary_users=0 - if [ -f "$_primary_db" ]; then + if file_exists_as_betterdesk "$_primary_db"; then _primary_users=$(_guard_run_as_betterdesk sqlite3 "$_primary_db" \ "SELECT COUNT(*) FROM users;" 2>/dev/null || echo "0") case "$_primary_users" in diff --git a/docker/server-entrypoint.sh b/docker/server-entrypoint.sh index bcea6316..844ce2cc 100644 --- a/docker/server-entrypoint.sh +++ b/docker/server-entrypoint.sh @@ -27,7 +27,7 @@ fi # Fix ownership before bootstrap writes .admin_credentials (issue #385). if [ "$(id -u)" = "0" ]; then chown -R betterdesk:betterdesk "$DATA_DIR" 2>/dev/null || true - if [ -f "$DATA_DIR/id_ed25519" ]; then + if file_exists_as_betterdesk "$DATA_DIR/id_ed25519"; then chmod 600 "$DATA_DIR/id_ed25519" chown betterdesk:betterdesk "$DATA_DIR/id_ed25519" fi @@ -48,22 +48,22 @@ panel_auth_db_ready() { if [ -z "$auth_path" ]; then return 0 fi - if [ ! -f "$auth_path" ] && [ "${SQLITE_AUTH_DB_MODE:-}" != "legacy" ]; then + if ! file_exists_as_betterdesk "$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 + if file_exists_as_betterdesk "$auth_path"; then echo "Panel auth.db ready: $auth_path" return 0 fi echo "Waiting for panel auth.db at $auth_path (console container)..." retries=0 max_retries=90 - while [ ! -f "$auth_path" ] && [ "$retries" -lt "$max_retries" ]; do + while ! file_exists_as_betterdesk "$auth_path" && [ "$retries" -lt "$max_retries" ]; do sleep 2 retries=$((retries + 1)) done - if [ ! -f "$auth_path" ]; then + if ! file_exists_as_betterdesk "$auth_path"; then echo "WARN: panel auth.db not found after ${max_retries} attempts — RustDesk folders/groups may be unavailable" return 0 fi @@ -84,8 +84,9 @@ if [ -n "${ENROLLMENT_MODE:-}" ]; then else export ENROLLMENT_MODE_ENV_OVERRIDE="N" ENROLLMENT_SENTINEL="$DATA_DIR/.enrollment_initialized" - if [ ! -f "$ENROLLMENT_SENTINEL" ]; then - if [ -f "$DATA_DIR/db_v2.sqlite3" ] || [ -f "$DATA_DIR/id_ed25519" ]; then + if ! file_exists_as_betterdesk "$ENROLLMENT_SENTINEL"; then + if file_exists_as_betterdesk "$DATA_DIR/db_v2.sqlite3" \ + || file_exists_as_betterdesk "$DATA_DIR/id_ed25519"; then : # pre-existing volume — keep current enrollment policy else export ENROLLMENT_MODE="managed" diff --git a/docs/docker/DOCKER_TROUBLESHOOTING.md b/docs/docker/DOCKER_TROUBLESHOOTING.md index 24108910..0552eb92 100644 --- a/docs/docker/DOCKER_TROUBLESHOOTING.md +++ b/docs/docker/DOCKER_TROUBLESHOOTING.md @@ -604,6 +604,55 @@ docker run ... \ ghcr.io/unitronix/betterdesk: ``` +### Problem: Hardened bind mounts regenerate `.api_key` or restart after credentials cleanup (#424) + +**Symptom:** An all-in-one container uses `cap_drop: [ALL]`, a bind-mounted +`/opt/rustdesk` owned by UID/GID `10001:10001` with mode `0700`, and logs +`Auto-generated API key` on every restart. If `.admin_credentials` was removed +from an installation that already has users, the container may also enter a +supervisord restart loop with an `ENV_DEFAULT_ADMIN_*` or `ENV_INIT_ADMIN_*` +format-string error. + +**Cause:** Root cannot traverse a `0700` app-owned directory after +`CAP_DAC_READ_SEARCH` and `CAP_DAC_OVERRIDE` are dropped. Older images tested +for files as root, so existing app-owned files looked absent. Older bootstrap +paths could also leave the variables required by supervisord undefined. + +**Fix:** Pull/rebuild the current all-in-one or split images and recreate the +container: + +```bash +docker compose pull +docker compose up -d --force-recreate +``` + +Current entrypoints perform data-file existence checks as `betterdesk` and +export empty admin defaults when no replacement password is appropriate. Do +not add `DAC_READ_SEARCH` just to work around this issue and do not delete +`.admin_credentials` to change a password; use the normal password-reset flow. +For custom bind mounts, keep the data directory accessible to the container's +configured `PUID`/`PGID` (defaults to `10001:10001`) and keep it private, for +example: + +```bash +chown -R 10001:10001 ./betterdesk-data +chmod 700 ./betterdesk-data +``` + +**Verify after updating:** + +```bash +first_key=$(docker compose exec -T -u betterdesk betterdesk \ + sh -c 'cat /opt/rustdesk/.api_key') +docker compose restart betterdesk +docker compose up -d --wait +second_key=$(docker compose exec -T -u betterdesk betterdesk \ + sh -c 'cat /opt/rustdesk/.api_key') +test "$first_key" = "$second_key" +docker compose logs betterdesk 2>&1 \ + | grep -E 'ENV_(DEFAULT_ADMIN|INIT_ADMIN)|Auto-generated API key' || true +``` + ### Problem: Browser shows `SSL_ERROR_RX_RECORD_TOO_LONG` (or Chrome “ERR_SSL_PROTOCOL_ERROR”) **Symptom:** After a fresh Docker install or `docker compose pull`, Firefox Advanced details show: diff --git a/scripts/test-docker-hardening.sh b/scripts/test-docker-hardening.sh new file mode 100644 index 00000000..dbc8f94c --- /dev/null +++ b/scripts/test-docker-hardening.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +IMAGE_TAG="${BETTERDESK_IMAGE_TAG:-dev}" +IMAGE="${BETTERDESK_HARDENING_IMAGE:-ghcr.io/unitronix/betterdesk:${IMAGE_TAG}}" +PROJECT="betterdesk-hardening-$RANDOM" +TEST_DIR="$(mktemp -d)" +OVERRIDE_FILE="$TEST_DIR/compose.override.yml" + +fail() { + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} + +cleanup() { + docker compose -p "$PROJECT" \ + -f "$SCRIPT_ROOT/docker-compose.quick.single.yml" \ + -f "$OVERRIDE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true + rm -rf "$TEST_DIR" +} +trap cleanup EXIT + +if ! command -v docker >/dev/null 2>&1 || ! docker info >/dev/null 2>&1; then + printf 'SKIP: Docker is not available\n' + exit 0 +fi + +mkdir -p "$TEST_DIR/rustdesk" "$TEST_DIR/console" +cat > "$OVERRIDE_FILE" < "$TEST_DIR/rustdesk/.api_key" +chmod 600 "$TEST_DIR/rustdesk/.api_key" +docker run --rm \ + --entrypoint sh \ + -v "$TEST_DIR/rustdesk:/opt/rustdesk" \ + -v "$TEST_DIR/console:/app/data" \ + "$IMAGE" \ + -c 'chown -R 10001:10001 /opt/rustdesk /app/data && chmod 700 /opt/rustdesk /app/data' + +docker compose -p "$PROJECT" \ + -f "$SCRIPT_ROOT/docker-compose.quick.single.yml" \ + -f "$OVERRIDE_FILE" up -d --wait + +first_key=$(docker compose -p "$PROJECT" \ + -f "$SCRIPT_ROOT/docker-compose.quick.single.yml" \ + -f "$OVERRIDE_FILE" exec -T -u betterdesk betterdesk \ + sh -c 'cat /opt/rustdesk/.api_key' | tr -d '\r\n') +[ "$first_key" = "stable-test-api-key" ] \ + || fail "existing API key was not preserved on first start" + +docker compose -p "$PROJECT" \ + -f "$SCRIPT_ROOT/docker-compose.quick.single.yml" \ + -f "$OVERRIDE_FILE" restart betterdesk +docker compose -p "$PROJECT" \ + -f "$SCRIPT_ROOT/docker-compose.quick.single.yml" \ + -f "$OVERRIDE_FILE" up -d --wait + +second_key=$(docker compose -p "$PROJECT" \ + -f "$SCRIPT_ROOT/docker-compose.quick.single.yml" \ + -f "$OVERRIDE_FILE" exec -T -u betterdesk betterdesk \ + sh -c 'cat /opt/rustdesk/.api_key' | tr -d '\r\n') +[ "$second_key" = "$first_key" ] \ + || fail "API key changed after a hardened-container restart" + +docker compose -p "$PROJECT" \ + -f "$SCRIPT_ROOT/docker-compose.quick.single.yml" \ + -f "$OVERRIDE_FILE" exec -T -u betterdesk betterdesk \ + rm -f /opt/rustdesk/.admin_credentials +docker compose -p "$PROJECT" \ + -f "$SCRIPT_ROOT/docker-compose.quick.single.yml" \ + -f "$OVERRIDE_FILE" restart betterdesk +docker compose -p "$PROJECT" \ + -f "$SCRIPT_ROOT/docker-compose.quick.single.yml" \ + -f "$OVERRIDE_FILE" up -d --wait + +[ ! -e "$TEST_DIR/rustdesk/.admin_credentials" ] \ + || fail "bootstrap recreated credentials for an existing database" +if docker compose -p "$PROJECT" \ + -f "$SCRIPT_ROOT/docker-compose.quick.single.yml" \ + -f "$OVERRIDE_FILE" logs 2>&1 \ + | grep -Eq 'Format string .*ENV_(DEFAULT_ADMIN|INIT_ADMIN)'; then + fail "supervisord failed to expand admin environment variables" +fi + +printf 'PASS: hardened Docker startup regression tests\n' diff --git a/web-nodejs/tests/dockerBootstrapCredentials.test.js b/web-nodejs/tests/dockerBootstrapCredentials.test.js index 8d70dc39..a309aa06 100644 --- a/web-nodejs/tests/dockerBootstrapCredentials.test.js +++ b/web-nodejs/tests/dockerBootstrapCredentials.test.js @@ -16,6 +16,21 @@ function sourceBootstrap(env) { }); } +function sourceBootstrapExports(env) { + const scriptPath = bootstrapScript.replace(/\\/g, '/'); + return spawnSync( + 'sh', + [ + '-c', + `. "${scriptPath}" >/dev/null 2>&1; printf "%s|%s|%s|%s\\n" "$INIT_ADMIN_USER" "$DEFAULT_ADMIN_USERNAME" "$INIT_ADMIN_PASS" "$DEFAULT_ADMIN_PASSWORD"`, + ], + { + env: { ...process.env, ...env }, + encoding: 'utf8', + } + ); +} + (hasPosixShell ? describe : describe.skip)('Docker bootstrap-admin-credentials', () => { let tempDir; @@ -60,6 +75,39 @@ function sourceBootstrap(env) { const credentials = fs.readFileSync(path.join(tempDir, '.admin_credentials'), 'utf8'); expect(credentials).toContain('Admin Password: replacement-password-123'); }); + + test('exports supervisord admin variables when no password is available', () => { + const result = sourceBootstrapExports({ + RUSTDESK_PATH: tempDir, + DB_PATH: path.join(tempDir, 'missing.sqlite'), + INIT_ADMIN_USER: '', + INIT_ADMIN_PASS: '', + DEFAULT_ADMIN_USERNAME: '', + DEFAULT_ADMIN_PASSWORD: '', + ADMIN_USERNAME: '', + ADMIN_PASSWORD: '', + }); + + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe('admin|admin||'); + }); + + test('keeps configured admin values synchronized across services', () => { + const result = sourceBootstrapExports({ + RUSTDESK_PATH: tempDir, + INIT_ADMIN_USER: 'operator', + INIT_ADMIN_PASS: 'configured-password-123', + DEFAULT_ADMIN_USERNAME: '', + DEFAULT_ADMIN_PASSWORD: '', + ADMIN_USERNAME: '', + ADMIN_PASSWORD: '', + }); + + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe( + 'operator|operator|configured-password-123|configured-password-123' + ); + }); }); function runBootstrap(env) { diff --git a/web-nodejs/tests/dockerBuildContract.test.js b/web-nodejs/tests/dockerBuildContract.test.js index a5747f1c..add5fa5f 100644 --- a/web-nodejs/tests/dockerBuildContract.test.js +++ b/web-nodejs/tests/dockerBuildContract.test.js @@ -81,6 +81,52 @@ describe('Docker admin bootstrap contract', () => { expect(contents).toContain('export DB_URL="${DATABASE_URL:-${DB_PATH:-${RUSTDESK_PATH:-$DATA_DIR}/db_v2.sqlite3}}"'); }); + test('hardened Docker checks app-owned paths as betterdesk', () => { + const bootstrap = fs.readFileSync( + path.join(repoRoot, 'docker', 'bootstrap-admin-credentials.sh'), + 'utf8' + ); + const entrypoint = fs.readFileSync( + path.join(repoRoot, 'docker', 'entrypoint.sh'), + 'utf8' + ); + const guard = fs.readFileSync( + path.join(repoRoot, 'docker', 'guard-sqlite-auth-split.sh'), + 'utf8' + ); + + expect(bootstrap).toContain('file_exists_as_betterdesk()'); + expect(bootstrap).toContain('path_exists_as_betterdesk()'); + expect(bootstrap).toContain('file_exists_as_betterdesk "$_file"'); + expect(bootstrap).toContain('file_exists_as_betterdesk "$_primary_db"'); + expect(bootstrap).toContain('path_exists_as_betterdesk "$CREDS_FILE"'); + expect(entrypoint).toContain('file_exists_as_betterdesk "$API_KEY_FILE"'); + expect(entrypoint).toContain('file_exists_as_betterdesk "$ENROLLMENT_SENTINEL"'); + expect(guard).toContain('file_exists_as_betterdesk "$_auth_db"'); + expect(guard).toContain('file_exists_as_betterdesk "$_primary_db"'); + }); + + test('AIO supervisord admin variables are always exported by bootstrap', () => { + const bootstrap = fs.readFileSync( + path.join(repoRoot, 'docker', 'bootstrap-admin-credentials.sh'), + 'utf8' + ); + const supervisor = fs.readFileSync( + path.join(repoRoot, 'docker', 'supervisord.conf'), + 'utf8' + ); + + expect(bootstrap).toContain('ensure_admin_exports()'); + expect(bootstrap).toContain('export INIT_ADMIN_USER='); + expect(bootstrap).toContain('export INIT_ADMIN_PASS='); + expect(bootstrap).toContain('export DEFAULT_ADMIN_USERNAME='); + expect(bootstrap).toContain('export DEFAULT_ADMIN_PASSWORD='); + expect(supervisor).toContain('%(ENV_DEFAULT_ADMIN_USERNAME)s'); + expect(supervisor).toContain('%(ENV_DEFAULT_ADMIN_PASSWORD)s'); + expect(supervisor).toContain('%(ENV_INIT_ADMIN_USER)s'); + expect(supervisor).toContain('%(ENV_INIT_ADMIN_PASS)s'); + }); + test.each([ 'docker-compose.yml', 'docker-compose.single.yml',