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>
188 lines
7.4 KiB
Bash
188 lines
7.4 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) 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"
|
|
}
|
|
|
|
# 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")"
|
|
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"
|
|
}
|
|
|
|
# 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:-}";;
|
|
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
|