diff --git a/Dockerfile b/Dockerfile index 341da936146..a9224af64e8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -86,6 +86,7 @@ LABEL org.opencontainers.image.licenses="Apache-2.0" LABEL org.opencontainers.image.vendor="changedetection.io" RUN apt-get update && apt-get install -y --no-install-recommends \ + gosu \ libxslt1.1 \ # For presenting price amounts correctly in the restock/price detection overview locales \ @@ -101,18 +102,29 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libxrender-dev \ && apt-get clean && rm -rf /var/lib/apt/lists/* +# Create unprivileged user and required directories +RUN groupadd -g 911 changedetection && \ + useradd -u 911 -g 911 -M -s /bin/false changedetection && \ + mkdir -p /datastore /extra_packages && \ + chown changedetection:changedetection /extra_packages # https://stackoverflow.com/questions/58701233/docker-logs-erroneously-appears-empty-until-container-stops ENV PYTHONUNBUFFERED=1 - -RUN [ ! -d "/datastore" ] && mkdir /datastore +# Redirect .pyc cache to a writable location since /app is root-owned. +# To disable bytecode caching entirely, set PYTHONDONTWRITEBYTECODE=1 at runtime. +ENV PYTHONPYCACHEPREFIX=/tmp/pycache +# Disable pytest's .pytest_cache directory (also writes to /app, which is root-owned). +# Only has an effect when running tests inside the container. +ENV PYTEST_ADDOPTS="-p no:cacheprovider" +# Redirect test logs to the datastore (writable) instead of /app/tests/logs (read-only in container). +ENV TEST_LOG_DIR=/datastore/test_logs # Re #80, sets SECLEVEL=1 in openssl.conf to allow monitoring sites with weak/old cipher suites RUN sed -i 's/^CipherString = .*/CipherString = DEFAULT@SECLEVEL=1/' /etc/ssl/openssl.cnf # Copy modules over to the final image and add their dir to PYTHONPATH COPY --from=builder /dependencies /usr/local -ENV PYTHONPATH=/usr/local +ENV PYTHONPATH=/usr/local:/extra_packages EXPOSE 5000 diff --git a/changedetectionio/tests/conftest.py b/changedetectionio/tests/conftest.py index 9212681c92d..2a7c08a65e7 100644 --- a/changedetectionio/tests/conftest.py +++ b/changedetectionio/tests/conftest.py @@ -39,8 +39,9 @@ def per_test_log_file(request): """Create a separate log file for each test function with pytest output.""" import re - # Create logs directory if it doesn't exist - log_dir = os.path.join(os.path.dirname(__file__), "logs") + # Create logs directory if it doesn't exist. + # TEST_LOG_DIR can be overridden e.g. to a writable path when /app is read-only (Docker). + log_dir = os.environ.get('TEST_LOG_DIR', os.path.join(os.path.dirname(__file__), "logs")) os.makedirs(log_dir, exist_ok=True) # Generate log filename from test name and worker ID (for parallel runs) diff --git a/docker-compose.yml b/docker-compose.yml index d15b1dece2d..3cb2804d52f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,12 @@ services: # - ./proxies.json:/datastore/proxies.json # environment: + # Run as a specific user/group (UID:GID). Defaults to 911:911. + # The container will automatically fix datastore ownership on first start if needed. + # Set SKIP_CHOWN=1 to disable the ownership migration (e.g. if you manage permissions yourself). + # - PUID=1000 + # - PGID=1000 + # # Default listening port, can also be changed with the -p option (not to be confused with ports: below) # - PORT=5000 # @@ -80,8 +86,9 @@ services: # RAM usage will be higher if you increase this. # - SCREENSHOT_MAX_HEIGHT=16000 # - # HTTPS SSL Mode for webserver, unset both of these, you may need to volume mount these files also. + # HTTPS SSL Mode for webserver, volume mount the cert files and set these env vars. # ./cert.pem:/app/cert.pem and ./privkey.pem:/app/privkey.pem + # Permissions are fixed automatically on startup. # - SSL_CERT_FILE=cert.pem # - SSL_PRIVKEY_FILE=privkey.pem # @@ -95,6 +102,8 @@ services: ports: - 127.0.0.1:5000:5000 restart: unless-stopped + security_opt: + - no-new-privileges:true # Used for fetching pages via WebDriver+Chrome where you need Javascript support. # Now working on arm64 (needs testing on rPi - tested on Oracle ARM instance) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index ac243289c84..66212508479 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,28 +1,68 @@ #!/bin/bash -set -e +set -eu -# Install additional packages from EXTRA_PACKAGES env var -# Uses a marker file to avoid reinstalling on every container restart -INSTALLED_MARKER="/datastore/.extra_packages_installed" -CURRENT_PACKAGES="$EXTRA_PACKAGES" +DATASTORE_PATH="${DATASTORE_PATH:-/datastore}" -if [ -n "$EXTRA_PACKAGES" ]; then - # Check if we need to install/update packages - if [ ! -f "$INSTALLED_MARKER" ] || [ "$(cat $INSTALLED_MARKER 2>/dev/null)" != "$CURRENT_PACKAGES" ]; then - echo "Installing extra packages: $EXTRA_PACKAGES" - pip3 install --no-cache-dir $EXTRA_PACKAGES - - if [ $? -eq 0 ]; then - echo "$CURRENT_PACKAGES" > "$INSTALLED_MARKER" - echo "Extra packages installed successfully" - else - echo "ERROR: Failed to install extra packages" - exit 1 +# ----------------------------------------------------------------------- +# Phase 1: Running as root — fix up PUID/PGID and datastore ownership, +# then re-exec as the unprivileged changedetection user via gosu. +# ----------------------------------------------------------------------- +if [ "$(id -u)" = '0' ]; then + PUID=${PUID:-911} + PGID=${PGID:-911} + + groupmod -o -g "$PGID" changedetection + usermod -o -u "$PUID" changedetection + + # Keep /extra_packages writable by the (potentially re-mapped) user + chown changedetection:changedetection /extra_packages + + # One-time ownership migration: only chown if the datastore isn't already + # owned by the target UID (e.g. existing root-owned installations). + if [ -z "${SKIP_CHOWN:-}" ]; then + datastore_uid=$(stat -c '%u' "$DATASTORE_PATH") + if [ "$datastore_uid" != "$PUID" ]; then + echo "Updating $DATASTORE_PATH ownership to $PUID:$PGID (one-time migration)..." + chown -R changedetection:changedetection "$DATASTORE_PATH" + echo "Done." + fi + fi + + # Fix SSL certificate permissions so the unprivileged user can read them. + # SSL_CERT_FILE / SSL_PRIVKEY_FILE may be relative (to /app) or absolute. + fix_ssl_perm() { + local file="$1" mode="$2" + [ -z "$file" ] && return + [ "${file:0:1}" != "/" ] && file="/app/$file" + if [ -f "$file" ]; then + chown changedetection:changedetection "$file" + chmod "$mode" "$file" fi + } + fix_ssl_perm "${SSL_CERT_FILE:-}" 644 + fix_ssl_perm "${SSL_PRIVKEY_FILE:-}" 600 + + # Re-exec this script as the unprivileged user + exec gosu changedetection:changedetection "$0" "$@" +fi + +# ----------------------------------------------------------------------- +# Phase 2: Running as unprivileged user — install any EXTRA_PACKAGES into +# /extra_packages (already on PYTHONPATH) then exec the app. +# ----------------------------------------------------------------------- + +# Install additional packages from EXTRA_PACKAGES env var. +# Uses a marker file in the datastore to avoid reinstalling on every restart. +if [ -n "${EXTRA_PACKAGES:-}" ]; then + INSTALLED_MARKER="${DATASTORE_PATH}/.extra_packages_installed" + if [ ! -f "$INSTALLED_MARKER" ] || [ "$(cat "$INSTALLED_MARKER" 2>/dev/null)" != "$EXTRA_PACKAGES" ]; then + echo "Installing extra packages: $EXTRA_PACKAGES" + pip3 install --target=/extra_packages --no-cache-dir $EXTRA_PACKAGES + echo "$EXTRA_PACKAGES" > "$INSTALLED_MARKER" + echo "Extra packages installed successfully" else echo "Extra packages already installed: $EXTRA_PACKAGES" fi fi -# Execute the main command exec "$@"