feat(desudo): root-owned ownership helper (no blanket sudo chown needed)

Under Model A the runtime runs as the manager, so establishing the
/docker ownership model needs root. Granting the manager a blanket
'sudo chown'/'sudo chmod' in the scoped sudoers would be root-equivalent
(chown /etc/sudoers, ...). Introduce a self-contained, root-owned helper
that performs only a FIXED set of reconciles on FIXED LibrePortal paths,
with owners derived from config + a baked manager name (never the caller)
and a strictly-validated app-name argument.

- scripts/system/libreportal-ownership: the helper (actions: reconcile,
  traversal, containers-top, app-perms, webui, taskdir, app-data-nobody)
- run_privileged: runOwnership wrapper (sudo the installed helper; run the
  bundled copy directly when already root mid-install)
- init.sh: installOwnershipHelper bakes the manager name and installs it
  root:root 0755 to /usr/local/sbin (manager can't modify it)
- libreportal_folders/app_folder/app_update_specifics/task processor:
  delegate the ownership chowns to runOwnership instead of runSystem chown

This removes chown/chmod-on-/docker from the runtime sudo surface, a
prerequisite for a non-root-equivalent scoped sudoers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-05-24 18:16:23 +01:00
parent 85fd086281
commit 46622cd2f9
7 changed files with 238 additions and 132 deletions

28
init.sh
View File

@ -705,6 +705,34 @@ initUsers()
isError "Refusing to install an invalid sudoers drop-in for $sudo_user_name."
fi
rm -f "$sudoers_tmp"
initOwnershipHelper
}
# Install the root-owned ownership-reconcile helper. Under Model A the runtime
# runs AS the manager, so establishing the /docker ownership model needs root —
# but granting the manager a blanket `sudo chown`/`sudo chmod` would be
# root-equivalent. This helper does a FIXED set of reconciles on FIXED paths; it
# lives root:root 0755 where the manager can't edit it, so the scoped sudoers can
# allow it wholesale. The manager name is baked in here (manager can't change it).
initOwnershipHelper()
{
local helper_src="$script_dir/scripts/system/libreportal-ownership"
local helper_dst="/usr/local/sbin/libreportal-ownership"
if [[ ! -f "$helper_src" ]]; then
isError "Ownership helper source missing ($helper_src) — skipping install."
return 1
fi
local helper_tmp
helper_tmp=$(mktemp)
sed "s/__MANAGER__/${sudo_user_name}/g" "$helper_src" > "$helper_tmp"
if bash -n "$helper_tmp" 2>/dev/null; then
sudo install -m 0755 -o root -g root "$helper_tmp" "$helper_dst"
isSuccessful "Installed root-owned ownership helper ($helper_dst)."
else
isError "Refusing to install a malformed ownership helper."
fi
rm -f "$helper_tmp"
}
initFolders()

View File

@ -33,7 +33,7 @@ appUpdateSpecifics()
# under its mounted data dir; fixPermissionsBeforeStart hands the dir to
# the install user, so give it to 65534 here or the server can't open
# the database. Restart so it picks the dir up.
runSystem chown -R 65534:65534 "$containers_dir$app_name/data";
runOwnership app-data-nobody "$app_name";
shouldrestart="true";
fi

View File

@ -143,12 +143,10 @@ setupTaskDir() {
# it. Create-if-absent to keep a stable inode for flock across restarts.
[[ -e "$LOCK_FILE" ]] || runFileOp install -m 666 /dev/null "$LOCK_FILE" 2>/dev/null
runFileOp chmod 666 "$LOCK_FILE" 2>/dev/null
# Establish ownership with runSystem (root): the unprivileged dir owner can't
# reclaim files an earlier run left root/manager-owned (e.g. a root-owned
# Establish ownership via the root-owned helper: the unprivileged dir owner
# can't reclaim files an earlier run left root/manager-owned (e.g. a root-owned
# task_processor.log), which would then block the daemon's log appends.
if [[ -n "$docker_install_user" ]]; then
runSystem chown -R "$docker_install_user":"$docker_install_user" "$TASK_DIR" 2>/dev/null
fi
runOwnership taskdir 2>/dev/null
}
# Open the FIFO in read-write mode on fd 3. With <>, Linux returns from open()

