LibrePortal/scripts/system/libreportal-ownership
librelad 0aa92e128e webui: a one-shot channel for secrets, instead of the command line
A password typed in the WebUI has to reach the host, and both existing routes
leak it. As part of a task's command string it lands in
frontend/data/tasks/*.json — 0644, inside a world-readable directory — and is
visible in `ps` while the task runs; as a plain file there it is either
world-readable at 0644 or unreadable by the manager at 0640. Verified still true
on a clean install. A backup repository password sent that way is the key to
every backup the user has, readable by any local account.

libreportal-ownership gains `secret-dir`: the mirror of _webui_bind_access.
That one makes manager-owned config readable by the container; this makes a
container-written file readable by the MANAGER. The directory is
<container>:<manager> mode 2730 — setgid so each file inherits the manager's
group, the container writes it 0640, and 0730 leaves the directory unlistable
because the manager is handed a filename rather than going looking. Group rwx
is what lets it unlink after reading.

The WebUI then sends a REFERENCE ("secret:<id>") wherever it used to send the
value, and configUpdateBatch redeems it at the last moment before the write.
That is the single point every config write from the WebUI passes through, so
this covers every password field rather than only the backup ones — which is
what docs/roadmap/first-run-restore.md §4.1 asked for. A reference that cannot
be redeemed leaves the field unchanged rather than blanking it.

Verified on a live install: the container drops a secret, the manager applies it
by reference, the file is unlinked, `nobody` can neither read nor list it, and a
second redemption of the same reference fails.

footprint_version 9 -> 10 (root-owned helper changed).

Also fixes a block of constructor initialisations I spliced into the middle of
renderStorageChoices in aa44e0b: on a single-drive box — the case in the
screenshot that prompted this — rendering the Storage step silently reset
backupDest and cleared the import selections.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:48:03 +01:00

499 lines
22 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).
_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"
}
# 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"
[[ -d "$parent" ]] || { echo "libreportal-ownership: no WebUI data dir at $parent" >&2; return 1; }
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; }
local d="$CONTAINERS_DIR/$app"
[[ -d "$d" ]] || 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
}
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;;
*) 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