#!/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 + backups) needs root. Granting the manager a # blanket `sudo chown`/`sudo chmod` would be root-equivalent (chown /etc/sudoers, # etc.). Instead this script — installed root:root 0755 to /usr/local/lib/libreportal/ # by init.sh, so the manager cannot modify it — performs a FIXED set of reconciles # on FIXED LibrePortal paths only. The roots and the manager name are BAKED at # install (sed placeholders), never read at runtime from a manager-writable config; # the single free argument (an app name / relpath) is strictly validated. # # Layout — three independently-relocatable roots, each owned by ONE principal: # SYSTEM_DIR manager-owned control plane (configs/logs/install/db/ssl/ssh/…) # CONTAINERS_DIR container-user-owned live app data (apps live directly under it) # BACKUPS_DIR container-user-owned backup repos (own mount-able) # # Self-contained ON PURPOSE: it must NOT source any manager-owned code (incl. # paths.sh), 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 values 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 (placeholders replaced). An unbaked copy (run # directly from the repo before baking) still contains the "__" sentinel, which no # real absolute path does — fall back to the defaults in that case only. MANAGER="__MANAGER__" CONTAINERS_DIR="__CONTAINERS_DIR__" BACKUPS_DIR="__BACKUPS_DIR__" SYSTEM_DIR="__SYSTEM_DIR__" [[ "$MANAGER" == *"__"* || -z "$MANAGER" ]] && MANAGER="libreportal" [[ "$SYSTEM_DIR" == *"__"* || -z "$SYSTEM_DIR" ]] && SYSTEM_DIR="/libreportal-system" [[ "$CONTAINERS_DIR" == *"__"* || -z "$CONTAINERS_DIR" ]] && CONTAINERS_DIR="/libreportal-containers" [[ "$BACKUPS_DIR" == *"__"* || -z "$BACKUPS_DIR" ]] && BACKUPS_DIR="/libreportal-backups" # Refuse to operate on dangerous roots even if mis-baked (defence in depth). for _d in "$SYSTEM_DIR" "$CONTAINERS_DIR" "$BACKUPS_DIR"; do case "$_d" in /|/etc|/usr|/bin|/sbin|/lib|/lib64|/boot|/proc|/sys|/dev|/run|/home|/root|/var|/tmp) echo "libreportal-ownership: refusing dangerous root '$_d'" >&2; exit 1 ;; /*) ;; # absolute — ok *) echo "libreportal-ownership: root must be absolute: '$_d'" >&2; exit 1 ;; esac done CONFIGS_DIR="$SYSTEM_DIR/configs" LOGS_DIR="$SYSTEM_DIR/logs" INSTALL_DIR="$SYSTEM_DIR/install" SSL_DIR="$SYSTEM_DIR/ssl" SSH_DIR="$SYSTEM_DIR/ssh" RESTORE_DIR="$SYSTEM_DIR/restore" MIGRATE_DIR="$SYSTEM_DIR/migrate" # Storage-location bookkeeping the manager writes (the app -> root index). # Deliberately NOT under configs/: that tree is SOURCED, and a data file there # is executed. See storageIndexFile in scripts/source/paths.sh. STORAGE_DIR="$SYSTEM_DIR/storage" DB_PATH="$SYSTEM_DIR/database.db" WEBUI_DIR="$CONTAINERS_DIR/libreportal" TASK_DIR="$WEBUI_DIR/frontend/data/tasks" DB_CFG="$CONFIGS_DIR/general/general_docker_install" # Root-owned storage registry — the only authority on which roots may hold apps. STORAGE_REGISTRY="/usr/local/lib/libreportal/storage.roots" # Current docker mode, read authoritatively from config (read-only — informs the # container OWNER choice, not any path). _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/backup 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). # # An app does not have to be under CONTAINERS_DIR: it can live on any REGISTERED # storage location. Looking only in the primary root meant every action keyed on # an app name silently did nothing for those — app-data-remove returned 0 having # removed nothing, so restore's "Wiping existing app folder" wiped nothing and a # restore laid new data over old. # # Candidate roots come from the root-owned registry, never from the caller, so # this still cannot be pointed anywhere root does not already own. _app_dir() { local app="$1" [[ "$app" =~ ^[A-Za-z0-9._-]+$ && "$app" != "." && "$app" != ".." ]] \ || { echo "libreportal-ownership: invalid app name" >&2; return 1; } if [[ -d "$CONTAINERS_DIR/$app" ]]; then printf '%s' "$CONTAINERS_DIR/$app" return 0 fi if [[ -r "$STORAGE_REGISTRY" ]]; then local _id _path _rest while IFS=$'\t' read -r _id _path _rest || [[ -n "$_id" ]]; do [[ -z "$_path" || "$_id" == \#* ]] && continue if [[ -d "${_path%/}/$app" ]]; then printf '%s' "${_path%/}/$app" return 0 fi done < "$STORAGE_REGISTRY" fi echo "libreportal-ownership: no such app dir for '$app'" >&2 return 1 } # Let the rootless container user reach the few system-tree files it must read as # bind-mount sources (the WebUI's configs/webui/*), WITHOUT exposing the rest of # the control plane — or those files' contents to other local users. # # Access is granted via the GROUP, not world: under rootless the container's gid 0 # maps to the container owner's gid on the host, so group-read is enough for the # container while other local users get nothing. Owner stays the manager so the # control plane can still rewrite them; the dir keeps only o+x (traverse, not list). # This is what keeps secrets like webui_logins from being world-readable. _webui_bind_access() { chmod o+x "$SYSTEM_DIR" 2>/dev/null [[ -d "$CONFIGS_DIR" ]] && chmod o+x "$CONFIGS_DIR" 2>/dev/null if [[ -d "$CONFIGS_DIR/webui" ]]; then local cowner; cowner="$(_container_owner "$(_mode)")" chown "$MANAGER:$cowner" "$CONFIGS_DIR/webui" 2>/dev/null chmod 0751 "$CONFIGS_DIR/webui" 2>/dev/null find "$CONFIGS_DIR/webui" -maxdepth 1 -type f \ -exec chown "$MANAGER:$cowner" {} \; -exec chmod 0640 {} \; 2>/dev/null fi } # A one-shot channel for a secret typed in the browser. # # The mirror of _webui_bind_access above. That one makes manager-owned config # READABLE by the container; this makes a container-written file readable by the # MANAGER — so a password entered in the WebUI never has to travel as part of a # task command string. Those land in frontend/data/tasks/*.json, which is 0644 # inside a world-readable directory, so every local account can read them; a # backup repository password is the key to every backup the user has. # # cowner:MANAGER with the setgid bit: the container owns the directory and can # create in it, setgid gives each new file the manager's group, and the container # writes the file 0640 — readable by the manager, by nobody else. Mode 0730 # leaves the directory unlistable on purpose: the manager is handed a filename, # it never enumerates. Group rwx is what lets the manager unlink after reading. secret_dir() { local mode cowner; mode="$(_mode)"; cowner="$(_container_owner "$mode")" local parent="$WEBUI_DIR/frontend/data" # Create the parent rather than requiring it. The install reconciles WebUI # ownership before the container has made frontend/data, so demanding it # already exist meant this returned 1 there and the drop was simply absent # on a fresh install — silently, since the caller has no reason to check. # Ownership is only set when we are the ones creating it; an existing # directory belongs to the container and is left exactly as it is. if [[ ! -d "$parent" ]]; then mkdir -p -- "$parent" || return 1 chown "$cowner:$cowner" -- "$parent" || return 1 fi local d="$parent/.secrets" mkdir -p -- "$d" || return 1 chown "$cowner:$MANAGER" -- "$d" || return 1 chmod 2730 -- "$d" || return 1 return 0 } # Control plane -> manager; container + backup roots -> container owner. reconcile() { local mode="${1:-$(_mode)}" local cowner; cowner="$(_container_owner "$mode")" if [[ -d "$SYSTEM_DIR" ]]; then chown "$MANAGER:$MANAGER" "$SYSTEM_DIR" local p for p in "$CONFIGS_DIR" "$LOGS_DIR" "$INSTALL_DIR" "$SSL_DIR" "$SSH_DIR" \ "$RESTORE_DIR" "$MIGRATE_DIR" "$STORAGE_DIR" "$DB_PATH"; do [[ -e "$p" ]] && chown -R "$MANAGER:$MANAGER" "$p" done [[ -f "$DB_PATH" ]] && chmod o+r "$DB_PATH" _webui_bind_access fi # Data + backups: wholly the container owner's (rootless requires it; this is # also what lets restic — which runs AS that user — write the backup repos). local d for d in "$CONTAINERS_DIR" "$BACKUPS_DIR"; do if [[ -d "$d" ]]; then chown "$cowner:$cowner" "$d" chmod o+x "$d" fi done [[ -d "$WEBUI_DIR" ]] && chown -R "$cowner:$cowner" "$WEBUI_DIR" } # Traversal (+x) bits only, on the structural LibrePortal dirs. traversal() { [[ -d "$SYSTEM_DIR" ]] && chmod o+x "$SYSTEM_DIR" local d for d in "$INSTALL_DIR" "$SSL_DIR" "$SSH_DIR" "$RESTORE_DIR" "$MIGRATE_DIR"; do [[ -d "$d" ]] && find "$d" -maxdepth 2 -type d -exec chmod +x {} \; done _webui_bind_access local mode cowner; mode="$(_mode)"; cowner="$(_container_owner "$mode")" for d in "$CONTAINERS_DIR" "$BACKUPS_DIR"; do if [[ -d "$d" ]]; then chown "$cowner:$cowner" "$d" chmod o+x "$d" fi done } # 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" } # Ensure the apps DB is manager-owned + world-readable (reclaims a stray # root/other-owned DB; the WebUI reads it). db_own() { [[ -f "$DB_PATH" ]] || return 0 chown "$MANAGER:$MANAGER" "$DB_PATH" chmod o+r "$DB_PATH" } # 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 backups root -> container owner + traversable (restic runs AS that user). backups_top() { local mode cowner; mode="$(_mode)"; cowner="$(_container_owner "$mode")" if [[ -d "$BACKUPS_DIR" ]]; then chown "$cowner:$cowner" "$BACKUPS_DIR" chmod o+x "$BACKUPS_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" } # Wipe an entire app data tree, including container sub-UID dirs the # manager / dockerinstall user can't reach (e.g. invidious/postgresdata uid # 232070, nextcloud/html uid 33). Used by uninstall + restore-overwrite — # both previously ran `rm -rf` via runFileOp (= as dockerinstall) and silently # left sub-UID dirs behind, breaking reinstall + leaking storage. # Idempotent: a missing dir is success (caller wants "ensure gone"). Refuses # the WebUI's own slot (libreportal) — removing it would brick the WebUI. app_data_remove() { local app="${1:-}" [[ "$app" =~ ^[A-Za-z0-9._-]+$ && "$app" != "." && "$app" != ".." ]] \ || { echo "libreportal-ownership: invalid app name" >&2; return 1; } [[ "$app" == "libreportal" ]] \ && { echo "libreportal-ownership: refusing to remove the WebUI app dir" >&2; return 1; } # Through the resolver, so an app on a registered location is found rather # than quietly skipped. local d d=$(_app_dir "$app" 2>/dev/null) || return 0 rm -rf -- "$d" } # Move an app directory to another storage root. # # Root because it must be: app data holds files owned by rootless sub-UIDs # (postgres at 231141 and friends) that the manager can neither read nor # recreate, so an unprivileged copy silently loses them. # # The destination root is validated against the ROOT-OWNED storage registry, not # against anything the caller says — the same trust boundary as everywhere else # here. A path that is not a registered location is refused outright. # # Copy-verify-then-delete, never `mv` across filesystems: a half-completed move # that has already unlinked the source is unrecoverable, and this runs on the # only copy of someone's data. app_move() { local app="${1:-}" dest_root="${2:-}" [[ "$app" =~ ^[A-Za-z0-9._-]+$ && "$app" != "." && "$app" != ".." ]] \ || { echo "libreportal-ownership: invalid app name" >&2; return 1; } [[ "$app" == "libreportal" ]] \ && { echo "libreportal-ownership: the WebUI app dir cannot be moved" >&2; return 1; } [[ -n "$dest_root" && "$dest_root" == /* ]] \ || { echo "libreportal-ownership: destination must be an absolute path" >&2; return 1; } dest_root="${dest_root%/}" # The destination must be a REGISTERED storage root (or the primary one). local ok=0 [[ "$dest_root" == "$CONTAINERS_DIR" ]] && ok=1 if (( ! ok )) && [[ -r "$STORAGE_REGISTRY" ]]; then local _id _path _rest while IFS=$'\t' read -r _id _path _rest || [[ -n "$_id" ]]; do [[ -z "$_path" || "$_id" == \#* ]] && continue [[ "${_path%/}" == "$dest_root" ]] && { ok=1; break; } done < "$STORAGE_REGISTRY" fi (( ok )) || { echo "libreportal-ownership: '$dest_root' is not a registered storage location" >&2; return 1; } # Find the source across every known root. local src="" root for root in "$CONTAINERS_DIR" $(awk -F'\t' '$1 !~ /^#/ && $2 != "" {print $2}' "$STORAGE_REGISTRY" 2>/dev/null); do root="${root%/}" [[ -d "$root/$app" ]] && { src="$root/$app"; break; } done [[ -n "$src" ]] || { echo "libreportal-ownership: no such app dir for '$app'" >&2; return 1; } local dst="$dest_root/$app" [[ -e "$dst" ]] && { echo "libreportal-ownership: '$dst' already exists" >&2; return 1; } [[ "$src" == "$dst" ]] && return 0 # Same filesystem -> rename is atomic and instant. Different -> full copy, # verified, and only then unlink the source. local src_dev dst_dev src_dev=$(stat -c '%d' -- "$src" 2>/dev/null) dst_dev=$(stat -c '%d' -- "$dest_root" 2>/dev/null) if [[ -n "$src_dev" && "$src_dev" == "$dst_dev" ]]; then mv -- "$src" "$dst" || return 1 return 0 fi cp -a --reflink=auto -- "$src" "$dst" || { rm -rf -- "$dst"; return 1; } # Verify before deleting anything: file counts and total bytes must match. local src_n dst_n src_b dst_b src_n=$(find "$src" -mindepth 1 2>/dev/null | wc -l) dst_n=$(find "$dst" -mindepth 1 2>/dev/null | wc -l) src_b=$(du -sb "$src" 2>/dev/null | awk '{print $1}') dst_b=$(du -sb "$dst" 2>/dev/null | awk '{print $1}') if [[ "$src_n" != "$dst_n" ]]; then echo "libreportal-ownership: copy verification failed ($src_n entries at source, $dst_n at destination). Source left intact at $src" >&2 return 1 fi if [[ -n "$src_b" && -n "$dst_b" && "$src_b" != "$dst_b" ]]; then echo "libreportal-ownership: copy verification failed ($src_b bytes at source, $dst_b at destination). Source left intact at $src" >&2 return 1 fi rm -rf -- "$src" return 0 } # Adopt a restored tree into place: move to /. # # Separate from app_move because the source is a staging directory under the # system tree, not a live app dir — but the same reasoning applies: the tree # carries container sub-UIDs the manager cannot recreate, and the destination # root is validated against the ROOT-OWNED registry rather than trusted from # the caller. app_adopt() { local app="${1:-}" staged="${2:-}" dest_root="${3:-}" [[ "$app" =~ ^[A-Za-z0-9._-]+$ && "$app" != "." && "$app" != ".." ]] \ || { echo "libreportal-ownership: invalid app name" >&2; return 1; } [[ -n "$staged" && "$staged" == /* && -d "$staged" ]] \ || { echo "libreportal-ownership: staged tree must be an existing absolute path" >&2; return 1; } [[ "$staged" == *..* ]] \ && { echo "libreportal-ownership: invalid staged path" >&2; return 1; } # Staging must live under the system tree — never an arbitrary location. [[ "$staged" == "$RESTORE_DIR"/* || "$staged" == "$MIGRATE_DIR"/* ]] \ || { echo "libreportal-ownership: staged tree must be under the restore/migrate area" >&2; return 1; } [[ -n "$dest_root" && "$dest_root" == /* ]] \ || { echo "libreportal-ownership: destination must be an absolute path" >&2; return 1; } dest_root="${dest_root%/}" local ok=0 [[ "$dest_root" == "$CONTAINERS_DIR" ]] && ok=1 if (( ! ok )) && [[ -r "$STORAGE_REGISTRY" ]]; then local _id _path _rest while IFS=$'\t' read -r _id _path _rest || [[ -n "$_id" ]]; do [[ -z "$_path" || "$_id" == \#* ]] && continue [[ "${_path%/}" == "$dest_root" ]] && { ok=1; break; } done < "$STORAGE_REGISTRY" fi (( ok )) || { echo "libreportal-ownership: '$dest_root' is not a registered storage location" >&2; return 1; } local dst="$dest_root/$app" rm -rf -- "$dst" mkdir -p -- "$dest_root" local s_dev d_dev s_dev=$(stat -c '%d' -- "$staged" 2>/dev/null) d_dev=$(stat -c '%d' -- "$dest_root" 2>/dev/null) if [[ -n "$s_dev" && "$s_dev" == "$d_dev" ]]; then mv -- "$staged" "$dst" || return 1 else cp -a --reflink=auto -- "$staged" "$dst" || { rm -rf -- "$dst"; return 1; } rm -rf -- "$staged" fi return 0 } # Chown one LibrePortal-managed file under an app dir to the container owner. # relpath is validated: no traversal, no absolute path, safe charset only. app_file() { local d rel mode cowner d="$(_app_dir "${1:-}")" || return 1 rel="${2:-}" [[ -n "$rel" && "$rel" != /* && "$rel" != *..* && "$rel" =~ ^[A-Za-z0-9._/-]+$ ]] \ || { echo "libreportal-ownership: invalid relpath" >&2; return 1; } mode="$(_mode)"; cowner="$(_container_owner "$mode")" [[ -e "$d/$rel" ]] && chown "$cowner:$cowner" "$d/$rel" } # Create a staging directory under the restore/migrate area, owned by the # container user. # # Restore stages through $SYSTEM_DIR, which the MANAGER owns — but the thing # that writes into the staging tree is restic, and runBackupOp runs it as the # container user. So whoever created the directory, the other one could not # write to it, and both existing call sites created it as the wrong principal: # # backupRestoreSystemConfig runFileOp mkdir -> container user, denied on a # 0751 manager-owned restore_dir, and the failure # was never checked. restic then wrote nothing, # exited 0 ("ignoring error ... permission denied"), # and the CLI reported "System config restored to: # " for a path that did not exist. # storageRestoreAppTo runInstallOp mkdir -> manager, so restic could # not create anything beneath it. # # Root has to bridge that, the same way webui-bind already bridges the mirror # case. The path is confined to the restore/migrate area and the name to a # single component, so this cannot be pointed anywhere else. # Shared gate for the staging actions: exactly one component directly below the # restore or migrate area, no traversal, no arbitrary path. _restore_stage_ok() { local path="${1:-}" [[ -n "$path" && "$path" == /* && "$path" != *..* ]] \ || { echo "libreportal-ownership: invalid staging path" >&2; return 1; } path="${path%/}" local parent="${path%/*}" leaf="${path##*/}" [[ "$parent" == "$RESTORE_DIR" || "$parent" == "$MIGRATE_DIR" ]] \ || { echo "libreportal-ownership: staging must sit directly under the restore/migrate area" >&2; return 1; } [[ "$leaf" =~ ^[A-Za-z0-9._-]+$ && "$leaf" != "." && "$leaf" != ".." ]] \ || { echo "libreportal-ownership: invalid staging name" >&2; return 1; } return 0 } # Remove a staging tree. Root's job for the same reason creating it was: the # tree belongs to the container user but sits in a directory the manager owns, # so neither of them can unlink it — the manager cannot delete the container # user's files inside, and the container user cannot remove the entry from the # manager's directory. Left to itself the staging dir simply accumulated. restore_unstage() { local path="${1:-}" _restore_stage_ok "$path" || return 1 path="${path%/}" [[ -e "$path" ]] || return 0 rm -rf -- "$path" } restore_stage() { local path="${1:-}" _restore_stage_ok "$path" || return 1 path="${path%/}" local parent="${path%/*}" local mode cowner; mode="$(_mode)"; cowner="$(_container_owner "$mode")" mkdir -p -- "$parent" || return 1 chown "$MANAGER:$MANAGER" -- "$parent" 2>/dev/null chmod 0751 -- "$parent" 2>/dev/null mkdir -p -- "$path" || return 1 # Owner writes (restic, as the container user), group traverses (the # manager, which has to confirm the tree landed and is the account a human # reviews it from). Same shape as webui-bind, mirrored. chown "$cowner:$MANAGER" -- "$path" || return 1 chmod 0750 -- "$path" || return 1 return 0 } # Adopt ONE file from a restore staging tree into the live config tree. # # Root has to do this: the staging tree is owned by the container user (restic # wrote it) and the manager can traverse but not read inside, so the account # that owns the destination cannot read the source. # # Deliberately one file per call, driven by an allow-list in the caller. A # recursive copy of a whole configs tree would carry the SOURCE machine's # identity across — its docker user and generated password, its port # allocations, its storage registry naming drives this box does not have. The # caller decides what is portable; this only enforces where it may land. config_adopt() { local staging="${1:-}" rel="${2:-}" [[ -n "$staging" && -n "$rel" ]] || { echo "libreportal-ownership: config-adopt needs a staging dir and a relative path" >&2; return 2; } # The relative path must stay relative and stay inside the config tree. A # rel of "../../etc/shadow" would otherwise be a root-owned write anywhere. case "$rel" in /*|*..*) echo "libreportal-ownership: refusing unsafe config path: $rel" >&2; return 1 ;; esac case "$staging" in /*) ;; *) echo "libreportal-ownership: staging must be an absolute path" >&2; return 1 ;; esac local src="${staging%/}/$rel" local dst="$CONFIGS_DIR/$rel" [[ -f "$src" ]] || { echo "libreportal-ownership: no such file in the backup: $rel" >&2; return 1; } # Resolve and re-check: a symlink inside the staging tree could otherwise # point the read anywhere, and the destination must land under CONFIGS_DIR # no matter what the path looked like before normalisation. local real_dst real_dst="$(realpath -m -- "$dst")" || return 1 case "$real_dst" in "$CONFIGS_DIR"/*) ;; *) echo "libreportal-ownership: refusing to write outside the config tree: $real_dst" >&2; return 1 ;; esac # Create the parent if it is missing, but never re-permission one that # already exists. Clamping every parent to manager:manager 0750 is what # closed configs/backup to the container user and stopped it reading the # backup credentials it runs with — a copy has no business rewriting the # permissions of directories it merely passes through. if [[ ! -d "${real_dst%/*}" ]]; then mkdir -p -- "${real_dst%/*}" || return 1 chown "$MANAGER:$MANAGER" -- "${real_dst%/*}" 2>/dev/null chmod 0750 -- "${real_dst%/*}" 2>/dev/null fi # --dereference: copy what a symlink points at, never the link itself. cp -f --dereference -- "$src" "$real_dst" || return 1 chown "$MANAGER:$MANAGER" -- "$real_dst" || return 1 # These files carry backup-repository passwords and login hashes, so they # are never group- or world-readable. chmod 0640 -- "$real_dst" || return 1 return 0 } # Close the backup-location config directory to accounts that are not part of # LibrePortal. # # Those files hold CFG_BACKUP_LOC__PASSWORD — the key to every backup the # user has — and they were world-readable: `nobody` could read them. The file # mode cannot simply be tightened, because the backup runs as the container # user and genuinely has to read the credentials it is about to use. # # So the directory carries the restriction instead: owned by the manager, group # the container user, 0750. Both accounts that need it still get in; nothing # else can traverse, whatever the modes inside say. config_secure() { local d="$CONFIGS_DIR/backup/locations" [[ -d "$d" ]] || return 0 local mode cowner; mode="$(_mode)"; cowner="$(_container_owner "$mode")" chown "$MANAGER:$cowner" -- "$d" || return 1 chmod 0750 -- "$d" || return 1 # Per-location subdirectories, same reasoning. local sub for sub in "$d"/*/; do [[ -d "$sub" ]] || continue chown "$MANAGER:$cowner" -- "$sub" 2>/dev/null chmod 0750 -- "$sub" 2>/dev/null done return 0 } # Adopt a whole config SUBTREE from a restore staging tree. # # For the per-location directories, where the index is part of the path and an # allow-list of fixed filenames cannot name them. backup/locations// is the # one that matters: it holds the repository credentials, and a restore that # does not bring those back has not restored the thing the user came for. # # Files land 0644, not 0640, deliberately — the backup runs as the container # user and has to read the credentials it is about to use. What keeps everyone # else out is the directory (config_secure, manager:container 0750), which this # re-applies once the copy is done. config_adopt_tree() { local staging="${1:-}" rel="${2:-}" [[ -n "$staging" && -n "$rel" ]] || { echo "libreportal-ownership: config-adopt-tree needs a staging dir and a relative path" >&2; return 2; } case "$rel" in /*|*..*) echo "libreportal-ownership: refusing unsafe config path: $rel" >&2; return 1 ;; esac case "$staging" in /*) ;; *) echo "libreportal-ownership: staging must be an absolute path" >&2; return 1 ;; esac local src="${staging%/}/$rel" [[ -d "$src" ]] || { echo "libreportal-ownership: no such directory in the backup: $rel" >&2; return 1; } local dst real_dst dst="$CONFIGS_DIR/$rel" real_dst="$(realpath -m -- "$dst")" || return 1 case "$real_dst" in "$CONFIGS_DIR"/*) ;; *) echo "libreportal-ownership: refusing to write outside the config tree: $real_dst" >&2; return 1 ;; esac mkdir -p -- "$real_dst" || return 1 # --no-dereference is wrong here and -L is right: a symlink in the staging # tree must be resolved to its content, never recreated as a link that # could point anywhere once it lands in the config tree. cp -RfL --no-preserve=mode,ownership -- "$src/." "$real_dst/" || return 1 chown -R "$MANAGER:$MANAGER" -- "$real_dst" || return 1 find "$real_dst" -type d -exec chmod 0750 {} + 2>/dev/null find "$real_dst" -type f -exec chmod 0644 {} + 2>/dev/null config_secure return 0 } action="${1:-}"; shift 2>/dev/null || true case "$action" in reconcile) reconcile "${1:-}";; traversal) traversal;; containers-top) containers_top;; backups-top) backups_top;; db-own) db_own;; app-perms) app_perms;; webui) webui;; webui-bind) _webui_bind_access;; taskdir) taskdir;; app-data-nobody) app_data_nobody "${1:-}";; app-data-remove) app_data_remove "${1:-}";; app-file) app_file "${1:-}" "${2:-}";; app-move) app_move "${1:-}" "${2:-}";; app-adopt) app_adopt "${1:-}" "${2:-}" "${3:-}";; restore-stage) restore_stage "${1:-}";; restore-unstage) restore_unstage "${1:-}";; secret-dir) secret_dir;; config-adopt) config_adopt "${1:-}" "${2:-}";; config-secure) config_secure;; config-adopt-tree) config_adopt_tree "${1:-}" "${2:-}";; *) echo "usage: libreportal-ownership {reconcile [mode]|traversal|containers-top|backups-top|db-own|app-perms|webui|webui-bind|taskdir|app-data-nobody |app-data-remove |app-file |app-move |app-adopt |restore-stage |restore-unstage |secret-dir}" >&2; exit 2;; esac