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>
188 lines
8.9 KiB
Bash
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 "$@"
|
|
}
|