LibrePortal/scripts/docker/command/run_privileged.sh
librelad 7aed9102c4 fix(tools): keep the user on the Tools tab and show the result there
Running any tool jumped to the Tasks tab and left the user stranded there. That
is right for an install — long, log-heavy, worth watching — and wrong for a
tool, which is a short admin action whose answer is one line. Worse, half of
these are only meaningful back on Tools: List Users opens a modal over that tab,
and Create User Account returns a generated password that was being buried in a
log the user then had to go read.

Tools now stay put. On completion the tool's own outcome lines — the
isSuccessful/isError/isNotice output, ANSI stripped and framework boilerplate
filtered — are shown in a small result modal, with a View log button for
anything needing the full detail. list_users is left alone because the existing
account-list modal is already a better result view.

Also stops generate_arrays.sh walking scripts/dev. That directory is
`export-ignore`d, so it exists in a working clone but never in a shipped
install; generating a files_dev.sh entry from it wrote a reference into
files_source.sh that no install could satisfy, and the loader treats a missing
array file as a broken installation — every libreportal command stopped with
"files_dev.sh is missing from your LibrePortal Installation". Excluded alongside
unused/, system/ and release/. Regenerating also picked up scripts/validation,
which had never had an array file.

And Matrix's account listing prints its aligned line from python rather than
re-splitting the marker line in bash: TAB is IFS whitespace, so an empty display
name collapsed into the previous delimiter and shifted every later column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 00:25:19 +01:00

188 lines
8.9 KiB
Bash

