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)
|
# Required for the initial /auth/setup endpoint (x-setup-token header)
|
||||||
SETUP_BOOTSTRAP_TOKEN=change-this-to-a-long-random-secret
|
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
|
# External/public URL used by emails and Kobo endpoints
|
||||||
APP_URL=https://bookorbit.example.com
|
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
|
# postgres service from docker-compose.yml. Your instance must have the
|
||||||
# uuid-ossp, pg_trgm, and vector (pgvector) extensions available.
|
# uuid-ossp, pg_trgm, and vector (pgvector) extensions available.
|
||||||
# DATABASE_URL=postgres://user:password@yourhost:5432/bookorbit
|
# 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)
|
# 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
|
# 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)
|
# Recommended if you store SMTP provider credentials (generate: openssl rand -hex 32)
|
||||||
# EMAIL_ENCRYPTION_KEY=
|
# 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)
|
# Recommended if you store migration source credentials (generate: openssl rand -hex 32)
|
||||||
# MIGRATION_ENCRYPTION_KEY=
|
# 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.
|
# Absolute container path that authorizes local migration backup files.
|
||||||
# Mount a dedicated host directory at this path as read-only before enabling backup mode.
|
# 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=server-builder --chown=node:node /deploy ./
|
||||||
COPY --from=client-builder --chown=node:node /app/client/dist ./public
|
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 LICENSE NOTICE ADDITIONAL_TERMS.md ./
|
||||||
COPY --chown=node:node server/bin/kepubify/ ./bin/kepubify/
|
COPY --chown=node:node server/bin/kepubify/ ./bin/kepubify/
|
||||||
COPY --chown=node:node koreader-plugin/bookorbit.koplugin/ ./koreader-plugin/bookorbit.koplugin/
|
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
|
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
|
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.
|
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 `/`.
|
Optionally set `LIBRARY_BROWSE_ROOT=/books` to start the library folder picker at `/books` instead of `/`.
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
script_dir="$(dirname -- "$0")"
|
||||||
|
. "$script_dir/file-env.sh"
|
||||||
|
|
||||||
log() {
|
log() {
|
||||||
echo "BookOrbit startup: $*" >&2
|
echo "BookOrbit startup: $*" >&2
|
||||||
}
|
}
|
||||||
@@ -109,6 +112,8 @@ urlencode() {
|
|||||||
node -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$1"
|
node -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
load_file_env
|
||||||
|
|
||||||
if [ -z "$DATABASE_URL" ]; then
|
if [ -z "$DATABASE_URL" ]; then
|
||||||
encoded_user="$(urlencode "$POSTGRES_USER")"
|
encoded_user="$(urlencode "$POSTGRES_USER")"
|
||||||
encoded_password="$(urlencode "$POSTGRES_PASSWORD")"
|
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