From 46622cd2f9d270247e4a56b6c4618295bf20bcc7 Mon Sep 17 00:00:00 2001 From: librelad Date: Sun, 24 May 2026 18:16:23 +0100 Subject: [PATCH] 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 Signed-off-by: librelad --- init.sh | 28 +++ scripts/app/app_update_specifics.sh | 2 +- .../crontab/task/crontab_task_processor.sh | 8 +- scripts/docker/command/run_privileged.sh | 22 +++ scripts/function/permission/app_folder.sh | 85 +-------- .../permission/libreportal_folders.sh | 61 ++----- scripts/system/libreportal-ownership | 164 ++++++++++++++++++ 7 files changed, 238 insertions(+), 132 deletions(-) create mode 100644 scripts/system/libreportal-ownership diff --git a/init.sh b/init.sh index 34ec053..1ca3dcb 100755 --- a/init.sh +++ b/init.sh @@ -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() diff --git a/scripts/app/app_update_specifics.sh b/scripts/app/app_update_specifics.sh index d8ccbc8..91699ba 100755 --- a/scripts/app/app_update_specifics.sh +++ b/scripts/app/app_update_specifics.sh @@ -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 diff --git a/scripts/crontab/task/crontab_task_processor.sh b/scripts/crontab/task/crontab_task_processor.sh index c0713b1..b3baeb2 100755 --- a/scripts/crontab/task/crontab_task_processor.sh +++ b/scripts/crontab/task/crontab_task_processor.sh @@ -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() diff --git a/scripts/docker/command/run_privileged.sh b/scripts/docker/command/run_privileged.sh index 1cf5224..0dffc78 100644 --- a/scripts/docker/command/run_privileged.sh +++ b/scripts/docker/command/run_privileged.sh @@ -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 } +# 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. diff --git a/scripts/function/permission/app_folder.sh b/scripts/function/permission/app_folder.sh index 00e1553..b831d38 100755 --- a/scripts/function/permission/app_folder.sh +++ b/scripts/function/permission/app_folder.sh @@ -1,81 +1,14 @@ #!/bin/bash -fixAppFolderPermissions() +# 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 } diff --git a/scripts/function/permission/libreportal_folders.sh b/scripts/function/permission/libreportal_folders.sh index 3dff82a..a7b564d 100755 --- a/scripts/function/permission/libreportal_folders.sh +++ b/scripts/function/permission/libreportal_folders.sh @@ -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 } diff --git a/scripts/system/libreportal-ownership b/scripts/system/libreportal-ownership new file mode 100644 index 0000000..d053334 --- /dev/null +++ b/scripts/system/libreportal-ownership @@ -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 }" >&2; exit 2;; +esac