LibrePortal/scripts/peer/peer_pull.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

131 lines
5.4 KiB
Bash

#!/bin/bash
# Pull an app from a direct-ssh-direct peer onto this host. End-to-end:
#
# 1. Resolve peer + verify reachable (peerPing).
# 2. Take the existing-app safety backup (migratePreBackupDestination) so
# a bad pull is rollback-able via the normal restore flow.
# 3. Stop + wipe the destination's app folder (same as restoreAppStart).
# 4. Stream `peer-shell stream-app <slug>` over SSH and untar into
# containers_dir/.
# 5. Reuse the install-time tag pipeline to repoint host-bound values:
# - dockerComposeUpdateAndStartApp <app> install re-emits compose
# - migrateApplyUrlRewrite rewrites URLs
# 6. Start the container, run app-specific post-restore hooks.
#
# That gives us the same idempotent install path the restic-mediated
# migrateApplyApp uses, just with tar-over-SSH as the data source instead
# of a restic snapshot.
# peerPullApp <peer-name> <app-slug> [--no-pre-backup] [--keep-urls]
peerPullApp()
{
local peer_name="$1"; shift
local app="$1"; shift
# Reuse migrate_apply's opt parser to keep flag semantics identical.
_migrateParseOpts "$@"
if [[ -z "$peer_name" || -z "$app" ]]; then
isError "peerPullApp: peer_name and app required"
return 1
fi
local row; row=$(peerGet "$peer_name")
if [[ -z "$row" || "$row" == "null" ]]; then
isError "No peer named '$peer_name'"
return 1
fi
local kind
kind=$(printf '%s' "$row" | grep -o '"kind":"[^"]*"' | head -1 | cut -d'"' -f4)
if [[ "$kind" != "direct-ssh-direct" ]]; then
isError "Peer '$peer_name' is kind=$kind — peerPullApp needs direct-ssh-direct"
return 1
fi
local started_at; started_at=$(date +%s)
isHeader "Pull $app from peer=$peer_name → this host"
migrateEmit phase=start status=running app="$app" peer="$peer_name" transport=direct-ssh
# ---- 1. Reachability --------------------------------------------------
migrateEmit phase=ping status=running peer="$peer_name"
local ping_status; ping_status=$(peerPing "$peer_name")
if [[ "$ping_status" != "ok" ]]; then
isError "Peer '$peer_name' unreachable: $ping_status"
migrateEmit phase=ping status=failed detail="$ping_status"
return 1
fi
migrateEmit phase=ping status=complete
# ---- 2. Pre-backup of destination (default ON) ------------------------
if (( MIGRATE_OPT_PRE_BACKUP )); then
migratePreBackupDestination "$app"
else
migrateEmit phase=pre-backup status=skipped reason=user-opt-out app="$app"
fi
# Per-app pre-migrate hook (optional) — declared in the app's tools.sh.
migrateRunHook "$app" pre "$peer_name" "direct-ssh"
# ---- 3. Stop + wipe ----------------------------------------------------
migrateEmit phase=stop status=running app="$app"
if declare -f dockerComposeDown >/dev/null 2>&1 && [[ -d "$containers_dir$app" ]]; then
dockerComposeDown "$app" >/dev/null 2>&1 || true
fi
if [[ -d "$containers_dir$app" ]]; then
runFileOp rm -rf "${containers_dir:?}$app"
fi
migrateEmit phase=stop status=complete app="$app"
# ---- 4. Stream tar over SSH and untar ---------------------------------
migrateEmit phase=transfer status=running app="$app"
# The pipe gives us streaming throughput without staging the whole tarball
# to disk. set -o pipefail catches ssh failures so we don't run the rest
# of the flow on a partial extract.
(
set -o pipefail
peerExec "$peer_name" "stream-app $app" | runFileOp tar -C "$containers_dir" -xf -
)
local transfer_rc=$?
if (( transfer_rc != 0 )); then
isError "Transfer of $app from $peer_name failed (rc=$transfer_rc)"
migrateEmit phase=transfer status=failed rc="$transfer_rc"
return 1
fi
if [[ ! -d "$containers_dir$app" ]]; then
isError "Transfer reported success but $containers_dir$app missing"
migrateEmit phase=transfer status=failed reason=missing-output
return 1
fi
runFileOp chown -R "${docker_install_user:-$(whoami)}":"${docker_install_user:-$(whoami)}" "$containers_dir$app" 2>/dev/null || true
migrateEmit phase=transfer status=complete app="$app"
# ---- 5. URL rewrite (default ON) + re-deploy compose -------------------
if ! (( MIGRATE_OPT_KEEP_URLS )); then
migrateApplyUrlRewrite "$app"
else
migrateEmit phase=url-rewrite status=skipped reason=user-opt-out app="$app"
fi
if declare -f dockerComposeUpdateAndStartApp >/dev/null 2>&1; then
dockerComposeUpdateAndStartApp "$app" install >/dev/null 2>&1 || true
fi
# ---- 6. Start + post-restore hooks ------------------------------------
migrateEmit phase=start-app status=running app="$app"
if declare -f dockerComposeUp >/dev/null 2>&1; then
dockerComposeUp "$app" >/dev/null 2>&1 || true
fi
if declare -f restoreAppRunHook >/dev/null 2>&1; then
restoreAppRunHook "$app" post || true
fi
migrateEmit phase=start-app status=complete app="$app"
# Per-app post-migrate hook (optional) — last thing before declaring done.
migrateRunHook "$app" post "$peer_name" "direct-ssh"
local finished_at; finished_at=$(date +%s)
local duration=$((finished_at - started_at))
isSuccessful "Pulled $app from $peer_name in ${duration}s"
migrateEmit phase=done status=complete app="$app" duration_seconds="$duration"
}