Phase 2 and 4 of docs/roadmap/storage-locations.md. Apps can now be placed on a location and moved between them. CFG_<APP>_STORAGE lands in all 37 app templates, holding a location NAME rather than a path: names survive a migrate to a host with different disks, paths do not. The 11 infrastructure apps that other apps reach by literal path (traefik, prometheus, grafana, adguard, gluetun, crowdsec, headscale, dashy, pihole, unbound, wireguard) are pinned. libreportal itself never gets the key — it is pinned structurally by webuiDir. Pinning needed no second config key. "Pinned" is not a fact about a value, it is a statement about whether the field may be edited, so it goes in the comment beside **ADVANCED** and **DEV** as **READONLY**, and the field factory renders those disabled. That marker earns its keep beyond this feature: derived fields already warned in prose that editing them does nothing (crowdsec.config:72) next to a perfectly editable input. storage_app_config.sh keeps the comment honest — it carries the resolved path for hand-recovery and regenerates the dropdown from the registry, but only writes when something actually changed, since the app .config is user-editable and lives in the container-owned tree. app move stops the app (a live copy of a running Postgres is a corrupt copy), snapshots it, copies, verifies, and only then removes the source. The copy runs in libreportal-ownership because it must: app data holds rootless sub-UID files the manager can neither read nor recreate. Verified against two real ext4 filesystems that a cross-device move preserves uid 231141 and the payload, and that the source survives every refusal path — unregistered destination, the WebUI app, a traversal in the app name, and an occupied destination. Task titles registered in both tables, with a specific rule so a move renders as "Nextcloud - Move to bigdisk" rather than the generic fallback dropping the destination. lp-task-names could not be run to confirm — it borrows the WebUI container's node and no containers are running. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
354 lines
15 KiB
Bash
354 lines
15 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
|
|
}
|
|
|
|
# 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
|
|
}
|
|
|
|
# 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"
|
|
}
|
|
|
|
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:-}";;
|
|
*) 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>}" >&2; exit 2;;
|
|
esac
|