LibrePortal/scripts/cli/commands/updater/cli_updater_commands.sh
librelad f49455e38e fix(de-sudo): route all confirmed container-tree writes through the privileged path
Exhaustive audit (workflow: 19 finders + adversarial per-file verify; 85 raw ->
66 unique -> 39 confirmed) found 36 direct writes into the container-owned tree
that bypass runFileOp/runFileWrite/runCfgOp (manager => EACCES in rootless) plus
3 $?-masking sites. Fixes by area:

- apps: grafana + prometheus install hooks (sudo chmod -> runFileOp chmod);
  gluetun provider etag (tee -> runFileWrite).
- webui generators: task-create (10 sites: mkdir/chown/tee/jq|tee/sed|tee ->
  runFileOp/runFileWrite); app-icons (mkdir/cp/mv); config icon cp; system
  metrics + update throttle stamps (runAsManager touch -> runFileOp touch);
  setup-lock rm; updater history seed + cp.
- task health checker: 4 log writes (tee -a -> runFileWrite -a) + 3 find -delete
  (-> runFileOp find).
- config reconcile: backup cp -> runCfgOp; live cp -> runFileWrite < tmp for
  container-owned configs (the container user can't read a manager 0600 tmp).
- peer pull: tar extract into the container tree -> runFileOp tar.
- masking: ip_find_available + folder_group(x2) — split 'local VAR=$(cmd)' so $?
  reaches the following [[ $? ]] check.

15 files, all pass bash -n; fixed idioms confirmed gone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-31 03:50:48 +01:00

169 lines
7.1 KiB
Bash

#!/bin/bash
# App Updater command handler — `libreportal updater <sub>`
# ---------------------------------------------------------------------------
# Dispatched automatically by cli_initialize.sh (category -> cliHandleUpdaterCommands).
# Subcommands (the features/updater WebUI buttons route to these as tasks):
# check refresh the version/CVE data (runs the WebUI generator)
# apply <app> update one app — DISASTER-RECOVERY FIRST: snapshot the app
# via the backup engine, then pull + recreate; on failure,
# roll back to the snapshot automatically
# apply-all [a,b] apply to a comma-list (or every update-available app)
# rollback <app> restore the app's most recent pre-update snapshot
#
# State-changing subcommands use the standard task-exec split: invoked normally
# they enqueue a task (so the WebUI + CLI share locking + the audit trail);
# the task processor re-invokes them with LIBREPORTAL_TASK_EXEC=1 to do the work.
cliHandleUpdaterCommands()
{
local sub="$initial_command2"
local app="$initial_command3"
case "$sub" in
""|"check")
# Quick + safe — just regenerates the read-only data files. Source
# the generator explicitly if the lazy loader hasn't mapped it yet
# (new file; the array regen self-heals it on deploy, this covers
# the gap before that).
if ! declare -F webuiUpdaterScan >/dev/null 2>&1; then
source "$install_scripts_dir/webui/data/generators/updater/webui_updater_scan.sh" 2>/dev/null
fi
webuiUpdaterScan
;;
"apply"|"now")
if [[ -z "$app" ]]; then isError "Usage: libreportal updater apply <app>"; return 1; fi
if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
updaterApplyApp "$app"
else
cliTaskRun "libreportal updater apply $app" "updater_apply" "$app" ""
fi
;;
"apply-all")
local list="$app" # optional comma-list in $initial_command3
if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
updaterApplyAll "$list"
else
cliTaskRun "libreportal updater apply-all $list" "updater_apply_all" "updater" ""
fi
;;
"rollback")
if [[ -z "$app" ]]; then isError "Usage: libreportal updater rollback <app>"; return 1; fi
if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
updaterRollbackApp "$app"
else
cliTaskRun "libreportal updater rollback $app" "updater_rollback" "$app" ""
fi
;;
*)
cliShowUpdaterHelp
;;
esac
}
# Update one app with disaster-recovery: snapshot -> pull -> recreate -> verify,
# auto-rolling-back on failure. Uses existing primitives (the backup CLI for the
# snapshot, docker compose for the image swap) so it shares their locking/logging.
updaterApplyApp()
{
local app="$1"
local app_dir="$containers_dir/$app"
if [[ ! -d "$app_dir" ]]; then isError "App '$app' is not installed."; return 1; fi
isHeader "Updating $app (a recovery snapshot is taken first)"
# 1. DISASTER RECOVERY — snapshot before touching anything.
isNotice "Snapshotting $app before update…"
if ! libreportal backup app "$app" >/dev/null 2>&1; then
isNotice "Pre-update snapshot did not complete cleanly — continuing is risky; aborting $app update."
updaterRecordHistory "$app" "update" "" "" "aborted-no-snapshot"
return 1
fi
# 2. Capture the current image so we can record from->to / roll back.
local before; before="$(grep -m1 -E '^\s*image:' "$app_dir/docker-compose.yml" 2>/dev/null | sed -E 's/^\s*image:\s*//; s/["'"'"']//g')"
# 3. Pull + recreate (uses the real, install-type-aware compose helpers).
isNotice "Pulling new image(s) for $app"
if updaterComposePull "$app" && dockerComposeUp "$app" >/dev/null 2>&1; then
local after; after="$(grep -m1 -E '^\s*image:' "$app_dir/docker-compose.yml" 2>/dev/null | sed -E 's/^\s*image:\s*//; s/["'"'"']//g')"
updaterRecordHistory "$app" "update" "$before" "$after" "ok"
isSuccessful "$app updated. Rollback point retained."
webuiUpdaterScan >/dev/null 2>&1 || true
return 0
fi
# 4. Failure -> automatic rollback.
isNotice "Update of $app failed — rolling back to the pre-update snapshot…"
updaterRollbackApp "$app" "auto"
updaterRecordHistory "$app" "update" "$before" "" "rolled-back"
return 1
}
updaterApplyAll()
{
local list="$1" failures=0
if [[ -z "$list" ]]; then
isNotice "No app list given; nothing to do (the WebUI passes the update-available apps)."
return 0
fi
local IFS=','
for app in $list; do
[[ -z "$app" ]] && continue
updaterApplyApp "$app" || failures=$((failures+1))
done
[[ $failures -gt 0 ]] && isNotice "$failures app(s) failed and were rolled back." || isSuccessful "All requested apps updated."
}
# Roll an app back to its most recent snapshot. $2='auto' suppresses the header
# (called from the failure path of an apply).
updaterRollbackApp()
{
local app="$1" mode="$2"
[[ "$mode" != "auto" ]] && isHeader "Rolling $app back to its pre-update snapshot"
# Delegate to the backup engine's restore (latest snapshot for this app).
if libreportal backup app "$app" restore latest >/dev/null 2>&1; then
dockerComposeUp "$app" >/dev/null 2>&1 || true
[[ "$mode" != "auto" ]] && updaterRecordHistory "$app" "rollback" "" "" "rolled-back"
isSuccessful "$app restored from its pre-update snapshot."
return 0
fi
isError "Could not roll $app back automatically — restore manually from the Backups page."
return 1
}
# Force a fresh image pull for an app (mirrors up_app.sh's install-type split).
# dockerComposeUp uses --quiet-pull which won't re-fetch a moved tag, so we pull
# explicitly first to actually pick up a new image.
updaterComposePull()
{
local app="$1" dir="${containers_dir%/}/$1"
[ -d "$dir" ] || return 1
if [[ "$CFG_DOCKER_INSTALL_TYPE" == "rootless" ]]; then
dockerCommandRunInstallUser "cd $dir && docker compose pull" >/dev/null 2>&1
else
( cd "$dir" && docker compose pull >/dev/null 2>&1 )
fi
}
# Append an entry to history.json (best-effort; needs jq, skips silently if absent).
updaterRecordHistory()
{
local app="$1" action="$2" from="$3" to="$4" result="$5"
local f="$containers_dir/libreportal/frontend/data/updater/generated/history.json"
command -v jq >/dev/null 2>&1 || return 0
[ -f "$f" ] || printf '{ "entries": [] }\n' | runFileWrite "$f"
local ts; ts="$(date -Iseconds 2>/dev/null || date)"
local tmp; tmp="$(mktemp)"
if jq --arg ts "$ts" --arg app "$app" --arg action "$action" --arg from "$from" --arg to "$to" --arg result "$result" \
'.entries = ([{ts:$ts, app:$app, action:$action, from:$from, to:$to, result:$result}] + (.entries // []))[0:200]' \
"$f" > "$tmp" 2>/dev/null; then
runFileWrite "$f" < "$tmp"
fi
rm -f "$tmp"
}