View File

@ -86,6 +86,28 @@ runBackupOp() {
sudo -E -u "$docker_install_user" "$@"
}
# Trigger a fixed ownership reconcile through the ROOT-OWNED helper installed at
# /usr/local/sbin/libreportal-ownership. This is how the manager-run runtime
# (Model A) establishes the ownership model — manager owns the control plane, the
# docker install user owns the containers — without the scoped sudoers having to
# grant a blanket `sudo chown`/`sudo chmod` (which would be root-equivalent: chown
# /etc/sudoers and so on). The helper validates its own (fixed-path) operations,
# so the sudoers can allow it wholesale.
# action ∈ {reconcile [mode]|traversal|containers-top|app-perms|webui|taskdir|
# app-data-nobody <app>}
# At install time (already root) the helper may not be installed yet, so run the
# bundled copy directly — no sudo, no escalation, since we are root already.
runOwnership() {
local helper="/usr/local/sbin/libreportal-ownership"
if [[ -x "$helper" ]]; then
sudo "$helper" "$@"
elif [[ $EUID -eq 0 ]]; then
bash "${script_dir:-/docker/install}/scripts/system/libreportal-ownership" "$@"
else
sudo "$helper" "$@"
fi
}
# Genuine system-administration command (ufw/systemctl/apt/sysctl/useradd, /etc
# edits). Needs real root in both modes; funnelled through one place so it can
# later be confined to a scoped sudoers allowlist.

View File

