mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(docker): support file-backed secrets
Load mounted secrets before database migrations while preserving direct environment values for existing deployments. Closes #373
This commit is contained in:
@@ -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.
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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 `/`.
|
||||
|
||||
@@ -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")"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user