From 2ced82794f0b88c0777b6f7e468d910aaa30ab0f Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:02:03 +0000 Subject: [PATCH] feat(docker): support file-backed secrets Load mounted secrets before database migrations while preserving direct environment values for existing deployments. Closes #373 --- .env.example | 20 ++++ Dockerfile | 4 +- README.md | 20 ++++ server/entrypoint.sh | 5 + server/file-env.sh | 79 ++++++++++++++ server/src/config/docker-env-file.test.ts | 124 ++++++++++++++++++++++ 6 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 server/file-env.sh create mode 100644 server/src/config/docker-env-file.test.ts diff --git a/.env.example b/.env.example index bea991c08..1e442e20c 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,15 @@ JWT_SECRET=change-this-to-a-long-random-secret # Required for the initial /auth/setup endpoint (x-setup-token header) SETUP_BOOTSTRAP_TOKEN=change-this-to-a-long-random-secret +# Sensitive values can instead be read from mounted files by appending _FILE. +# Leave the direct value blank, then set its _FILE variable to the container path. +# Keep the blank assignment: docker-compose.yml requires these keys to be present. +# A non-empty direct value and its _FILE variable are mutually exclusive. +# The file must be mounted into every service that uses it, and must not be empty. +# POSTGRES_PASSWORD_FILE=/run/secrets/bookorbit_postgres_password +# JWT_SECRET_FILE=/run/secrets/bookorbit_jwt_secret +# SETUP_BOOTSTRAP_TOKEN_FILE=/run/secrets/bookorbit_setup_bootstrap_token + # External/public URL used by emails and Kobo endpoints APP_URL=https://bookorbit.example.com @@ -40,6 +49,7 @@ NODE_MAX_OLD_SPACE_SIZE=2048 # postgres service from docker-compose.yml. Your instance must have the # uuid-ossp, pg_trgm, and vector (pgvector) extensions available. # DATABASE_URL=postgres://user:password@yourhost:5432/bookorbit +# DATABASE_URL_FILE=/run/secrets/bookorbit_database_url # Or set individual parts (POSTGRES_HOST, POSTGRES_PORT, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB) # CORS origin for the frontend when served from a different domain than APP_URL @@ -54,9 +64,19 @@ NODE_MAX_OLD_SPACE_SIZE=2048 # Recommended if you store SMTP provider credentials (generate: openssl rand -hex 32) # EMAIL_ENCRYPTION_KEY= +# EMAIL_ENCRYPTION_KEY_FILE=/run/secrets/bookorbit_email_encryption_key # Recommended if you store migration source credentials (generate: openssl rand -hex 32) # MIGRATION_ENCRYPTION_KEY= +# MIGRATION_ENCRYPTION_KEY_FILE=/run/secrets/bookorbit_migration_encryption_key + +# Required before download-client or indexer credentials can be saved. +# BOOK_REQUEST_ENCRYPTION_KEY= +# BOOK_REQUEST_ENCRYPTION_KEY_FILE=/run/secrets/bookorbit_book_request_encryption_key + +# Optional GitHub token used to read release notes, raising the anonymous API rate limit. +# GITHUB_RELEASES_TOKEN= +# GITHUB_RELEASES_TOKEN_FILE=/run/secrets/bookorbit_github_releases_token # Absolute container path that authorizes local migration backup files. # Mount a dedicated host directory at this path as read-only before enabling backup mode. diff --git a/Dockerfile b/Dockerfile index d6ebf4a93..25d7ea2e2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -71,12 +71,12 @@ ENV PORT=3000 COPY --from=server-builder --chown=node:node /deploy ./ COPY --from=client-builder --chown=node:node /app/client/dist ./public -COPY --from=server-builder --chown=node:node /app/server/entrypoint.sh ./entrypoint.sh +COPY --from=server-builder --chown=node:node /app/server/entrypoint.sh /app/server/file-env.sh ./ COPY --chown=node:node LICENSE NOTICE ADDITIONAL_TERMS.md ./ COPY --chown=node:node server/bin/kepubify/ ./bin/kepubify/ COPY --chown=node:node koreader-plugin/bookorbit.koplugin/ ./koreader-plugin/bookorbit.koplugin/ -RUN sed -i 's/\r$//' /app/entrypoint.sh && chmod +x /app/entrypoint.sh /app/bin/kepubify/* && mkdir -p /books /data/covers /data/book-bucket /tmp && chown -R node:node /data /tmp +RUN sed -i 's/\r$//' /app/entrypoint.sh /app/file-env.sh && chmod +x /app/entrypoint.sh /app/bin/kepubify/* && mkdir -p /books /data/covers /data/book-bucket /tmp && chown -R node:node /data /tmp EXPOSE 3000 diff --git a/README.md b/README.md index faccb683f..4f25f2067 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,26 @@ JWT_SECRET= # signs login tokens - openssl rand -hex 32 SETUP_BOOTSTRAP_TOKEN= # one-time setup wizard token - openssl rand -hex 16 ``` +Sensitive values can instead be supplied through mounted files by setting the corresponding `_FILE` variable, such as `JWT_SECRET_FILE=/run/secrets/bookorbit_jwt_secret`. Leave the direct variable blank, mount the file into every service that uses it, and restart. Keep the blank assignment rather than deleting the line: the bundled `docker-compose.yml` marks `POSTGRES_PASSWORD`, `JWT_SECRET`, and `SETUP_BOOTSTRAP_TOKEN` as required, so removing them entirely fails before the container starts. BookOrbit rejects configurations where both forms are non-empty, and rejects a file that is missing, unreadable, or empty. This convention is supported for database credentials, application encryption keys, `GITHUB_RELEASES_TOKEN`, `JWT_SECRET`, and `SETUP_BOOTSTRAP_TOKEN`. + +For example, a Compose override can mount one database password file into both containers: + +```yaml +services: + app: + secrets: [bookorbit_postgres_password] + postgres: + environment: + POSTGRES_PASSWORD_FILE: ${POSTGRES_PASSWORD_FILE?required} + secrets: [bookorbit_postgres_password] + +secrets: + bookorbit_postgres_password: + file: ./secrets/postgres_password +``` + +Set `POSTGRES_PASSWORD=` and `POSTGRES_PASSWORD_FILE=/run/secrets/bookorbit_postgres_password` in `.env`. The override passes the file path to PostgreSQL while the app receives it from the existing `.env` import. Other container platforms may mount secret files directly without a Compose override. + On a NAS, or any host where your book folder is owned by a user other than UID 1000, also set `PUID` and `PGID` to match that owner. Run `id -u` and `id -g` as the owning user to find them. Getting these wrong is the most common cause of permission errors on first scan. Optionally set `LIBRARY_BROWSE_ROOT=/books` to start the library folder picker at `/books` instead of `/`. diff --git a/server/entrypoint.sh b/server/entrypoint.sh index 141dd868c..648dfc477 100644 --- a/server/entrypoint.sh +++ b/server/entrypoint.sh @@ -1,6 +1,9 @@ #!/bin/sh set -e +script_dir="$(dirname -- "$0")" +. "$script_dir/file-env.sh" + log() { echo "BookOrbit startup: $*" >&2 } @@ -109,6 +112,8 @@ urlencode() { node -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$1" } +load_file_env + if [ -z "$DATABASE_URL" ]; then encoded_user="$(urlencode "$POSTGRES_USER")" encoded_password="$(urlencode "$POSTGRES_PASSWORD")" diff --git a/server/file-env.sh b/server/file-env.sh new file mode 100644 index 000000000..ae04dde88 --- /dev/null +++ b/server/file-env.sh @@ -0,0 +1,79 @@ +#!/bin/sh + +FILE_ENV_MAX_BYTES=65536 + +# POSIX sh has no `local`, so clear the working values instead of leaving secrets +# readable in the calling shell. +file_env_reset() { + unset file_env_name file_var_name direct_value file_path file_value file_size +} + +file_env() { + file_env_name="$1" + file_var_name="${file_env_name}_FILE" + direct_value="$(printenv "$file_env_name" 2>/dev/null || true)" + file_path="$(printenv "$file_var_name" 2>/dev/null || true)" + + if [ -n "$direct_value" ] && [ -n "$file_path" ]; then + echo "BookOrbit startup: $file_env_name and $file_var_name are mutually exclusive." >&2 + file_env_reset + return 1 + fi + + if [ -n "$direct_value" ] || [ -z "$file_path" ]; then + file_env_reset + return 0 + fi + + if [ ! -f "$file_path" ] || [ ! -r "$file_path" ]; then + echo "BookOrbit startup: $file_var_name must point to a readable regular file." >&2 + file_env_reset + return 1 + fi + + file_size="$(wc -c < "$file_path" | tr -d '[:space:]')" + case "$file_size" in + '' | *[!0-9]*) + echo "BookOrbit startup: could not determine the size of $file_var_name." >&2 + file_env_reset + return 1 + ;; + esac + + if [ "$file_size" -gt "$FILE_ENV_MAX_BYTES" ]; then + echo "BookOrbit startup: $file_var_name exceeds the ${FILE_ENV_MAX_BYTES}-byte limit." >&2 + file_env_reset + return 1 + fi + + file_value="$(cat < "$file_path")" + if [ -z "$file_value" ]; then + echo "BookOrbit startup: $file_var_name points to an empty file." >&2 + file_env_reset + return 1 + fi + + export "$file_env_name=$file_value" + unset "$file_var_name" + file_env_reset +} + +load_file_env() { + for pending_env_name in \ + BOOK_REQUEST_ENCRYPTION_KEY \ + DATABASE_URL \ + EMAIL_ENCRYPTION_KEY \ + GITHUB_RELEASES_TOKEN \ + JWT_SECRET \ + MIGRATION_ENCRYPTION_KEY \ + POSTGRES_PASSWORD \ + SETUP_BOOTSTRAP_TOKEN + do + if ! file_env "$pending_env_name"; then + unset pending_env_name + return 1 + fi + done + + unset pending_env_name +} diff --git a/server/src/config/docker-env-file.test.ts b/server/src/config/docker-env-file.test.ts new file mode 100644 index 000000000..8e9720a0f --- /dev/null +++ b/server/src/config/docker-env-file.test.ts @@ -0,0 +1,124 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +const helperPath = resolve(__dirname, '../../file-env.sh'); + +describe('Docker file-backed environment values', () => { + let secretDir: string; + + beforeEach(() => { + secretDir = mkdtempSync(join(tmpdir(), 'bookorbit-file-env-')); + }); + + afterEach(() => { + rmSync(secretDir, { recursive: true, force: true }); + }); + + it('preserves an existing direct value', () => { + const result = runHelper('file_env JWT_SECRET; printf "%s" "$JWT_SECRET"', { + JWT_SECRET: 'direct-secret-value', + }); + + expect(result.status).toBe(0); + expect(result.stdout).toBe('direct-secret-value'); + }); + + it('loads a file value and removes the file variable', () => { + const secretPath = join(secretDir, 'jwt-secret'); + writeFileSync(secretPath, ' file-secret-value \n\n'); + + const result = runHelper('file_env JWT_SECRET; printf "%s|%s" "$JWT_SECRET" "${JWT_SECRET_FILE-unset}"', { + JWT_SECRET: '', + JWT_SECRET_FILE: secretPath, + }); + + expect(result.status).toBe(0); + expect(result.stdout).toBe(' file-secret-value |unset'); + }); + + it('loads application encryption keys', () => { + const secretPath = join(secretDir, 'book-request-key'); + writeFileSync(secretPath, 'book-request-encryption-key'); + + const result = runHelper('load_file_env; printf "%s" "$BOOK_REQUEST_ENCRYPTION_KEY"', { + BOOK_REQUEST_ENCRYPTION_KEY_FILE: secretPath, + }); + + expect(result.status).toBe(0); + expect(result.stdout).toBe('book-request-encryption-key'); + }); + + it('rejects non-empty direct and file values together', () => { + const secretPath = join(secretDir, 'jwt-secret'); + writeFileSync(secretPath, 'file-secret-value'); + + const result = runHelper('file_env JWT_SECRET', { + JWT_SECRET: 'direct-secret-value', + JWT_SECRET_FILE: secretPath, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('JWT_SECRET and JWT_SECRET_FILE are mutually exclusive'); + }); + + it('rejects missing, empty, and oversized files without exposing their paths', () => { + const missingPath = join(secretDir, 'missing-secret'); + const missingResult = runHelper('file_env JWT_SECRET', { JWT_SECRET_FILE: missingPath }); + + expect(missingResult.status).toBe(1); + expect(missingResult.stderr).toContain('JWT_SECRET_FILE must point to a readable regular file'); + expect(missingResult.stderr).not.toContain(missingPath); + + const emptyPath = join(secretDir, 'empty-secret'); + writeFileSync(emptyPath, '\n'); + const emptyResult = runHelper('file_env JWT_SECRET', { JWT_SECRET_FILE: emptyPath }); + + expect(emptyResult.status).toBe(1); + expect(emptyResult.stderr).toContain('JWT_SECRET_FILE points to an empty file'); + expect(emptyResult.stderr).not.toContain(emptyPath); + + const oversizedPath = join(secretDir, 'oversized-secret'); + writeFileSync(oversizedPath, 'x'.repeat(65_537)); + const oversizedResult = runHelper('file_env JWT_SECRET', { JWT_SECRET_FILE: oversizedPath }); + + expect(oversizedResult.status).toBe(1); + expect(oversizedResult.stderr).toContain('JWT_SECRET_FILE exceeds the 65536-byte limit'); + }); + + it('does not leave the loaded secret in a shell variable', () => { + const secretPath = join(secretDir, 'jwt-secret'); + writeFileSync(secretPath, 'file-secret-value'); + + const result = runHelper('load_file_env; printf "%s|%s" "${file_value-unset}" "${direct_value-unset}"', { + JWT_SECRET_FILE: secretPath, + }); + + expect(result.status).toBe(0); + expect(result.stdout).toBe('unset|unset'); + }); + + it('aborts the calling script under set -e', () => { + const secretPath = join(secretDir, 'jwt-secret'); + writeFileSync(secretPath, 'file-secret-value'); + + const result = runHelper('set -e\nload_file_env\nprintf "unreachable"', { + JWT_SECRET: 'direct-secret-value', + JWT_SECRET_FILE: secretPath, + }); + + expect(result.status).toBe(1); + expect(result.stdout).toBe(''); + }); +}); + +function runHelper(script: string, env: NodeJS.ProcessEnv = {}) { + return spawnSync('sh', ['-c', '. "$1"\n' + script, 'sh', helperPath], { + env: { + PATH: process.env.PATH ?? '/usr/bin:/bin', + ...env, + }, + encoding: 'utf8', + }); +}