fix(docker): preserve secrets on hardened bind mounts

Refs #424

Thanks: INSOLVE (Honorary); Marco Jakobs (@jacotec); MyNameisStitch (@MyNameisStitch); Redspin (@playerumpknow)
This commit is contained in:
UNITRONIX
2026-09-26 03:02:04 +02:00
parent 98341e30d7
commit 602c4e798b
11 changed files with 302 additions and 25 deletions
+18 -2
View File
@@ -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'
+1 -1
View File
@@ -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.
---
+21 -4
View File
@@ -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
+2 -2
View File
@@ -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
+7 -6
View File
@@ -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
+3 -3
View File
@@ -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
+8 -7
View File
@@ -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"
+49
View File
@@ -604,6 +604,55 @@ docker run ... \
ghcr.io/unitronix/betterdesk:<tag>
```
### 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:
+99
View File
@@ -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" <<EOF
services:
betterdesk:
image: ${IMAGE}
volumes:
- ${TEST_DIR}/rustdesk:/opt/rustdesk
- ${TEST_DIR}/console:/app/data
EOF
if [[ "$IMAGE" == ghcr.io/* ]]; then
docker compose -p "$PROJECT" \
-f "$SCRIPT_ROOT/docker-compose.quick.single.yml" \
-f "$OVERRIDE_FILE" pull betterdesk
fi
printf 'stable-test-api-key\n' > "$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'
@@ -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) {
@@ -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',