feat(desudo): socket + systemd-svc helpers; route traefik/db chowns + svc

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 <app> <relpath> 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 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-05-24 18:28:56 +01:00
parent c6ba3b0ed8
commit 9af2465ffe
8 changed files with 188 additions and 76 deletions

View File

@ -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

View File

@ -119,6 +119,14 @@ runResolv() { _runRootHelper libreportal-dns "$@"; }
# key-add <b64>|key-remove <fp>|pw-set <on|off>}
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.

View File

@ -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

View File

@ -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

View File

@ -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 <app>}" >&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>|app-file <app> <relpath>}" >&2; exit 2;;
esac

View File

@ -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

View File

@ -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 <<EOF
[Unit]
Description=LibrePortal Task Processor
After=network.target
Wants=network.target
[Service]
Type=simple
User=$MANAGER
Group=$MANAGER
WorkingDirectory=$INSTALL_SCRIPTS_DIR
ExecStart=$TASK_PROCESSOR start_script
Restart=always
RestartSec=5
SyslogIdentifier=libreportal
${env_block}
# Security
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
}
install_unit() {
local desired current=""
desired="$(_gen_unit)"
[[ -f "$SERVICE_FILE" ]] && current="$(cat "$SERVICE_FILE" 2>/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

View File

@ -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/<uid>/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 <<EOF
[Unit]
Description=LibrePortal Task Processor
After=network.target
Wants=network.target
[Service]
Type=simple
User=$sudo_user_name
Group=$sudo_user_name
WorkingDirectory=$install_scripts_dir
ExecStart=$task_processor_script start_script
Restart=always
RestartSec=5
SyslogIdentifier=libreportal
${service_env_block}
# Security
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
)"
local current=""
[[ -f "$service_file" ]] && current="$(runSystem cat "$service_file" 2>/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
}