@ -1,81 +1,14 @@
#!/bin/bash
# Per-app structural permissions + ownership of the LibrePortal-managed files
# (config + compose) for every installed app. The actual chown/chmod run in the
# root-owned ownership helper (runOwnership) so the manager-run runtime needs no
# blanket sudo; the helper walks /docker/containers itself.
fixAppFolderPermissions()
{
local silent_flag="$1"
# Collect all app names in an array
local app_names=()
for app_dir in "$containers_dir"/*/; do
if [ -d "$app_dir" ]; then
local app_name=$(basename "$app_dir")
app_names+=("$app_name")
fi
done
for app_name in "${app_names[@]}"; do
if [[ $app_name != "" ]]; then
# Updating $containers_dir with execute permissions
if [ -d "$containers_dir" ]; then
local result=$(runSystem chmod +x "$containers_dir" > /dev/null 2>&1)
if [ "$silent_flag" == "loud" ]; then
checkSuccess "Updating $containers_dir with execute permissions."
fi
else
if [ "$silent_flag" == "loud" ]; then
isNotice "$containers_dir does not exist."
fi
fi
# Updating $containers_dir$app_name with execute permissions
if [ -d "$containers_dir$app_name" ]; then
local result=$(runSystem chmod +x "$containers_dir$app_name" > /dev/null 2>&1)
if [ "$silent_flag" == "loud" ]; then
checkSuccess "Updating $containers_dir$app_name with execute permissions."
fi
else
if [ "$silent_flag" == "loud" ]; then
isNotice "$containers_dir$app_name does not exist."
fi
fi
# Updating $app_name with read permissions
if [ -d "$containers_dir$app_name" ]; then
local result=$(runSystem chmod o+r "$containers_dir$app_name")
if [ "$silent_flag" == "loud" ]; then
checkSuccess "Updating $app_name with read permissions"
fi
else
if [ "$silent_flag" == "loud" ]; then
isNotice "$containers_dir$app_name does not exist."
fi
fi
# Updating compose file(s) for LibrePortal access
if [ -d "$containers_dir$app_name" ]; then
local result=$(runSystem find "$containers_dir$app_name" -type f -name '*docker-compose*' -exec chmod o+r {} \;)
if [ "$silent_flag" == "loud" ]; then
isNotice "Updating compose file(s) for LibrePortal access"
fi
else
if [ "$silent_flag" == "loud" ]; then
isNotice "$containers_dir$app_name does not exist."
fi
fi
# Fix LibrePortal specific file permissions
local files=("migrate.txt" "$app_name.config" "docker-compose.yml" "docker-compose.$app_name.yml")
for file in "${files[@]}"; do
local file_path="$containers_dir$app_name/$file"
# Check if the file exists
if [ -e "$file_path" ]; then
local result=$(runSystem chown $docker_install_user:$docker_install_user "$file_path")
if [ "$silent_flag" == "loud" ]; then
checkSuccess "Updating $file with $docker_install_user ownership"
fi
fi
done
fi
done
runOwnership app-perms
if [ "$silent_flag" == "loud" ]; then
checkSuccess "Updating app folder permissions."
fi
}

View File

@ -41,35 +41,10 @@ reconcileDockerOwnership()
{
local mode="${1:-$CFG_DOCKER_INSTALL_TYPE}"
[[ -d "$docker_dir" ]] || return 0
# Robust resolution — these globals aren't always populated in the CLI/switch
# context, which previously made ops silently no-op (relative paths / empty
# user). Fall back to absolute defaults; never empty.
local owner="${sudo_user_name:-libreportal}"
local ddir="${docker_dir:-/docker}"
local cfgdir="${configs_dir:-$ddir/configs/}"
local logdir="${logs_dir:-$ddir/logs/}"
local scrdir="${script_dir:-$ddir/install}"
local dbpath="$ddir/${db_file:-database.db}"
[[ -d "$ddir" ]] || return 0
# Swap ONLY the owner on our own control-plane files; never reset mode bits.
# The only two bits we *add* (never remove) are structural: o+x on /docker so
# the docker user can traverse to its container dirs, and o+r on the DB so the
# WebUI container can read it.
runSystem chown "$owner:$owner" "$ddir"
runSystem chmod o+x "$ddir"
local p
for p in "$cfgdir" "$logdir" "$scrdir" "$dbpath"; do
[[ -e "$p" ]] && runSystem chown -R "$owner:$owner" "$p"
done
[[ -f "$dbpath" ]] && runSystem chmod o+r "$dbpath"
# Structural container dirs owned by the mode's container owner.
reconcileContainersTopOwnership "$mode"
reconcileWebuiDirOwnership "$mode"
# All the chown/chmod live in the root-owned helper (see runOwnership) so the
# scoped sudoers needn't grant blanket `sudo chown`. The helper reads the mode
# + owners from config itself; this just triggers it.
runOwnership reconcile "$mode"
isSuccessful "Reconciled ownership for $mode"
}
@ -79,13 +54,7 @@ reconcileDockerOwnership()
# reconcile. Runs as root (runSystem stays root in both modes).
reconcileContainersTopOwnership()
{
local mode="${1:-$CFG_DOCKER_INSTALL_TYPE}"
local cdir="${containers_dir:-${docker_dir:-/docker}/containers/}"
[[ -d "$cdir" ]] || return 0
local owner
owner="$(dockerContainerOwner "$mode")"
runSystem chown "$owner:$owner" "$cdir"
runSystem chmod o+x "$cdir"
runOwnership containers-top
}
# Chown LibrePortal's own (regenerable) WebUI container dir to the mode's
@ -104,10 +73,8 @@ reconcileWebuiDirOwnership()
isNotice "reconcileWebuiDirOwnership: WebUI dir '$webui_dir' not found — skipped"
return 0
fi
local owner
owner="$(dockerContainerOwner "$mode")"
runSystem chown -R "$owner:$owner" "$webui_dir"
isSuccessful "Reconciled WebUI dir ($webui_dir) -> $owner"
runOwnership webui
isSuccessful "Reconciled WebUI dir ($webui_dir)"
}
# Traversal (+x) bits only. Ownership of the structural container dirs is handled
@ -116,17 +83,11 @@ reconcileWebuiDirOwnership()
fixFolderPermissions()
{
local silent_flag="$1"
local app_name="$2"
local result=$(runSystem chmod +x "$docker_dir" > /dev/null 2>&1)
# +x traversal bits on the structural dirs + the containers/ top owner — all
# in the root-owned helper (traversal already covers containers-top).
runOwnership traversal
if [ "$silent_flag" == "loud" ]; then
checkSuccess "Updating $docker_dir with execute permissions."
checkSuccess "Updating folder traversal permissions."
fi
local result=$(runSystem find "$script_dir" "$ssl_dir" "$ssh_dir" "$backup_dir" "$restore_dir" "$migrate_dir" -maxdepth 2 -type d -exec chmod +x {} \;)
if [ "$silent_flag" == "loud" ]; then
checkSuccess "Adding execute permissions for $docker_install_user user"
fi
reconcileContainersTopOwnership
}

View File

@ -0,0 +1,164 @@
#!/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) needs root. Granting the manager a blanket
# `sudo chown`/`sudo chmod` in the scoped sudoers would be root-equivalent (chown
# /etc/sudoers, etc.). Instead this script — installed root:root 0755 to
# /usr/local/sbin by init.sh, so the manager cannot modify it — performs a FIXED
# set of reconciles on FIXED LibrePortal paths only. Owners are derived from
# config + the baked manager name, never from the caller; the single free argument
# (an app name) is strictly validated and must resolve to an existing dir under
# /docker/containers.
#
# Self-contained ON PURPOSE: it must NOT source any manager-owned code, 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 manager name 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 (placeholder replaced); default if run unbaked.
MANAGER="__MANAGER__"
[[ "$MANAGER" == "__MANAGER__" || -z "$MANAGER" ]] && MANAGER="libreportal"
DOCKER_DIR="/docker"
CONFIGS_DIR="$DOCKER_DIR/configs"
LOGS_DIR="$DOCKER_DIR/logs"
INSTALL_DIR="$DOCKER_DIR/install"
CONTAINERS_DIR="$DOCKER_DIR/containers"
SSL_DIR="$DOCKER_DIR/ssl"
SSH_DIR="$DOCKER_DIR/ssh"
BACKUP_DIR="$DOCKER_DIR/backups"
RESTORE_DIR="$DOCKER_DIR/restore"
MIGRATE_DIR="$DOCKER_DIR/migrate"
DB_PATH="$DOCKER_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.
_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 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"
}
# Control plane -> manager; structural container dirs -> container owner. Owner
# only, never reset mode bits; only ADD o+x (traversal) and o+r (DB read).
reconcile() {
local mode="${1:-$(_mode)}"
[[ -d "$DOCKER_DIR" ]] || return 0
chown "$MANAGER:$MANAGER" "$DOCKER_DIR"
chmod o+x "$DOCKER_DIR"
local p
for p in "$CONFIGS_DIR" "$LOGS_DIR" "$INSTALL_DIR" "$DB_PATH"; do
[[ -e "$p" ]] && chown -R "$MANAGER:$MANAGER" "$p"
done
[[ -f "$DB_PATH" ]] && chmod o+r "$DB_PATH"
local cowner; cowner="$(_container_owner "$mode")"
if [[ -d "$CONTAINERS_DIR" ]]; then
chown "$cowner:$cowner" "$CONTAINERS_DIR"
chmod o+x "$CONTAINERS_DIR"
fi
[[ -d "$WEBUI_DIR" ]] && chown -R "$cowner:$cowner" "$WEBUI_DIR"
}
# Traversal (+x) bits only, on the structural LibrePortal dirs.
traversal() {
[[ -d "$DOCKER_DIR" ]] && chmod +x "$DOCKER_DIR"
local d
for d in "$INSTALL_DIR" "$SSL_DIR" "$SSH_DIR" "$BACKUP_DIR" "$RESTORE_DIR" "$MIGRATE_DIR"; do
[[ -d "$d" ]] && find "$d" -maxdepth 2 -type d -exec chmod +x {} \;
done
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
}
# 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"
}
# 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 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"
}
action="${1:-}"; shift 2>/dev/null || true
case "$action" in
reconcile) reconcile "${1:-}";;
traversal) traversal;;
containers-top) containers_top;;
app-perms) app_perms;;
webui) webui;;
taskdir) taskdir;;
app-data-nobody) app_data_nobody "${1:-}";;
*) echo "usage: libreportal-ownership {reconcile [mode]|traversal|containers-top|app-perms|webui|taskdir|app-data-nobody <app>}" >&2; exit 2;;
esac