mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-10-25 14:00:53 +00:00
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:
@@ -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
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user