LibrePortal/scripts/system/libreportal-ownership
librelad edcdf00aca feat(layout): three-root split + ownership model (phase 2)
Split the single tree into three owner-isolated roots and fix the backup
permission failure (restic, running as the container user, could not write the
manager-owned /docker/backups).

Ownership helper (libreportal-ownership), rewritten for three baked roots:
  SYSTEM_DIR (manager)  CONTAINERS_DIR + BACKUPS_DIR (container user)
- reconcile now drives each tree to its single owner; backups + the WebUI dir go
  to the container user (the actual fix). The container user reaches only the
  WebUI bind-mount sources (configs/webui/*) via a scoped _webui_bind_access —
  traverse the system root + configs, read configs/webui only, nothing else.
- defence-in-depth: refuse dangerous/relative roots even if mis-baked; new
  backups-top action.

Baking: init.sh initRootHelpers now seds __SYSTEM_DIR__/__CONTAINERS_DIR__/
__BACKUPS_DIR__ (alongside __MANAGER__) into every helper at install — the trust
boundary stays root-controlled. svc/socket/appcfg helpers updated to derive from
the baked SYSTEM_DIR; the svc unit now exports LP_*_DIR so the processor resolves
roots authoritatively. A baking-safe '*"__"*' sentinel check survives the sed.

Install/uninstall: initFolders creates the three roots; initContainerLayer hands
containers + backups to the container user; uninstall removes all three
(idempotent on legacy single-tree installs). Remaining functional /docker
literals in init.sh (config reads, setupConfigsFromRepo, uninstall) parameterised.

Compose: the WebUI's two relative ../../configs mounts (the only cross-tree
relative mounts in the tree) are now absolute, filled at generation via a new
CONFIGS_DIR_TAG; CONTAINERS_DIR_TAG likewise for the LP_CONTAINERS_DIR env.

Live box unaffected: installed helpers + the live compose only change on reinstall/
rebuild (both of which fill the tags); the CLI-wrapper heredoc paths are baked in
phase 3.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-25 15:21:28 +01:00

240 lines
9.8 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"
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"
# 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: traverse SYSTEM_DIR + configs, read configs/webui only.
_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
chmod o+rx "$CONFIGS_DIR/webui" 2>/dev/null
find "$CONFIGS_DIR/webui" -maxdepth 1 -type f -exec chmod o+r {} \; 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" "$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"
}
# 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;;
taskdir) taskdir;;
app-data-nobody) app_data_nobody "${1:-}";;
app-file) app_file "${1:-}" "${2:-}";;
*) echo "usage: libreportal-ownership {reconcile [mode]|traversal|containers-top|backups-top|db-own|app-perms|webui|taskdir|app-data-nobody <app>|app-file <app> <relpath>}" >&2; exit 2;;
esac