#!/bin/bash
# Mode-aware privileged operations.
#
# Ownership model (single source of truth — see reconcileDockerOwnership):
# The MANAGER user ($sudo_user_name, e.g. libreportal) runs the CLI + host
# scripts and is in the docker group, so it owns and operates the LibrePortal
# control plane in BOTH modes. root:root is never the intended owner — it only
# ever appeared as an artifact of un-de-sudo'd `sudo` commands.
# rooted — the manager owns everything under /docker (it talks to the root
# docker socket via the docker group); ops run AS the manager.
# rootless — the manager owns the control plane; the docker install user owns
# /docker/containers/** (the rootless daemon requires it).
# Only genuine system administration (apt/systemctl/ufw/sysctl/useradd, /etc)
# needs real root — that goes through runSystem.
# Run a command AS the manager user (plain if we're already it — the runtime
# case — otherwise sudo -u to it, e.g. at install time when we're root). This is
# how we keep files manager-owned instead of accidentally root-owned.
runAsManager() {
local mgr="${sudo_user_name:-libreportal}"
if [[ "$(id -un)" == "$mgr" ]]; then
"$@"
else
sudo -u "$mgr" "$@"
fi
}
# /docker data-plane command (mkdir/chown/rm/cp/mv/sed/sqlite3/docker/etc.) on
# app/container files.
# rooted -> as the manager user (owns /docker, in the docker group)
# rootless -> as the docker install user (owns /docker/containers/**, and has
# DOCKER_HOST set so `docker ...` hits the rootless socket)
# For stdin-fed writes (`… | sudo tee file`) use runFileWrite below.
runFileOp() {
if [[ "$CFG_DOCKER_INSTALL_TYPE" == "rootless" ]]; then
dockerCommandRunInstallUser --argv "$@"
else
runAsManager "$@"
fi
}
# Write stdin to a /docker data-plane path (replaces `… | sudo tee path`).
# Pass -a/--append as the first arg to append instead of truncate.
# Usage: some_command | runFileWrite [-a] /path/to/file
runFileWrite() {
local append_flag=()
if [[ "$1" == "-a" || "$1" == "--append" ]]; then
append_flag=(-a)
shift
fi
local dest="$1"
if [[ "$CFG_DOCKER_INSTALL_TYPE" == "rootless" ]]; then
# --argv: pass tee + the destination as literal argv (no `bash -c`), so a
# path containing a quote/metachar can't break out of a shell string and
# inject a command. Mirrors runFileOp; the >/dev/null is the manager-side
# shell's (suppresses tee's stdout echo). stdin is preserved by sudo.
dockerCommandRunInstallUser --argv tee "${append_flag[@]}" "$dest" >/dev/null
else
runAsManager tee "${append_flag[@]}" "$dest" >/dev/null
fi
}
# Op on a MANAGER-owned path — the LibrePortal clone/templates AND the /docker
# control plane (apps DB, configs/, logs/, scripts). Owned by the manager in
# BOTH modes, so it always runs as the manager.
runInstallOp() {
runAsManager "$@"
}
# Write stdin to a MANAGER-owned path (apps DB sidecars, configs/, logs/ — e.g.
# the /docker/logs log-append idiom). Manager-owned in both modes.
# Pass -a/--append as the first arg to append.
runInstallWrite() {
local append_flag=()
if [[ "$1" == "-a" || "$1" == "--append" ]]; then
append_flag=(-a)
shift
fi
local dest="$1"
runAsManager tee "${append_flag[@]}" "$dest" >/dev/null
}
# Run a read/edit op against a CONFIG FILE, auto-selecting elevation by where the
# file lives: the container data-plane (/libreportal-containers, install-user-owned
# in rootless) -> runFileOp; the manager-owned control plane (configs/, the clone,
# backup-location configs) -> runInstallOp. The target file must be the LAST arg
# (true for the grep/sed/awk calls in the password replacers). Without this,
# sed -i EACCES'd its own temp file whenever the manager edited an app config
# copied into the container tree (the adguard.config "couldn't open temporary
# file" bug — the substitution silently failed, leaving the placeholder).
runCfgOp() {
local _file="${!#}"
if [[ -n "$containers_dir" && "$_file" == "$containers_dir"* ]]; then
runFileOp "$@"
else
runInstallOp "$@"
fi
}
# Backup-engine command (borg/restic/kopia) run AS the dedicated backup user
# ($docker_install_user), with the repo password and BORG_/RESTIC_/KOPIA_ env
# vars carried across the privilege drop. Never root — the scoped sudoers lets
# the manager drop to this user. Single funnel so the backup subsystem's
# privilege drop has one audit point.
# The vars are named explicitly instead of using bare `-E`: sudo-rs (the default
# from Ubuntu 25.10, so on 26.04) doesn't implement -E — it prints "preserving
# the entire environment is not supported, '-E' is ignored" to stderr and then
# runs the command with the environment DROPPED. Exit status is unaffected and
# callers capture stderr, so that failure is invisible; the engine just can't
# open the repository. --preserve-env=<list> is honoured by both sudo-rs and
# classic sudo (>=1.8.21, so Debian 10's 1.8.27 included).
# The literal fallback mirrors $backup_env_preserve in variables.sh, which isn't
# loaded when init.sh sources this file directly during install.
# -H resets HOME to the target user's so restic finds (or creates) its cache
# under /home/$docker_install_user/.cache/restic instead of inheriting the
# manager's HOME (which dockerinstall can't write into, surfacing as
# "unable to open cache: mkdir /home/libreportal/.cache/restic: permission denied"
# on every backup).
runBackupOp() {
sudo --preserve-env="${backup_env_preserve:-BORG_PASSPHRASE,BORG_REPO,BORG_RSH,KOPIA_CHECK_FOR_UPDATES,KOPIA_CONFIG_PATH,KOPIA_PASSWORD,RESTIC_PASSWORD,RESTIC_REPOSITORY,RESTIC_SFTP_COMMAND}" -H -u "$docker_install_user" "$@"
}
# Run one of the ROOT-OWNED LibrePortal helpers installed (root:root 0755) under
# /usr/local/lib/libreportal/ by init.sh. These are how the manager-run runtime
# (Model A) performs the genuine-root operations it can't drop — establishing the
# /docker ownership model, editing /etc/resolv.conf, managing host SSH access —
# WITHOUT the scoped sudoers granting blanket `sudo chown/chmod/tee/sed/cp` (which
# would be root-equivalent: chown /etc/sudoers, tee a new sudoers drop-in, …).
# Each helper validates its own fixed-path operations, so the sudoers can allow it
# wholesale. At install time (already root) the installed helper may be absent, so
# run the bundled copy directly — no sudo, no escalation, since we are root.
_runRootHelper() {
local name="$1"; shift
local helper="/usr/local/lib/libreportal/$name"
if [[ -x "$helper" ]]; then
sudo "$helper" "$@"
elif [[ $EUID -eq 0 ]]; then
bash "${script_dir:-/libreportal-system/install}/scripts/system/$name" "$@"
else
sudo "$helper" "$@"
fi
}
# Ownership reconcile: action ∈ {reconcile [mode]|traversal|containers-top|
# app-perms|webui|taskdir|app-data-nobody <app>}
runOwnership() { _runRootHelper libreportal-ownership "$@"; }
# /etc/resolv.conf edits: {clear|add <ip>}
runResolv() { _runRootHelper libreportal-dns "$@"; }
# Host SSH access (authorized_keys + sshd PasswordAuthentication):
# {ensure-dir|key-count|pw-status|has-keys|read-keys|authkeys-path|
# 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 "$@"; }
# Backup-engine binary install (restic/kopia) to /usr/local/bin: install <engine>
runBinInstall() { _runRootHelper libreportal-bininstall "$@"; }
# App config-file rewrites owned by in-container uids / root /etc:
# {adguard-auth <user> <bcrypt>|owncloud-config <public> <host> <ip> <public_ip>|
# wireguard-ip-forward}
runAppCfg() { _runRootHelper libreportal-appcfg "$@"; }
# CrowdSec host-side privileged ops — apt install of the agent + firewall
# bouncer, cscli register/enroll, /etc/crowdsec/* edits, /var/log/crowdsec*.log
# touch+chmod, /etc/crowdsec/traefik_bouncer.key write. One audit funnel for
# every operation the host-side CrowdSec install needs the manager can't drop:
# {install|services <enable|disable|restart>|capi <register|unregister|status>
# |console <enroll <token>|disenroll|status>|bouncer-traefik-init|bouncer-traefik-rotate|
# bouncer-priority|bind-lapi <addr:port>|prometheus <on <addr> <port>|off>|touch-host-logs}
runCrowdsec() { _runRootHelper libreportal-crowdsec "$@"; }
# 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.
runSystem() {
sudo "$@"
}