From 9af2465ffed4a99f06fc1456e6205ee36f2f4d17 Mon Sep 17 00:00:00 2001 From: librelad Date: Sun, 24 May 2026 18:28:56 +0100 Subject: [PATCH] feat(desudo): socket + systemd-svc helpers; route traefik/db chowns + svc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the last runtime-critical root file-primitive subsystems behind root-owned helpers so the type switcher + task service work under a scoped sudoers: - scripts/system/libreportal-socket: {rootless|rooted} {on|off} chmod of the docker sockets (paths computed from config, not caller-supplied; exit 3 = absent so the *_found flags come from its exit code) - scripts/system/libreportal-svc: GENERATES + installs the systemd unit from config (mode/uid/baked manager) — never accepts unit content from the caller (arbitrary unit = root). Idempotent install/enable/restart. - ownership helper: add db-own + app-file actions - run_privileged: runSocket / runSvc - set_socket_permissions -> runSocket; webui_install_systemd -> runSvc (+ crontab cleanup runs as the manager directly, no sudo -u self) - before_start: db chown -> runOwnership db-own; traefik cert/yml -> runOwnership app-file (retires updateFileOwnership/changeRootOwnedFile) - init.sh installs all five helpers Co-Authored-By: Claude Opus 4.7 Signed-off-by: librelad --- init.sh | 2 +- scripts/docker/command/run_privileged.sh | 8 ++ .../type_switcher/set_socket_permissions.sh | 22 ++--- scripts/function/permission/before_start.sh | 6 +- scripts/system/libreportal-ownership | 25 ++++- scripts/system/libreportal-socket | 38 ++++++++ scripts/system/libreportal-svc | 92 +++++++++++++++++++ scripts/webui/webui_install_systemd.sh | 71 +++----------- 8 files changed, 188 insertions(+), 76 deletions(-) create mode 100644 scripts/system/libreportal-socket create mode 100644 scripts/system/libreportal-svc diff --git a/init.sh b/init.sh index 4f6f5a4..e9bde21 100755 --- a/init.sh +++ b/init.sh @@ -720,7 +720,7 @@ initUsers() initRootHelpers() { local helper helper_src helper_dst helper_tmp - for helper in libreportal-ownership libreportal-dns libreportal-ssh-access; do + for helper in libreportal-ownership libreportal-dns libreportal-ssh-access libreportal-socket libreportal-svc; do helper_src="$script_dir/scripts/system/$helper" helper_dst="/usr/local/sbin/$helper" if [[ ! -f "$helper_src" ]]; then diff --git a/scripts/docker/command/run_privileged.sh b/scripts/docker/command/run_privileged.sh index df3a6c8..110d632 100644 --- a/scripts/docker/command/run_privileged.sh +++ b/scripts/docker/command/run_privileged.sh @@ -119,6 +119,14 @@ runResolv() { _runRootHelper libreportal-dns "$@"; } # key-add |key-remove |pw-set } runSshAccess() { _runRootHelper libreportal-ssh-access "$@"; } +# Docker-socket read perms for the type switcher: {rootless|rooted} {on|off} +# (exit 3 = socket absent). +runSocket() { _runRootHelper libreportal-socket "$@"; } + +# Install/refresh the systemd task-processor unit (root generates the unit from +# config; no caller-supplied content): {install|enable|restart|start|status} +runSvc() { _runRootHelper libreportal-svc "$@"; } + # 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/docker/type_switcher/set_socket_permissions.sh b/scripts/docker/type_switcher/set_socket_permissions.sh index 710db7d..19c2701 100755 --- a/scripts/docker/type_switcher/set_socket_permissions.sh +++ b/scripts/docker/type_switcher/set_socket_permissions.sh @@ -13,22 +13,21 @@ dockerSwitcherSetSocketPermissions() isHeader "Docker Socket Checker" + # The chmods run in the root-owned socket helper (runSocket), which computes + # the socket paths itself and returns 3 when the socket is absent — so the + # *_found flags (read downstream by stop_docker/swap_docker_type) come from + # its exit code, no privileged `test -e` needed. if [[ $CFG_DOCKER_INSTALL_TYPE == "rooted" ]]; then if [[ $docker_rootless_exist == "false" ]]; then - # if File exists - if runSystem test -e "$docker_rootless_socket"; then - local result=$(runSystem chmod o-r "$docker_rootless_socket") + if runSocket rootless off; then checkSuccess "Removing read permissions from Rootless docker socket." docker_rootless_found="true" else - #isSuccessful "Rootless socket not found, no need to do anything with rootless setup." docker_rootless_found="false" fi fi - # if File exists - if runSystem test -e "$docker_rooted_socket"; then - local result=$(runSystem chmod +r "$docker_rooted_socket") + if runSocket rooted on; then checkSuccess "Adding read permissions to Rooted docker socket." docker_rooted_found="true" else @@ -38,19 +37,14 @@ dockerSwitcherSetSocketPermissions() fi if [[ $CFG_DOCKER_INSTALL_TYPE == "rootless" ]]; then - # if File exists - if runSystem test -e "$docker_rooted_socket"; then - local result=$(runSystem chmod o-r "$docker_rooted_socket") + if runSocket rooted off; then checkSuccess "Removing read permissions from Rooted docker socket." docker_rooted_found="true" else - #isSuccessful "Rooted socket not found, no need to do anything with rooted setup." docker_rooted_found="false" fi - # if File exists - if runSystem test -e "$docker_rootless_socket"; then - local result=$(runSystem chmod +r "$docker_rootless_socket") + if runSocket rootless on; then checkSuccess "Adding read permissions to Rootless docker socket." docker_rootless_found="true" else diff --git a/scripts/function/permission/before_start.sh b/scripts/function/permission/before_start.sh index feaf2fd..8f06044 100755 --- a/scripts/function/permission/before_start.sh +++ b/scripts/function/permission/before_start.sh @@ -10,7 +10,7 @@ fixPermissionsBeforeStart() fi fixAppFolderPermissions; - changeRootOwnedFile $docker_dir/$db_file $sudo_user_name + runOwnership db-own # The regenerable WebUI dir is reconciled to the mode's container owner via # the shared helper (same code path as install + switch). Third-party app @@ -22,12 +22,12 @@ fixPermissionsBeforeStart() # Traefik if [ -f "${containers_dir}traefik/etc/certs/acme.json" ]; then - updateFileOwnership "${containers_dir}traefik/etc/certs/acme.json" $docker_install_user $docker_install_user + runOwnership app-file traefik etc/certs/acme.json local result=$(runFileOp chmod 600 "${containers_dir}traefik/etc/certs/acme.json") checkSuccess "Set permissions to acme.json file for traefik" fi if [ -f "${containers_dir}traefik/etc/traefik.yml" ]; then - updateFileOwnership "${containers_dir}traefik/etc/traefik.yml" $docker_install_user $docker_install_user + runOwnership app-file traefik etc/traefik.yml local result=$(runFileOp chmod 600 "${containers_dir}traefik/etc/traefik.yml") checkSuccess "Set permissions to traefik.yml file for traefik" fi diff --git a/scripts/system/libreportal-ownership b/scripts/system/libreportal-ownership index d053334..e26d6fd 100644 --- a/scripts/system/libreportal-ownership +++ b/scripts/system/libreportal-ownership @@ -130,6 +130,14 @@ webui() { [[ -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 container 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")" @@ -151,14 +159,29 @@ app_data_nobody() { [[ -d "$d/data" ]] && chown -R 65534:65534 "$d/data" } +# Chown one LibrePortal-managed file under an app dir to the container owner +# (e.g. traefik acme.json / traefik.yml the container creates as another uid). +# 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;; + db-own) db_own;; 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;; + app-file) app_file "${1:-}" "${2:-}";; + *) echo "usage: libreportal-ownership {reconcile [mode]|traversal|containers-top|db-own|app-perms|webui|taskdir|app-data-nobody |app-file }" >&2; exit 2;; esac diff --git a/scripts/system/libreportal-socket b/scripts/system/libreportal-socket new file mode 100644 index 0000000..913e7bb --- /dev/null +++ b/scripts/system/libreportal-socket @@ -0,0 +1,38 @@ +#!/bin/bash +# LibrePortal docker-socket permission helper — the only root-privileged chmod of +# the docker sockets the manager may trigger (the type switcher hides/exposes the +# inactive/active mode's socket). Installed root:root 0755 to /usr/local/sbin by +# init.sh. Self-contained; the socket paths are computed here (never caller- +# supplied), so the scoped sudoers can allow it instead of blanket `sudo chmod`. +# +# Exit: 0 = socket found + chmod'd, 3 = socket absent (caller treats as not-found). + +set -u + +[[ $EUID -eq 0 ]] || { echo "libreportal-socket: must run as root" >&2; exit 1; } + +DB_CFG="/docker/configs/general/general_docker_install" +ROOTED_SOCK="/var/run/docker.sock" + +_rootless_sock() { + local u uid + u=$(grep -h '^CFG_DOCKER_INSTALL_USER=' "$DB_CFG" 2>/dev/null | head -1 | cut -d= -f2 | awk '{print $1}') + [[ -n "$u" ]] || return 1 + uid=$(id -u "$u" 2>/dev/null) || return 1 + printf '/run/user/%s/docker.sock' "$uid" +} + +which="${1:-}"; state="${2:-}" +case "$which" in + rootless) sock="$(_rootless_sock)" || exit 3 ;; + rooted) sock="$ROOTED_SOCK" ;; + *) echo "usage: libreportal-socket {rootless|rooted} {on|off}" >&2; exit 2 ;; +esac + +[[ -e "$sock" ]] || exit 3 + +case "$state" in + on) chmod +r "$sock" ;; + off) chmod o-r "$sock" ;; + *) echo "usage: libreportal-socket {rootless|rooted} {on|off}" >&2; exit 2 ;; +esac diff --git a/scripts/system/libreportal-svc b/scripts/system/libreportal-svc new file mode 100644 index 0000000..132a649 --- /dev/null +++ b/scripts/system/libreportal-svc @@ -0,0 +1,92 @@ +#!/bin/bash +# LibrePortal task-processor systemd helper — the only root-privileged management +# of the libreportal.service unit the manager may trigger. Installed root:root +# 0755 to /usr/local/sbin by init.sh. Self-contained: it GENERATES the unit from +# config (mode + install-user uid + the baked manager name + fixed script paths) +# — it does NOT accept unit content from the caller (that would be root: an +# arbitrary systemd unit runs anything as root). So the scoped sudoers can allow +# it instead of blanket `sudo tee /etc/systemd/...` + `sudo systemctl`. +# +# Idempotent: only rewrites + daemon-reloads + restarts when the unit changed, +# else just ensures it's enabled + running (no needless restart of in-flight work). + +set -u + +[[ $EUID -eq 0 ]] || { echo "libreportal-svc: must run as root" >&2; exit 1; } + +MANAGER="__MANAGER__" +[[ "$MANAGER" == "__MANAGER__" || -z "$MANAGER" ]] && MANAGER="libreportal" + +SERVICE_FILE="/etc/systemd/system/libreportal.service" +INSTALL_SCRIPTS_DIR="/docker/install/scripts" +TASK_PROCESSOR="$INSTALL_SCRIPTS_DIR/crontab/task/crontab_task_processor.sh" +DB_CFG="/docker/configs/general/general_docker_install" + +_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}" +} + +_gen_unit() { + local env_block="" + if [[ "$(_mode)" == "rootless" ]]; then + local u uid + u=$(grep -h '^CFG_DOCKER_INSTALL_USER=' "$DB_CFG" 2>/dev/null | head -1 | cut -d= -f2 | awk '{print $1}') + uid=$(id -u "${u:-dockerinstall}" 2>/dev/null) + if [[ -n "$uid" ]]; then + env_block="Environment=DOCKER_HOST=unix:///run/user/${uid}/docker.sock +Environment=XDG_RUNTIME_DIR=/run/user/${uid}" + fi + fi + cat </dev/null)" + if [[ "$desired" != "$current" ]]; then + printf '%s\n' "$desired" > "$SERVICE_FILE" + systemctl daemon-reload + systemctl enable libreportal.service >/dev/null 2>&1 + systemctl restart libreportal.service + echo "updated" + else + systemctl enable libreportal.service >/dev/null 2>&1 + systemctl is-active --quiet libreportal.service || systemctl start libreportal.service + echo "unchanged" + fi +} + +action="${1:-}" +case "$action" in + install) install_unit ;; + enable) systemctl enable libreportal.service >/dev/null 2>&1 ;; + restart) systemctl restart libreportal.service ;; + start) systemctl start libreportal.service ;; + status) systemctl is-active libreportal.service ;; + *) echo "usage: libreportal-svc {install|enable|restart|start|status}" >&2; exit 2 ;; +esac diff --git a/scripts/webui/webui_install_systemd.sh b/scripts/webui/webui_install_systemd.sh index afc9073..8ab91d4 100755 --- a/scripts/webui/webui_install_systemd.sh +++ b/scripts/webui/webui_install_systemd.sh @@ -13,11 +13,11 @@ installLibrePortalWebUITaskService() { [[ "$CFG_REQUIREMENT_WEBUI_SERVICE" == "true" ]] || return 0 - local service_file="/etc/systemd/system/libreportal.service" local task_processor_script="$install_scripts_dir/crontab/task/crontab_task_processor.sh" local task_dir="$containers_dir/libreportal/frontend/data/tasks" - # Point the processor at the task dir (idempotent). + # Point the processor at the task dir (idempotent). This edits the + # manager-owned install tree, so no privilege is needed. if [ -f "$task_processor_script" ]; then sed -i "s|TASK_DIR=\".*\"|TASK_DIR=\"$task_dir\"|g" "$task_processor_script" chmod +x "$task_processor_script" @@ -25,66 +25,23 @@ installLibrePortalWebUITaskService() isNotice "Task processor script not found" fi - # Rootless docker exposes the daemon at /run/user//docker.sock and depends - # on XDG_RUNTIME_DIR being set. Systemd units don't inherit user bashrc, so - # without these Environment= lines the processor would fall back to - # /var/run/docker.sock (which rootless does not create). The rootless daemon - # runs as the DOCKER INSTALL USER, so its socket lives in THAT user's runtime - # dir (matches dockerCommandRunInstallUser). Rooted gets no extras — the - # default /var/run path is already correct. - local service_env_block="" - if [[ "$CFG_DOCKER_INSTALL_TYPE" == "rootless" ]]; then - local docker_install_uid - docker_install_uid="$(id -u "$CFG_DOCKER_INSTALL_USER")" - service_env_block="Environment=DOCKER_HOST=unix:///run/user/${docker_install_uid}/docker.sock -Environment=XDG_RUNTIME_DIR=/run/user/${docker_install_uid}" - fi - - local desired - desired="$(cat </dev/null)" - - if [[ "$desired" != "$current" ]]; then - printf '%s\n' "$desired" | runSystem tee "$service_file" > /dev/null - runSystem systemctl daemon-reload - runSystem systemctl enable libreportal.service >/dev/null 2>&1 - runSystem systemctl restart libreportal.service + # The unit itself is generated + installed by the root-owned svc helper (it + # reads the mode + install-user uid from config to build the rootless + # DOCKER_HOST/XDG_RUNTIME_DIR Environment= lines). Idempotent: only restarts on + # an actual change, so a rooted<->rootless switch re-reads the new mode without + # bouncing the processor on routine re-runs. + local svc_result + svc_result="$(runSvc install)" + if [[ "$svc_result" == "updated" ]]; then isSuccessful "LibrePortal task processor service installed/updated ($CFG_DOCKER_INSTALL_TYPE)." else - # Unit already correct — ensure it's enabled + running, without a restart. - runSystem systemctl enable libreportal.service >/dev/null 2>&1 - runSystem systemctl is-active --quiet libreportal.service || runSystem systemctl start libreportal.service isSuccessful "LibrePortal task processor service already up to date." fi - # Drop the legacy crontab entry if present (superseded by the service). - if sudo -u "$sudo_user_name" crontab -l 2>/dev/null | grep -q "task_processor.sh"; then - sudo -u "$sudo_user_name" crontab -l 2>/dev/null | grep -v "task_processor.sh" | sudo -u "$sudo_user_name" crontab - + # Drop the legacy crontab entry if present (superseded by the service). We are + # the manager, so operate on its own crontab directly. + if crontab -l 2>/dev/null | grep -q "task_processor.sh"; then + crontab -l 2>/dev/null | grep -v "task_processor.sh" | crontab - isNotice "Removed task processor from crontab" fi }