The installer's restore path printed "Settings restored" and had never restored
a setting. backupRestoreSystemConfig only STAGES — right in general, since
overwriting a running control plane's config should not be automatic — but
nothing ever adopted the staged tree. The backup locations, domains and logins
landed in $restore_dir/system-config and stayed there.
So adoption is its own step now (`restore adopt`), allowed only on a machine
with nothing on it yet. backup/locations/ is adopted as a subtree, since the
index is part of the path and that directory is the whole point: it holds every
repository and its credentials, which is what makes "one password unlocks the
rest" true. Deliberately NOT adopted: the container account and its generated
password, port allocations, docker/rootless wiring, and storage/locations —
those describe the old machine, and a registry of drives this box does not have
would make every placement lookup resolve to a phantom.
The guard failed in the shape this project keeps hitting. It globbed the
containers directory, but the manager can traverse that without listing it, so
the glob returned a literal '*', the loop skipped it, and the function returned
"first run" on a machine with three apps. It adopted over a live install in
testing. It now asks the container user for the listing and fails closed: an
unreadable directory means "in use", never "empty".
Two config modes were inverted, found because a restore cannot restore from a
snapshot that was never taken:
- storage location configs were 0640 and hold no secrets. The backup runs as
the container user, could not read them, and restic wrote an INCOMPLETE
snapshot and exited 3 — so EVERY system-config backup failed once a second
storage location existed. Now 0644, with the test asserting they stay
secret-free so that mode remains defensible.
- backup location configs were 0644 and hold the repository password; nobody
could read them. They cannot simply be tightened, because the backup has to
read the credentials it uses — so the directory carries the restriction
(config-secure, manager:container 0750) and the file stays readable to the
two accounts that belong.
Fixing that surfaced a third: config-adopt clamped existing parent directories
to manager:manager 0750, closing configs/backup to the container user and
breaking the credential read the directory fix had just preserved.
restore domains reports which restored domains point here, and the installer
offers to drop the strays. Three verdicts, not two: setupCheckDomainPointsHere
falls back to hostname -I, and comparing a public A record to a private 10.x
address would condemn every correctly-pointed domain on a LAN-only box, which
is the deployment this product targets. Unverifiable is never offered for
deletion.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
669 lines
30 KiB
Bash
669 lines
30 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 + backups) needs root. Granting the manager a
|
|
# blanket `sudo chown`/`sudo chmod` would be root-equivalent (chown /etc/sudoers,
|
|
# etc.). Instead this script — installed root:root 0755 to /usr/local/lib/libreportal/
|
|
# by init.sh, so the manager cannot modify it — performs a FIXED set of reconciles
|
|
# on FIXED LibrePortal paths only. The roots and the manager name are BAKED at
|
|
# install (sed placeholders), never read at runtime from a manager-writable config;
|
|
# the single free argument (an app name / relpath) is strictly validated.
|
|
#
|
|
# Layout — three independently-relocatable roots, each owned by ONE principal:
|
|
# SYSTEM_DIR manager-owned control plane (configs/logs/install/db/ssl/ssh/…)
|
|
# CONTAINERS_DIR container-user-owned live app data (apps live directly under it)
|
|
# BACKUPS_DIR container-user-owned backup repos (own mount-able)
|
|
#
|
|
# Self-contained ON PURPOSE: it must NOT source any manager-owned code (incl.
|
|
# paths.sh), 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 values 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 (placeholders replaced). An unbaked copy (run
|
|
# directly from the repo before baking) still contains the "__" sentinel, which no
|
|
# real absolute path does — fall back to the defaults in that case only.
|
|
MANAGER="__MANAGER__"
|
|
CONTAINERS_DIR="__CONTAINERS_DIR__"
|
|
BACKUPS_DIR="__BACKUPS_DIR__"
|
|
SYSTEM_DIR="__SYSTEM_DIR__"
|
|
[[ "$MANAGER" == *"__"* || -z "$MANAGER" ]] && MANAGER="libreportal"
|
|
[[ "$SYSTEM_DIR" == *"__"* || -z "$SYSTEM_DIR" ]] && SYSTEM_DIR="/libreportal-system"
|
|
[[ "$CONTAINERS_DIR" == *"__"* || -z "$CONTAINERS_DIR" ]] && CONTAINERS_DIR="/libreportal-containers"
|
|
[[ "$BACKUPS_DIR" == *"__"* || -z "$BACKUPS_DIR" ]] && BACKUPS_DIR="/libreportal-backups"
|
|
|
|
# Refuse to operate on dangerous roots even if mis-baked (defence in depth).
|
|
for _d in "$SYSTEM_DIR" "$CONTAINERS_DIR" "$BACKUPS_DIR"; do
|
|
case "$_d" in
|
|
/|/etc|/usr|/bin|/sbin|/lib|/lib64|/boot|/proc|/sys|/dev|/run|/home|/root|/var|/tmp)
|
|
echo "libreportal-ownership: refusing dangerous root '$_d'" >&2; exit 1 ;;
|
|
/*) ;; # absolute — ok
|
|
*) echo "libreportal-ownership: root must be absolute: '$_d'" >&2; exit 1 ;;
|
|
esac
|
|
done
|
|
|
|
CONFIGS_DIR="$SYSTEM_DIR/configs"
|
|
LOGS_DIR="$SYSTEM_DIR/logs"
|
|
INSTALL_DIR="$SYSTEM_DIR/install"
|
|
SSL_DIR="$SYSTEM_DIR/ssl"
|
|
SSH_DIR="$SYSTEM_DIR/ssh"
|
|
RESTORE_DIR="$SYSTEM_DIR/restore"
|
|
MIGRATE_DIR="$SYSTEM_DIR/migrate"
|
|
# Storage-location bookkeeping the manager writes (the app -> root index).
|
|
# Deliberately NOT under configs/: that tree is SOURCED, and a data file there
|
|
# is executed. See storageIndexFile in scripts/source/paths.sh.
|
|
STORAGE_DIR="$SYSTEM_DIR/storage"
|
|
DB_PATH="$SYSTEM_DIR/database.db"
|
|
WEBUI_DIR="$CONTAINERS_DIR/libreportal"
|
|
TASK_DIR="$WEBUI_DIR/frontend/data/tasks"
|
|
DB_CFG="$CONFIGS_DIR/general/general_docker_install"
|
|
# Root-owned storage registry — the only authority on which roots may hold apps.
|
|
STORAGE_REGISTRY="/usr/local/lib/libreportal/storage.roots"
|
|
|
|
# Current docker mode, read authoritatively from config (read-only — informs the
|
|
# container OWNER choice, not any path).
|
|
_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/backup 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).
|
|
#
|
|
# An app does not have to be under CONTAINERS_DIR: it can live on any REGISTERED
|
|
# storage location. Looking only in the primary root meant every action keyed on
|
|
# an app name silently did nothing for those — app-data-remove returned 0 having
|
|
# removed nothing, so restore's "Wiping existing app folder" wiped nothing and a
|
|
# restore laid new data over old.
|
|
#
|
|
# Candidate roots come from the root-owned registry, never from the caller, so
|
|
# this still cannot be pointed anywhere root does not already own.
|
|
_app_dir() {
|
|
local app="$1"
|
|
[[ "$app" =~ ^[A-Za-z0-9._-]+$ && "$app" != "." && "$app" != ".." ]] \
|
|
|| { echo "libreportal-ownership: invalid app name" >&2; return 1; }
|
|
|
|
if [[ -d "$CONTAINERS_DIR/$app" ]]; then
|
|
printf '%s' "$CONTAINERS_DIR/$app"
|
|
return 0
|
|
fi
|
|
|
|
if [[ -r "$STORAGE_REGISTRY" ]]; then
|
|
local _id _path _rest
|
|
while IFS=$'\t' read -r _id _path _rest || [[ -n "$_id" ]]; do
|
|
[[ -z "$_path" || "$_id" == \#* ]] && continue
|
|
if [[ -d "${_path%/}/$app" ]]; then
|
|
printf '%s' "${_path%/}/$app"
|
|
return 0
|
|
fi
|
|
done < "$STORAGE_REGISTRY"
|
|
fi
|
|
|
|
echo "libreportal-ownership: no such app dir for '$app'" >&2
|
|
return 1
|
|
}
|
|
|
|
# Let the rootless container user reach the few system-tree files it must read as
|
|
# bind-mount sources (the WebUI's configs/webui/*), WITHOUT exposing the rest of
|
|
# the control plane — or those files' contents to other local users.
|
|
#
|
|
# Access is granted via the GROUP, not world: under rootless the container's gid 0
|
|
# maps to the container owner's gid on the host, so group-read is enough for the
|
|
# container while other local users get nothing. Owner stays the manager so the
|
|
# control plane can still rewrite them; the dir keeps only o+x (traverse, not list).
|
|
# This is what keeps secrets like webui_logins from being world-readable.
|
|
_webui_bind_access() {
|
|
chmod o+x "$SYSTEM_DIR" 2>/dev/null
|
|
[[ -d "$CONFIGS_DIR" ]] && chmod o+x "$CONFIGS_DIR" 2>/dev/null
|
|
if [[ -d "$CONFIGS_DIR/webui" ]]; then
|
|
local cowner; cowner="$(_container_owner "$(_mode)")"
|
|
chown "$MANAGER:$cowner" "$CONFIGS_DIR/webui" 2>/dev/null
|
|
chmod 0751 "$CONFIGS_DIR/webui" 2>/dev/null
|
|
find "$CONFIGS_DIR/webui" -maxdepth 1 -type f \
|
|
-exec chown "$MANAGER:$cowner" {} \; -exec chmod 0640 {} \; 2>/dev/null
|
|
fi
|
|
}
|
|
|
|
# A one-shot channel for a secret typed in the browser.
|
|
#
|
|
# The mirror of _webui_bind_access above. That one makes manager-owned config
|
|
# READABLE by the container; this makes a container-written file readable by the
|
|
# MANAGER — so a password entered in the WebUI never has to travel as part of a
|
|
# task command string. Those land in frontend/data/tasks/*.json, which is 0644
|
|
# inside a world-readable directory, so every local account can read them; a
|
|
# backup repository password is the key to every backup the user has.
|
|
#
|
|
# cowner:MANAGER with the setgid bit: the container owns the directory and can
|
|
# create in it, setgid gives each new file the manager's group, and the container
|
|
# writes the file 0640 — readable by the manager, by nobody else. Mode 0730
|
|
# leaves the directory unlistable on purpose: the manager is handed a filename,
|
|
# it never enumerates. Group rwx is what lets the manager unlink after reading.
|
|
secret_dir() {
|
|
local mode cowner; mode="$(_mode)"; cowner="$(_container_owner "$mode")"
|
|
local parent="$WEBUI_DIR/frontend/data"
|
|
# Create the parent rather than requiring it. The install reconciles WebUI
|
|
# ownership before the container has made frontend/data, so demanding it
|
|
# already exist meant this returned 1 there and the drop was simply absent
|
|
# on a fresh install — silently, since the caller has no reason to check.
|
|
# Ownership is only set when we are the ones creating it; an existing
|
|
# directory belongs to the container and is left exactly as it is.
|
|
if [[ ! -d "$parent" ]]; then
|
|
mkdir -p -- "$parent" || return 1
|
|
chown "$cowner:$cowner" -- "$parent" || return 1
|
|
fi
|
|
local d="$parent/.secrets"
|
|
mkdir -p -- "$d" || return 1
|
|
chown "$cowner:$MANAGER" -- "$d" || return 1
|
|
chmod 2730 -- "$d" || return 1
|
|
return 0
|
|
}
|
|
|
|
# Control plane -> manager; container + backup roots -> container owner.
|
|
reconcile() {
|
|
local mode="${1:-$(_mode)}"
|
|
local cowner; cowner="$(_container_owner "$mode")"
|
|
|
|
if [[ -d "$SYSTEM_DIR" ]]; then
|
|
chown "$MANAGER:$MANAGER" "$SYSTEM_DIR"
|
|
local p
|
|
for p in "$CONFIGS_DIR" "$LOGS_DIR" "$INSTALL_DIR" "$SSL_DIR" "$SSH_DIR" \
|
|
"$RESTORE_DIR" "$MIGRATE_DIR" "$STORAGE_DIR" "$DB_PATH"; do
|
|
[[ -e "$p" ]] && chown -R "$MANAGER:$MANAGER" "$p"
|
|
done
|
|
[[ -f "$DB_PATH" ]] && chmod o+r "$DB_PATH"
|
|
_webui_bind_access
|
|
fi
|
|
|
|
# Data + backups: wholly the container owner's (rootless requires it; this is
|
|
# also what lets restic — which runs AS that user — write the backup repos).
|
|
local d
|
|
for d in "$CONTAINERS_DIR" "$BACKUPS_DIR"; do
|
|
if [[ -d "$d" ]]; then
|
|
chown "$cowner:$cowner" "$d"
|
|
chmod o+x "$d"
|
|
fi
|
|
done
|
|
[[ -d "$WEBUI_DIR" ]] && chown -R "$cowner:$cowner" "$WEBUI_DIR"
|
|
}
|
|
|
|
# Traversal (+x) bits only, on the structural LibrePortal dirs.
|
|
traversal() {
|
|
[[ -d "$SYSTEM_DIR" ]] && chmod o+x "$SYSTEM_DIR"
|
|
local d
|
|
for d in "$INSTALL_DIR" "$SSL_DIR" "$SSH_DIR" "$RESTORE_DIR" "$MIGRATE_DIR"; do
|
|
[[ -d "$d" ]] && find "$d" -maxdepth 2 -type d -exec chmod +x {} \;
|
|
done
|
|
_webui_bind_access
|
|
local mode cowner; mode="$(_mode)"; cowner="$(_container_owner "$mode")"
|
|
for d in "$CONTAINERS_DIR" "$BACKUPS_DIR"; do
|
|
if [[ -d "$d" ]]; then
|
|
chown "$cowner:$cowner" "$d"
|
|
chmod o+x "$d"
|
|
fi
|
|
done
|
|
}
|
|
|
|
# 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 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 backups root -> container owner + traversable (restic runs AS that user).
|
|
backups_top() {
|
|
local mode cowner; mode="$(_mode)"; cowner="$(_container_owner "$mode")"
|
|
if [[ -d "$BACKUPS_DIR" ]]; then
|
|
chown "$cowner:$cowner" "$BACKUPS_DIR"
|
|
chmod o+x "$BACKUPS_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"
|
|
}
|
|
|
|
# Wipe an entire app data tree, including container sub-UID dirs the
|
|
# manager / dockerinstall user can't reach (e.g. invidious/postgresdata uid
|
|
# 232070, nextcloud/html uid 33). Used by uninstall + restore-overwrite —
|
|
# both previously ran `rm -rf` via runFileOp (= as dockerinstall) and silently
|
|
# left sub-UID dirs behind, breaking reinstall + leaking storage.
|
|
# Idempotent: a missing dir is success (caller wants "ensure gone"). Refuses
|
|
# the WebUI's own slot (libreportal) — removing it would brick the WebUI.
|
|
app_data_remove() {
|
|
local app="${1:-}"
|
|
[[ "$app" =~ ^[A-Za-z0-9._-]+$ && "$app" != "." && "$app" != ".." ]] \
|
|
|| { echo "libreportal-ownership: invalid app name" >&2; return 1; }
|
|
[[ "$app" == "libreportal" ]] \
|
|
&& { echo "libreportal-ownership: refusing to remove the WebUI app dir" >&2; return 1; }
|
|
# Through the resolver, so an app on a registered location is found rather
|
|
# than quietly skipped.
|
|
local d
|
|
d=$(_app_dir "$app" 2>/dev/null) || return 0
|
|
rm -rf -- "$d"
|
|
}
|
|
|
|
# Move an app directory to another storage root.
|
|
#
|
|
# Root because it must be: app data holds files owned by rootless sub-UIDs
|
|
# (postgres at 231141 and friends) that the manager can neither read nor
|
|
# recreate, so an unprivileged copy silently loses them.
|
|
#
|
|
# The destination root is validated against the ROOT-OWNED storage registry, not
|
|
# against anything the caller says — the same trust boundary as everywhere else
|
|
# here. A path that is not a registered location is refused outright.
|
|
#
|
|
# Copy-verify-then-delete, never `mv` across filesystems: a half-completed move
|
|
# that has already unlinked the source is unrecoverable, and this runs on the
|
|
# only copy of someone's data.
|
|
app_move() {
|
|
local app="${1:-}" dest_root="${2:-}"
|
|
[[ "$app" =~ ^[A-Za-z0-9._-]+$ && "$app" != "." && "$app" != ".." ]] \
|
|
|| { echo "libreportal-ownership: invalid app name" >&2; return 1; }
|
|
[[ "$app" == "libreportal" ]] \
|
|
&& { echo "libreportal-ownership: the WebUI app dir cannot be moved" >&2; return 1; }
|
|
[[ -n "$dest_root" && "$dest_root" == /* ]] \
|
|
|| { echo "libreportal-ownership: destination must be an absolute path" >&2; return 1; }
|
|
dest_root="${dest_root%/}"
|
|
|
|
# The destination must be a REGISTERED storage root (or the primary one).
|
|
local ok=0
|
|
[[ "$dest_root" == "$CONTAINERS_DIR" ]] && ok=1
|
|
if (( ! ok )) && [[ -r "$STORAGE_REGISTRY" ]]; then
|
|
local _id _path _rest
|
|
while IFS=$'\t' read -r _id _path _rest || [[ -n "$_id" ]]; do
|
|
[[ -z "$_path" || "$_id" == \#* ]] && continue
|
|
[[ "${_path%/}" == "$dest_root" ]] && { ok=1; break; }
|
|
done < "$STORAGE_REGISTRY"
|
|
fi
|
|
(( ok )) || { echo "libreportal-ownership: '$dest_root' is not a registered storage location" >&2; return 1; }
|
|
|
|
# Find the source across every known root.
|
|
local src="" root
|
|
for root in "$CONTAINERS_DIR" $(awk -F'\t' '$1 !~ /^#/ && $2 != "" {print $2}' "$STORAGE_REGISTRY" 2>/dev/null); do
|
|
root="${root%/}"
|
|
[[ -d "$root/$app" ]] && { src="$root/$app"; break; }
|
|
done
|
|
[[ -n "$src" ]] || { echo "libreportal-ownership: no such app dir for '$app'" >&2; return 1; }
|
|
|
|
local dst="$dest_root/$app"
|
|
[[ -e "$dst" ]] && { echo "libreportal-ownership: '$dst' already exists" >&2; return 1; }
|
|
[[ "$src" == "$dst" ]] && return 0
|
|
|
|
# Same filesystem -> rename is atomic and instant. Different -> full copy,
|
|
# verified, and only then unlink the source.
|
|
local src_dev dst_dev
|
|
src_dev=$(stat -c '%d' -- "$src" 2>/dev/null)
|
|
dst_dev=$(stat -c '%d' -- "$dest_root" 2>/dev/null)
|
|
if [[ -n "$src_dev" && "$src_dev" == "$dst_dev" ]]; then
|
|
mv -- "$src" "$dst" || return 1
|
|
return 0
|
|
fi
|
|
|
|
cp -a --reflink=auto -- "$src" "$dst" || { rm -rf -- "$dst"; return 1; }
|
|
|
|
# Verify before deleting anything: file counts and total bytes must match.
|
|
local src_n dst_n src_b dst_b
|
|
src_n=$(find "$src" -mindepth 1 2>/dev/null | wc -l)
|
|
dst_n=$(find "$dst" -mindepth 1 2>/dev/null | wc -l)
|
|
src_b=$(du -sb "$src" 2>/dev/null | awk '{print $1}')
|
|
dst_b=$(du -sb "$dst" 2>/dev/null | awk '{print $1}')
|
|
if [[ "$src_n" != "$dst_n" ]]; then
|
|
echo "libreportal-ownership: copy verification failed ($src_n entries at source, $dst_n at destination). Source left intact at $src" >&2
|
|
return 1
|
|
fi
|
|
if [[ -n "$src_b" && -n "$dst_b" && "$src_b" != "$dst_b" ]]; then
|
|
echo "libreportal-ownership: copy verification failed ($src_b bytes at source, $dst_b at destination). Source left intact at $src" >&2
|
|
return 1
|
|
fi
|
|
|
|
rm -rf -- "$src"
|
|
return 0
|
|
}
|
|
|
|
# Adopt a restored tree into place: move <staged> to <dest_root>/<app>.
|
|
#
|
|
# Separate from app_move because the source is a staging directory under the
|
|
# system tree, not a live app dir — but the same reasoning applies: the tree
|
|
# carries container sub-UIDs the manager cannot recreate, and the destination
|
|
# root is validated against the ROOT-OWNED registry rather than trusted from
|
|
# the caller.
|
|
app_adopt() {
|
|
local app="${1:-}" staged="${2:-}" dest_root="${3:-}"
|
|
[[ "$app" =~ ^[A-Za-z0-9._-]+$ && "$app" != "." && "$app" != ".." ]] \
|
|
|| { echo "libreportal-ownership: invalid app name" >&2; return 1; }
|
|
[[ -n "$staged" && "$staged" == /* && -d "$staged" ]] \
|
|
|| { echo "libreportal-ownership: staged tree must be an existing absolute path" >&2; return 1; }
|
|
[[ "$staged" == *..* ]] \
|
|
&& { echo "libreportal-ownership: invalid staged path" >&2; return 1; }
|
|
# Staging must live under the system tree — never an arbitrary location.
|
|
[[ "$staged" == "$RESTORE_DIR"/* || "$staged" == "$MIGRATE_DIR"/* ]] \
|
|
|| { echo "libreportal-ownership: staged tree must be under the restore/migrate area" >&2; return 1; }
|
|
[[ -n "$dest_root" && "$dest_root" == /* ]] \
|
|
|| { echo "libreportal-ownership: destination must be an absolute path" >&2; return 1; }
|
|
dest_root="${dest_root%/}"
|
|
|
|
local ok=0
|
|
[[ "$dest_root" == "$CONTAINERS_DIR" ]] && ok=1
|
|
if (( ! ok )) && [[ -r "$STORAGE_REGISTRY" ]]; then
|
|
local _id _path _rest
|
|
while IFS=$'\t' read -r _id _path _rest || [[ -n "$_id" ]]; do
|
|
[[ -z "$_path" || "$_id" == \#* ]] && continue
|
|
[[ "${_path%/}" == "$dest_root" ]] && { ok=1; break; }
|
|
done < "$STORAGE_REGISTRY"
|
|
fi
|
|
(( ok )) || { echo "libreportal-ownership: '$dest_root' is not a registered storage location" >&2; return 1; }
|
|
|
|
local dst="$dest_root/$app"
|
|
rm -rf -- "$dst"
|
|
mkdir -p -- "$dest_root"
|
|
|
|
local s_dev d_dev
|
|
s_dev=$(stat -c '%d' -- "$staged" 2>/dev/null)
|
|
d_dev=$(stat -c '%d' -- "$dest_root" 2>/dev/null)
|
|
if [[ -n "$s_dev" && "$s_dev" == "$d_dev" ]]; then
|
|
mv -- "$staged" "$dst" || return 1
|
|
else
|
|
cp -a --reflink=auto -- "$staged" "$dst" || { rm -rf -- "$dst"; return 1; }
|
|
rm -rf -- "$staged"
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# Chown one LibrePortal-managed file under an app dir to the container owner.
|
|
# 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"
|
|
}
|
|
|
|
# Create a staging directory under the restore/migrate area, owned by the
|
|
# container user.
|
|
#
|
|
# Restore stages through $SYSTEM_DIR, which the MANAGER owns — but the thing
|
|
# that writes into the staging tree is restic, and runBackupOp runs it as the
|
|
# container user. So whoever created the directory, the other one could not
|
|
# write to it, and both existing call sites created it as the wrong principal:
|
|
#
|
|
# backupRestoreSystemConfig runFileOp mkdir -> container user, denied on a
|
|
# 0751 manager-owned restore_dir, and the failure
|
|
# was never checked. restic then wrote nothing,
|
|
# exited 0 ("ignoring error ... permission denied"),
|
|
# and the CLI reported "System config restored to:
|
|
# <path>" for a path that did not exist.
|
|
# storageRestoreAppTo runInstallOp mkdir -> manager, so restic could
|
|
# not create anything beneath it.
|
|
#
|
|
# Root has to bridge that, the same way webui-bind already bridges the mirror
|
|
# case. The path is confined to the restore/migrate area and the name to a
|
|
# single component, so this cannot be pointed anywhere else.
|
|
# Shared gate for the staging actions: exactly one component directly below the
|
|
# restore or migrate area, no traversal, no arbitrary path.
|
|
_restore_stage_ok() {
|
|
local path="${1:-}"
|
|
[[ -n "$path" && "$path" == /* && "$path" != *..* ]] \
|
|
|| { echo "libreportal-ownership: invalid staging path" >&2; return 1; }
|
|
path="${path%/}"
|
|
local parent="${path%/*}" leaf="${path##*/}"
|
|
[[ "$parent" == "$RESTORE_DIR" || "$parent" == "$MIGRATE_DIR" ]] \
|
|
|| { echo "libreportal-ownership: staging must sit directly under the restore/migrate area" >&2; return 1; }
|
|
[[ "$leaf" =~ ^[A-Za-z0-9._-]+$ && "$leaf" != "." && "$leaf" != ".." ]] \
|
|
|| { echo "libreportal-ownership: invalid staging name" >&2; return 1; }
|
|
return 0
|
|
}
|
|
|
|
# Remove a staging tree. Root's job for the same reason creating it was: the
|
|
# tree belongs to the container user but sits in a directory the manager owns,
|
|
# so neither of them can unlink it — the manager cannot delete the container
|
|
# user's files inside, and the container user cannot remove the entry from the
|
|
# manager's directory. Left to itself the staging dir simply accumulated.
|
|
restore_unstage() {
|
|
local path="${1:-}"
|
|
_restore_stage_ok "$path" || return 1
|
|
path="${path%/}"
|
|
[[ -e "$path" ]] || return 0
|
|
rm -rf -- "$path"
|
|
}
|
|
|
|
restore_stage() {
|
|
local path="${1:-}"
|
|
_restore_stage_ok "$path" || return 1
|
|
path="${path%/}"
|
|
local parent="${path%/*}"
|
|
|
|
local mode cowner; mode="$(_mode)"; cowner="$(_container_owner "$mode")"
|
|
mkdir -p -- "$parent" || return 1
|
|
chown "$MANAGER:$MANAGER" -- "$parent" 2>/dev/null
|
|
chmod 0751 -- "$parent" 2>/dev/null
|
|
mkdir -p -- "$path" || return 1
|
|
# Owner writes (restic, as the container user), group traverses (the
|
|
# manager, which has to confirm the tree landed and is the account a human
|
|
# reviews it from). Same shape as webui-bind, mirrored.
|
|
chown "$cowner:$MANAGER" -- "$path" || return 1
|
|
chmod 0750 -- "$path" || return 1
|
|
return 0
|
|
}
|
|
|
|
# Adopt ONE file from a restore staging tree into the live config tree.
|
|
#
|
|
# Root has to do this: the staging tree is owned by the container user (restic
|
|
# wrote it) and the manager can traverse but not read inside, so the account
|
|
# that owns the destination cannot read the source.
|
|
#
|
|
# Deliberately one file per call, driven by an allow-list in the caller. A
|
|
# recursive copy of a whole configs tree would carry the SOURCE machine's
|
|
# identity across — its docker user and generated password, its port
|
|
# allocations, its storage registry naming drives this box does not have. The
|
|
# caller decides what is portable; this only enforces where it may land.
|
|
config_adopt() {
|
|
local staging="${1:-}" rel="${2:-}"
|
|
[[ -n "$staging" && -n "$rel" ]] || { echo "libreportal-ownership: config-adopt needs a staging dir and a relative path" >&2; return 2; }
|
|
|
|
# The relative path must stay relative and stay inside the config tree. A
|
|
# rel of "../../etc/shadow" would otherwise be a root-owned write anywhere.
|
|
case "$rel" in
|
|
/*|*..*) echo "libreportal-ownership: refusing unsafe config path: $rel" >&2; return 1 ;;
|
|
esac
|
|
case "$staging" in
|
|
/*) ;;
|
|
*) echo "libreportal-ownership: staging must be an absolute path" >&2; return 1 ;;
|
|
esac
|
|
|
|
local src="${staging%/}/$rel"
|
|
local dst="$CONFIGS_DIR/$rel"
|
|
[[ -f "$src" ]] || { echo "libreportal-ownership: no such file in the backup: $rel" >&2; return 1; }
|
|
|
|
# Resolve and re-check: a symlink inside the staging tree could otherwise
|
|
# point the read anywhere, and the destination must land under CONFIGS_DIR
|
|
# no matter what the path looked like before normalisation.
|
|
local real_dst
|
|
real_dst="$(realpath -m -- "$dst")" || return 1
|
|
case "$real_dst" in
|
|
"$CONFIGS_DIR"/*) ;;
|
|
*) echo "libreportal-ownership: refusing to write outside the config tree: $real_dst" >&2; return 1 ;;
|
|
esac
|
|
|
|
# Create the parent if it is missing, but never re-permission one that
|
|
# already exists. Clamping every parent to manager:manager 0750 is what
|
|
# closed configs/backup to the container user and stopped it reading the
|
|
# backup credentials it runs with — a copy has no business rewriting the
|
|
# permissions of directories it merely passes through.
|
|
if [[ ! -d "${real_dst%/*}" ]]; then
|
|
mkdir -p -- "${real_dst%/*}" || return 1
|
|
chown "$MANAGER:$MANAGER" -- "${real_dst%/*}" 2>/dev/null
|
|
chmod 0750 -- "${real_dst%/*}" 2>/dev/null
|
|
fi
|
|
|
|
# --dereference: copy what a symlink points at, never the link itself.
|
|
cp -f --dereference -- "$src" "$real_dst" || return 1
|
|
chown "$MANAGER:$MANAGER" -- "$real_dst" || return 1
|
|
# These files carry backup-repository passwords and login hashes, so they
|
|
# are never group- or world-readable.
|
|
chmod 0640 -- "$real_dst" || return 1
|
|
return 0
|
|
}
|
|
|
|
# Close the backup-location config directory to accounts that are not part of
|
|
# LibrePortal.
|
|
#
|
|
# Those files hold CFG_BACKUP_LOC_<idx>_PASSWORD — the key to every backup the
|
|
# user has — and they were world-readable: `nobody` could read them. The file
|
|
# mode cannot simply be tightened, because the backup runs as the container
|
|
# user and genuinely has to read the credentials it is about to use.
|
|
#
|
|
# So the directory carries the restriction instead: owned by the manager, group
|
|
# the container user, 0750. Both accounts that need it still get in; nothing
|
|
# else can traverse, whatever the modes inside say.
|
|
config_secure() {
|
|
local d="$CONFIGS_DIR/backup/locations"
|
|
[[ -d "$d" ]] || return 0
|
|
local mode cowner; mode="$(_mode)"; cowner="$(_container_owner "$mode")"
|
|
chown "$MANAGER:$cowner" -- "$d" || return 1
|
|
chmod 0750 -- "$d" || return 1
|
|
# Per-location subdirectories, same reasoning.
|
|
local sub
|
|
for sub in "$d"/*/; do
|
|
[[ -d "$sub" ]] || continue
|
|
chown "$MANAGER:$cowner" -- "$sub" 2>/dev/null
|
|
chmod 0750 -- "$sub" 2>/dev/null
|
|
done
|
|
return 0
|
|
}
|
|
|
|
# Adopt a whole config SUBTREE from a restore staging tree.
|
|
#
|
|
# For the per-location directories, where the index is part of the path and an
|
|
# allow-list of fixed filenames cannot name them. backup/locations/<n>/ is the
|
|
# one that matters: it holds the repository credentials, and a restore that
|
|
# does not bring those back has not restored the thing the user came for.
|
|
#
|
|
# Files land 0644, not 0640, deliberately — the backup runs as the container
|
|
# user and has to read the credentials it is about to use. What keeps everyone
|
|
# else out is the directory (config_secure, manager:container 0750), which this
|
|
# re-applies once the copy is done.
|
|
config_adopt_tree() {
|
|
local staging="${1:-}" rel="${2:-}"
|
|
[[ -n "$staging" && -n "$rel" ]] || { echo "libreportal-ownership: config-adopt-tree needs a staging dir and a relative path" >&2; return 2; }
|
|
case "$rel" in
|
|
/*|*..*) echo "libreportal-ownership: refusing unsafe config path: $rel" >&2; return 1 ;;
|
|
esac
|
|
case "$staging" in
|
|
/*) ;;
|
|
*) echo "libreportal-ownership: staging must be an absolute path" >&2; return 1 ;;
|
|
esac
|
|
|
|
local src="${staging%/}/$rel"
|
|
[[ -d "$src" ]] || { echo "libreportal-ownership: no such directory in the backup: $rel" >&2; return 1; }
|
|
|
|
local dst real_dst
|
|
dst="$CONFIGS_DIR/$rel"
|
|
real_dst="$(realpath -m -- "$dst")" || return 1
|
|
case "$real_dst" in
|
|
"$CONFIGS_DIR"/*) ;;
|
|
*) echo "libreportal-ownership: refusing to write outside the config tree: $real_dst" >&2; return 1 ;;
|
|
esac
|
|
|
|
mkdir -p -- "$real_dst" || return 1
|
|
# --no-dereference is wrong here and -L is right: a symlink in the staging
|
|
# tree must be resolved to its content, never recreated as a link that
|
|
# could point anywhere once it lands in the config tree.
|
|
cp -RfL --no-preserve=mode,ownership -- "$src/." "$real_dst/" || return 1
|
|
chown -R "$MANAGER:$MANAGER" -- "$real_dst" || return 1
|
|
find "$real_dst" -type d -exec chmod 0750 {} + 2>/dev/null
|
|
find "$real_dst" -type f -exec chmod 0644 {} + 2>/dev/null
|
|
config_secure
|
|
return 0
|
|
}
|
|
|
|
action="${1:-}"; shift 2>/dev/null || true
|
|
case "$action" in
|
|
reconcile) reconcile "${1:-}";;
|
|
traversal) traversal;;
|
|
containers-top) containers_top;;
|
|
backups-top) backups_top;;
|
|
db-own) db_own;;
|
|
app-perms) app_perms;;
|
|
webui) webui;;
|
|
webui-bind) _webui_bind_access;;
|
|
taskdir) taskdir;;
|
|
app-data-nobody) app_data_nobody "${1:-}";;
|
|
app-data-remove) app_data_remove "${1:-}";;
|
|
app-file) app_file "${1:-}" "${2:-}";;
|
|
app-move) app_move "${1:-}" "${2:-}";;
|
|
app-adopt) app_adopt "${1:-}" "${2:-}" "${3:-}";;
|
|
restore-stage) restore_stage "${1:-}";;
|
|
restore-unstage) restore_unstage "${1:-}";;
|
|
secret-dir) secret_dir;;
|
|
config-adopt) config_adopt "${1:-}" "${2:-}";;
|
|
config-secure) config_secure;;
|
|
config-adopt-tree) config_adopt_tree "${1:-}" "${2:-}";;
|
|
*) echo "usage: libreportal-ownership {reconcile [mode]|traversal|containers-top|backups-top|db-own|app-perms|webui|webui-bind|taskdir|app-data-nobody <app>|app-data-remove <app>|app-file <app> <relpath>|app-move <app> <dest-root>|app-adopt <app> <staged> <dest-root>|restore-stage <path>|restore-unstage <path>|secret-dir}" >&2; exit 2;;
|
|
esac
|