#!/bin/bash # LibrePortal ownership-reconcile helper — the ONLY root-privileged file-ownership # operation the manager is allowed to trigger via sudo. # # Why this exists: under Model A the runtime executes AS the manager (libreportal), # so establishing the ownership model (manager owns the control plane, the docker # install user owns the containers) needs root. Granting the manager a blanket # `sudo chown`/`sudo chmod` in the scoped sudoers would be root-equivalent (chown # /etc/sudoers, etc.). Instead this script — installed root:root 0755 to # /usr/local/sbin by init.sh, so the manager cannot modify it — performs a FIXED # set of reconciles on FIXED LibrePortal paths only. Owners are derived from # config + the baked manager name, never from the caller; the single free argument # (an app name) is strictly validated and must resolve to an existing dir under # /docker/containers. # # Self-contained ON PURPOSE: it must NOT source any manager-owned code, or it # would re-open the very escalation it exists to close. init.sh is the source of # truth for the install; it bakes the manager name into the installed copy. set -u [[ $EUID -eq 0 ]] || { echo "libreportal-ownership: must run as root" >&2; exit 1; } # Baked by init.sh at install (placeholder replaced); default if run unbaked. MANAGER="__MANAGER__" [[ "$MANAGER" == "__MANAGER__" || -z "$MANAGER" ]] && MANAGER="libreportal" DOCKER_DIR="/docker" CONFIGS_DIR="$DOCKER_DIR/configs" LOGS_DIR="$DOCKER_DIR/logs" INSTALL_DIR="$DOCKER_DIR/install" CONTAINERS_DIR="$DOCKER_DIR/containers" SSL_DIR="$DOCKER_DIR/ssl" SSH_DIR="$DOCKER_DIR/ssh" BACKUP_DIR="$DOCKER_DIR/backups" RESTORE_DIR="$DOCKER_DIR/restore" MIGRATE_DIR="$DOCKER_DIR/migrate" DB_PATH="$DOCKER_DIR/database.db" WEBUI_DIR="$CONTAINERS_DIR/libreportal" TASK_DIR="$WEBUI_DIR/frontend/data/tasks" DB_CFG="$CONFIGS_DIR/general/general_docker_install" # Current docker mode, read authoritatively from config. _mode() { local m m=$(grep -h '^CFG_DOCKER_INSTALL_TYPE=' "$DB_CFG" 2>/dev/null | head -1 | cut -d= -f2 | awk '{print $1}') echo "${m:-rootless}" } # Who owns container data for a mode: rooted -> the manager; rootless -> the # configured docker install user (must be a real account, else fall back). _container_owner() { local mode="$1" appusr="" if [[ "$mode" == "rootless" ]]; then appusr=$(grep -h '^CFG_DOCKER_INSTALL_USER=' "$DB_CFG" 2>/dev/null | head -1 | cut -d= -f2 | awk '{print $1}') if [[ -n "$appusr" ]] && id -u "$appusr" >/dev/null 2>&1; then echo "$appusr"; return fi echo "dockerinstall" else echo "$MANAGER" fi } # Validate + resolve an app name to its container dir (reject traversal/odd names). _app_dir() { local app="$1" [[ "$app" =~ ^[A-Za-z0-9._-]+$ && "$app" != "." && "$app" != ".." ]] \ || { echo "libreportal-ownership: invalid app name" >&2; return 1; } local d="$CONTAINERS_DIR/$app" [[ -d "$d" ]] || { echo "libreportal-ownership: no such app dir: $d" >&2; return 1; } printf '%s' "$d" } # Control plane -> manager; structural container dirs -> container owner. Owner # only, never reset mode bits; only ADD o+x (traversal) and o+r (DB read). reconcile() { local mode="${1:-$(_mode)}" [[ -d "$DOCKER_DIR" ]] || return 0 chown "$MANAGER:$MANAGER" "$DOCKER_DIR" chmod o+x "$DOCKER_DIR" local p for p in "$CONFIGS_DIR" "$LOGS_DIR" "$INSTALL_DIR" "$DB_PATH"; do [[ -e "$p" ]] && chown -R "$MANAGER:$MANAGER" "$p" done [[ -f "$DB_PATH" ]] && chmod o+r "$DB_PATH" local cowner; cowner="$(_container_owner "$mode")" if [[ -d "$CONTAINERS_DIR" ]]; then chown "$cowner:$cowner" "$CONTAINERS_DIR" chmod o+x "$CONTAINERS_DIR" fi [[ -d "$WEBUI_DIR" ]] && chown -R "$cowner:$cowner" "$WEBUI_DIR" } # Traversal (+x) bits only, on the structural LibrePortal dirs. traversal() { [[ -d "$DOCKER_DIR" ]] && chmod +x "$DOCKER_DIR" local d for d in "$INSTALL_DIR" "$SSL_DIR" "$SSH_DIR" "$BACKUP_DIR" "$RESTORE_DIR" "$MIGRATE_DIR"; do [[ -d "$d" ]] && find "$d" -maxdepth 2 -type d -exec chmod +x {} \; done local mode cowner; mode="$(_mode)"; cowner="$(_container_owner "$mode")" if [[ -d "$CONTAINERS_DIR" ]]; then chown "$cowner:$cowner" "$CONTAINERS_DIR" chmod o+x "$CONTAINERS_DIR" fi } # Per-app structural perms + ownership of the LibrePortal-managed files only. app_perms() { [[ -d "$CONTAINERS_DIR" ]] || return 0 local mode cowner; mode="$(_mode)"; cowner="$(_container_owner "$mode")" chmod +x "$CONTAINERS_DIR" 2>/dev/null local app_dir app f for app_dir in "$CONTAINERS_DIR"/*/; do [[ -d "$app_dir" ]] || continue app="$(basename "$app_dir")" chmod +x "$app_dir" 2>/dev/null chmod o+r "$app_dir" 2>/dev/null find "$app_dir" -type f -name '*docker-compose*' -exec chmod o+r {} \; for f in migrate.txt "$app.config" docker-compose.yml "docker-compose.$app.yml"; do [[ -e "$app_dir$f" ]] && chown "$cowner:$cowner" "$app_dir$f" done done } # LibrePortal's own (regenerable) WebUI container dir -> container owner. webui() { local mode cowner; mode="$(_mode)"; cowner="$(_container_owner "$mode")" [[ -d "$WEBUI_DIR" ]] && chown -R "$cowner:$cowner" "$WEBUI_DIR" } # Structural containers/ top dir only -> container owner + traversable. containers_top() { local mode cowner; mode="$(_mode)"; cowner="$(_container_owner "$mode")" if [[ -d "$CONTAINERS_DIR" ]]; then chown "$cowner:$cowner" "$CONTAINERS_DIR" chmod o+x "$CONTAINERS_DIR" fi } # The task IPC dir -> container owner (reclaims stale manager/root-owned files). taskdir() { local mode cowner; mode="$(_mode)"; cowner="$(_container_owner "$mode")" [[ -d "$TASK_DIR" ]] && chown -R "$cowner:$cowner" "$TASK_DIR" } # Some apps' data must be owned by nobody (65534) inside the container. app_data_nobody() { local d; d="$(_app_dir "${1:-}")" || return 1 [[ -d "$d/data" ]] && chown -R 65534:65534 "$d/data" } action="${1:-}"; shift 2>/dev/null || true case "$action" in reconcile) reconcile "${1:-}";; traversal) traversal;; containers-top) containers_top;; app-perms) app_perms;; webui) webui;; taskdir) taskdir;; app-data-nobody) app_data_nobody "${1:-}";; *) echo "usage: libreportal-ownership {reconcile [mode]|traversal|containers-top|app-perms|webui|taskdir|app-data-nobody }" >&2; exit 2;; esac