#!/bin/sh # Topic Watch container entrypoint. # # Bind-mounted volumes (./data:/app/data) keep their HOST ownership, so a host # user whose UID/GID is not 1000 cannot write /app/data and initialization # fails with "permission denied". To stay portable, this entrypoint: # # 1. When started as root, aligns the runtime user with the host-provided # PUID/PGID (default 1000:1000), chowns /app/data to match, then drops # privileges with gosu so the app never runs as root. # 2. When already started as a non-root user (e.g. compose `user:` override), # runs the command as-is without attempting any privileged operation. # # Idempotent: re-running adjusts the existing appuser/appgroup in place. set -eu PUID="${PUID:-1000}" PGID="${PGID:-1000}" DATA_DIR="/app/data" # Resolve the directory that will hold the SQLite database. db_path is a # first-class configurable (TOPIC_WATCH_DB_PATH); it defaults to a relative # path under /app/data, but an absolute or relocated value can point outside # the bind-mounted volume. Such a directory is NOT chowned/probed by the # /app/data logic below, so the dropped appuser would crash in init_db with # "permission denied" (OVH-121). Surface it as its own path to handle. DEFAULT_DB_PATH="data/topic_watch.db" db_path="${TOPIC_WATCH_DB_PATH:-$DEFAULT_DB_PATH}" case "$db_path" in /*) db_dir="$(dirname "$db_path")" ;; # absolute → use as-is *) db_dir="/app/$(dirname "$db_path")" ;; # relative → resolved from /app esac db_basename="$(basename "$db_path")" # When the DB dir already lives inside the data volume, the /app/data handling # covers it — collapse to DATA_DIR so the common case touches a single path. case "$db_dir/" in "$DATA_DIR"/* | "$DATA_DIR/") db_dir="$DATA_DIR" ;; esac # Validate that PUID and PGID are numeric. case "$PUID" in *[!0-9]*) echo "ERROR: PUID must be a numeric user ID (got: '$PUID')" >&2; exit 1;; esac case "$PGID" in *[!0-9]*) echo "ERROR: PGID must be a numeric group ID (got: '$PGID')" >&2; exit 1;; esac # Warn loudly when asked to run as root (privilege drop would be a no-op). if [ "$PUID" = "0" ]; then echo "WARNING: PUID=0 — the app will run as root, which defeats privilege drop." >&2 fi # Resolve a directory to its real, symlink-free absolute path. Requires the # directory to already exist. Prints nothing and returns non-zero if it # cannot be resolved (missing, or not traversable). canon_dir() { ( cd "$1" 2>/dev/null && pwd -P ) } # AUG-007: a relocated TOPIC_WATCH_DB_PATH can resolve its parent to the # filesystem root or another broad system directory — e.g. "/topic_watch.db" # resolves to "/", and "../topic_watch.db" resolves to the directory above # /app. Refuse to treat any of these as a database directory at all, so # neither the chown nor the writability probe below ever touches them. is_forbidden_chown_target() { case "$1" in ""|/) return 0 ;; /bin|/boot|/dev|/etc|/home|/lib|/lib64|/media|/mnt|/opt|/proc|/root|/run|/sbin|/srv|/sys|/tmp|/usr|/var) return 0 ;; *) return 1 ;; esac } if [ "$db_dir" != "$DATA_DIR" ]; then mkdir -p "$db_dir" 2>/dev/null || true resolved_db_dir="$(canon_dir "$db_dir" 2>/dev/null || true)" if [ -n "$resolved_db_dir" ]; then if is_forbidden_chown_target "$resolved_db_dir"; then echo "ERROR: TOPIC_WATCH_DB_PATH ('$db_path') resolves its directory to" >&2 echo " '$resolved_db_dir', a filesystem root or system directory." >&2 echo " Refusing to touch it. Point TOPIC_WATCH_DB_PATH at a dedicated" >&2 echo " subdirectory (e.g. a mounted volume) instead." >&2 exit 1 fi db_dir="$resolved_db_dir" fi # else: directory could not be resolved yet (e.g. not writable as a # non-root user) — fall through and let the probe/chown below produce # their own actionable error. fi # Probe one directory for writability by the current user, failing with an # actionable message instead of crashing deep inside init_db. Used on the # non-root path, where we cannot chown to fix ownership ourselves. probe_writable() { probe_dir="$1" mkdir -p "$probe_dir" 2>/dev/null || true if { touch "$probe_dir/.wtest" 2>/dev/null && rm -f "$probe_dir/.wtest"; }; then return 0 fi cur_uid="$(id -u)" cur_gid="$(id -g)" dir_owner_uid="$(stat -c %u "$probe_dir" 2>/dev/null || echo '?')" dir_owner_gid="$(stat -c %g "$probe_dir" 2>/dev/null || echo '?')" echo "ERROR: $probe_dir is not writable by uid=$cur_uid gid=$cur_gid" >&2 echo " (the directory is owned by uid=$dir_owner_uid gid=$dir_owner_gid)." >&2 echo " The container was started as a non-root user (e.g. a compose 'user:' override)," >&2 echo " so it cannot fix volume ownership itself. Remediate on the host with:" >&2 echo " chown -R $cur_uid:$cur_gid " >&2 echo " or drop the 'user:' override and set PUID/PGID to match the host owner." >&2 return 1 } # If we are not root, we cannot chown or change identity. Verify the data # volume — and the DB directory when it lives outside the volume — are writable # by the current (non-root) user. This is a diagnostic, not a privileged op # (which couldn't run as non-root anyway). if [ "$(id -u)" != "0" ]; then probe_writable "$DATA_DIR" || exit 1 if [ "$db_dir" != "$DATA_DIR" ]; then probe_writable "$db_dir" || exit 1 fi exec "$@" fi # Align the appgroup GID with PGID (idempotent). current_gid="$(getent group appgroup | cut -d: -f3 || true)" if [ "$current_gid" != "$PGID" ]; then groupmod -o -g "$PGID" appgroup fi # Align the appuser UID with PUID (idempotent). current_uid="$(id -u appuser 2>/dev/null || true)" if [ "$current_uid" != "$PUID" ]; then usermod -o -u "$PUID" appuser fi # Ensure a directory exists and is owned by the runtime user, recursively. # Skip the recursive walk when directory-level ownership already matches — it # is expensive on large bind-mounted volumes and unnecessary when the UID/GID # haven't changed. Only ever called on DATA_DIR or a known-bounded # subdirectory of it (never on an arbitrary configured db_dir — AUG-007). chown_dir_recursive() { target_dir="$1" mkdir -p "$target_dir" dir_uid="$(stat -c %u "$target_dir")" dir_gid="$(stat -c %g "$target_dir")" if [ "$dir_uid" != "$PUID" ] || [ "$dir_gid" != "$PGID" ]; then chown -R "$PUID:$PGID" "$target_dir" fi } # Chown a single existing path (file or directory entry) if its ownership # doesn't already match. Never recursive, so it is safe to call on a path # inside an arbitrary configured directory. chown_path_if_needed() { p="$1" [ -e "$p" ] || return 0 p_uid="$(stat -c %u "$p")" p_gid="$(stat -c %g "$p")" if [ "$p_uid" != "$PUID" ] || [ "$p_gid" != "$PGID" ]; then chown "$PUID:$PGID" "$p" fi } # Repair ownership of the exact database + sidecar files under a directory, # regardless of whether the containing directory's own ownership already # matches (AUG-057: a directory created with the right owner can still hold # root-owned files copied/restored in afterwards). Never recursive. chown_db_files() { dir="$1" base="$2" chown_path_if_needed "$dir/$base" chown_path_if_needed "$dir/$base-wal" chown_path_if_needed "$dir/$base-shm" chown_path_if_needed "$dir/$base-journal" } # Ensure the data volume itself is writable by the runtime user, and that the # config file and database (+ sidecars) it holds are individually correctly # owned even when the directory ownership already matched (AUG-057). chown_dir_recursive "$DATA_DIR" chown_path_if_needed "$DATA_DIR/config.yml" if [ "$db_dir" = "$DATA_DIR" ]; then chown_db_files "$DATA_DIR" "$db_basename" else # When the DB lives outside the data volume (absolute/relocated db_path), # its parent is not covered above and would stay root-owned (OVH-121). Fix # it WITHOUT recursing an arbitrary configured directory (AUG-007): chown # only the directory entry itself and the exact database + sidecar files. chown_path_if_needed "$db_dir" chown_db_files "$db_dir" "$db_basename" fi # Backups live alongside the database file (app/database.py _backup_db) and # are capped at 5 files by the app itself, so recursing just this # app-managed subdirectory is cheap and safe regardless of where the # database lives. if [ -d "$db_dir/backups" ]; then chown_dir_recursive "$db_dir/backups" fi # Drop privileges and run the app as the (now host-aligned) appuser. exec gosu "$PUID:$PGID" "$@"