LibrePortal/scripts/system/libreportal-ownership
librelad 5dd763713d Read a backup repository from the WebUI, and stop adoption breaking things
restore inspect answers "what would a restore from here bring?" without writing
anything: hosts, apps with sizes, and the domains — read out of the
system-config snapshot with engineDumpFile, the same way the preflight reads an
app manifest. Knowing a backup hands you six domains of which four point
elsewhere, before committing, is the difference between a rebuild and a
surprise.

restore connect is the WebUI entry point: creates the location from a base64
payload, redeems the repository password from the single-use secret channel,
inspects. Deliberately does not engineInitLocation — every other path that
creates a location initialises it because it is about to write there; this one
reads a repository that already exists. This is what unblocks the constraint
app_portable.sh records: a .lpapp could live in the WebUI because nothing
secret crosses from browser to host, and the repository restore could not. The
secret:<ref> channel is that missing piece.

A wrong password is the ordinary case and the user retries, so a failed connect
removes the location it just made. Otherwise every attempt left another
half-configured destination behind.

Three things found by using it:

  - locationRemove never worked. It unlinked as the container user, but
    configs/ is manager-owned, so it was always denied — and the result was
    never checked, so isSuccessful printed anyway and a "removed" location came
    back on the next listing. Now runInstallOp, and the directory is checked.

  - webuiSecretSweep had no callers. An abandoned flow left its repository
    password on disk forever. The sweep now runs in /api/setup/secret before
    each write, tied to the one event guaranteed to happen.

  - Adoption took the WebUI down. config-adopt chowned every adopted file to
    manager:manager 0640, and webui_logins is bind-mounted into the container,
    which then could not read its own credentials: exit 137 with no log line.
    It also clamped every parent directory it passed through, closing
    configs/webui and configs/backup to the container user.

    The fix is a principle, not a special case: a restore replaces the CONTENT
    of a config file and nothing else. The live install already knows who may
    read each one. Adoption preserves the destination's ownership and mode,
    defaults closed only for a file that did not exist, and never
    re-permissions a directory it passes through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 04:33:07 +01:00

688 lines
31 KiB
Bash

#!/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 <staged> to <dest_root>/<app>.
#
# 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:
# <path>" 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
# Preserve whatever the destination already is. A restore replaces the
# CONTENT of a config file; it has no business re-deciding who may read it.
# The live install already knows — webui_logins and webui_logs are
# bind-mounted into the container and must stay readable by the container
# user, and imposing manager:manager 0640 on them took the WebUI down
# immediately (exit 137, no log line, because the process could not read
# its own credentials file).
local had_owner="" had_mode=""
if [[ -e "$real_dst" ]]; then
had_owner="$(stat -c '%U:%G' -- "$real_dst" 2>/dev/null)"
had_mode="$(stat -c '%a' -- "$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
if [[ -n "$had_owner" && -n "$had_mode" ]]; then
chown "$had_owner" -- "$real_dst" || return 1
chmod "$had_mode" -- "$real_dst" || return 1
else
# New file: these can carry repository passwords and login hashes, so
# the default is closed.
chown "$MANAGER:$MANAGER" -- "$real_dst" || return 1
chmod 0640 -- "$real_dst" || return 1
fi
return 0
}
# Close the backup-location config directory to accounts that are not part of
# LibrePortal.
#
# Those files hold CFG_BACKUP_LOC_<idx>_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/<n>/ 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>|app-data-remove <app>|app-file <app> <relpath>|app-move <app> <dest-root>|app-adopt <app> <staged> <dest-root>|restore-stage <path>|restore-unstage <path>|secret-dir}" >&2; exit 2;;
esac