librelad 2df4e28a85 feat(distribution): Phase 2 — artifact apply/revert pipeline + ops interpreter
The mutating side of the unified distribution primitive (spec §8.3). Hotfixes
can now be applied and reverted, first-party, through the task system.

New scripts/cli/commands/artifact/cli_artifact_apply.sh:
- artifactApply <id>: resolve+gate (applies_when / min_lp / max_lp /
  max_footprint / publishers-map role) → fetch+verify payload (sha256 pinned by
  the signed index + minisig) → dry-precheck ALL ops (all-or-nothing) → best-
  effort snapshot → apply each op recording a precise inverse → bring app up →
  auto-rollback (replay undo LIFO, snapshot fallback) → applied-record + History.
- artifactRevert <id>: replay the applied-record's undo log (LIFO).
- Bounded, CLOSED op vocabulary (no run-script/exec, ever): set-config-key,
  set-compose-image, patch-file-if-checksum-matches, set-data-file. An
  unsupported op rejects the whole artifact at precheck (fail-closed).
- Write-target firewall: scope:app → containers/<app>/ only; scope:system →
  configs/ only; the install tree (our code) is off-limits to hotfixes (fork 1).
  Drift guards (expect_current / checksum) skip cleanly rather than clobber.
- Two-tier trust: index minisig-verified vs the footprint key (lpFetchIndex)
  covers the envelope; payload sha256-pinned + minisig-verified; publishers-map
  role gate (a non-official publisher can't claim official). Community per-
  artifact-key sigs are gated off until that tier is enabled.

cli_artifact_commands.sh: apply/revert via the task system (artifact_apply /
artifact_revert types — no allowlist needed), + read-only `applied` list.

cli_updater_commands.sh:
- FIX verified safety bug: updaterApplyApp/RollbackApp called `libreportal backup
  app "$app"` and `... restore latest`, which parse the app name as the ACTION,
  hit the dispatcher's `*)` default (exits 0) — so updates ran with NO snapshot
  and rollback was a silent no-op. Call backupAppStart / restoreAppStart directly.
- FIX updaterRecordHistory jq-silent-skip: was `command -v jq || return 0`
  (silently dropped the audit entry). Now fail-closed with a brace-agnostic
  bash-native prepend fallback; extended with artifact_id/serial/undo_id.

fetch.sh: add _lpJsonEsc (shared JSON-escape for the jq-free fallbacks).
Regenerated source arrays + lazy-load manifest for the new file/functions.

Unit-tested 31/31: every op apply+precheck+undo round-trip, the path-allowlist
firewall (incl. .. traversal + install-tree + cross-app rejection), all-or-
nothing abort, unsupported-op rejection, and the History bash-native fallback
(records + preserves prior entries without jq). A full signed-apply e2e needs
minisign + the signing key (Phase 5 make_hotfix.sh).

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

152 lines
8.3 KiB
Bash

#!/bin/bash
#
# LibrePortal source-fetch helpers for the RUNTIME paths (update / reinstall /
# reset). They place a fresh copy of the code at $script_dir (the install tree,
# which holds ONLY code — configs/logs live in the separate system tree, so the
# install tree can be replaced wholesale).
#
# Three modes, keyed off CFG_INSTALL_MODE:
# release download a versioned, checksum-verified tarball over HTTPS (default)
# git clone (handled by the existing git_* helpers / initGIT)
# local copy from a local folder (existing copyFilesFromLocal)
#
# init.sh's initGIT does the FRESH-install fetch inline (it is self-contained for
# the bare /root reinstall copy); these functions are the sourced runtime twins.
# Release host + channel: env wins (testing), then config, then the public default.
lpReleaseBaseUrl() { echo "${LP_RELEASE_BASE_URL:-${CFG_RELEASE_BASE_URL:-https://get.libreportal.org}}"; }
lpReleaseChannel() { echo "${LP_RELEASE_CHANNEL:-${CFG_RELEASE_CHANNEL:-stable}}"; }
# Downloader + checksum tool detection (curl|wget, sha256sum|shasum).
_lpFetchTool() { command -v curl >/dev/null 2>&1 && { echo curl; return; }; command -v wget >/dev/null 2>&1 && { echo wget; return; }; echo ""; }
_lpDownload() { # _lpDownload <url> <outfile|->
local tool; tool="$(_lpFetchTool)"
case "$tool" in
curl) [[ "$2" == "-" ]] && curl -fsSL "$1" || curl -fsSL "$1" -o "$2" ;;
wget) [[ "$2" == "-" ]] && wget -qO- "$1" || wget -qO "$2" "$1" ;;
*) return 1 ;;
esac
}
_lpSha256() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1; elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d' ' -f1; fi; }
_lpJsonStr() { printf '%s' "$1" | grep -oE "\"$2\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -1 | sed -E 's/.*"([^"]*)"$/\1/'; }
_lpJsonNum() { printf '%s' "$1" | grep -oE "\"$2\"[[:space:]]*:[[:space:]]*[0-9]+" | head -1 | grep -oE '[0-9]+$'; }
# JSON-escape a scalar for safe inline embedding (backslash first, then quote).
# Used by the jq-free fallbacks (history fallback, artifact applied-records).
_lpJsonEsc() { local s="${1//\\/\\\\}"; s="${s//\"/\\\"}"; printf '%s' "$s"; }
# Verify a downloaded file against a detached minisig using the ROOT-OWNED
# footprint public key (/usr/local/lib/libreportal/libreportal.pub). The key is
# in the footprint so the manager can't swap it to bless a forgery. Trust posture
# matches lpVerifyInstall (verify.sh): once the key is REAL (not the REPLACE_ME
# placeholder) a valid signature is MANDATORY; an unactivated/dev build skips it.
# This is the single trust anchor shared by the release fetch (lpFetchRelease)
# and the artifact-index fetch (lpFetchIndex, source/artifacts.sh).
# Args: <file> <sigfile>
# Returns 0 = OK to proceed (signature verified, OR signing not activated);
# 1 = REFUSE (key real but minisign missing / sig missing / sig invalid).
# Echoes "verified" or "unsigned" for the caller's audit line.
lpVerifyMinisig() {
local file="$1" sig="$2"
local pub="/usr/local/lib/libreportal/libreportal.pub"
if [[ ! -f "$pub" ]] || grep -q REPLACE_ME "$pub" 2>/dev/null; then
echo unsigned; return 0
fi
if ! command -v minisign >/dev/null 2>&1; then
isError "lpVerifyMinisig: minisign required to verify '$file' but not installed"; return 1
fi
if [[ ! -f "$sig" ]]; then
isError "lpVerifyMinisig: signature for '$file' missing — refusing"; return 1
fi
if ! minisign -Vm "$file" -p "$pub" -x "$sig" >/dev/null 2>&1; then
isError "lpVerifyMinisig: SIGNATURE INVALID for '$file' — refusing"; return 1
fi
echo verified; return 0
}
# Root-owned-footprint version: the one INSTALLED on this box (marker written by
# init.sh) vs the one the channel's latest release ships (manifest). When the
# latter is greater, a plain manager-run update can't apply it (it can't rewrite
# the root-owned helpers/wrapper/unit) — a root re-install is required.
lpInstalledFootprintVersion() { local v; v=$(cat /usr/local/lib/libreportal/.footprint_version 2>/dev/null | tr -d ' \t\n\r'); echo "${v:-0}"; }
lpReleaseLatestFootprint() {
local m; m="$(_lpDownload "$(lpReleaseBaseUrl)/$(lpReleaseChannel)/latest.json" - 2>/dev/null)" || return 1
local f; f=$(_lpJsonNum "$m" footprint_version); echo "${f:-0}"
}
# Resolve the latest published version of the configured channel (empty on failure).
lpReleaseLatestVersion() {
local m; m="$(_lpDownload "$(lpReleaseBaseUrl)/$(lpReleaseChannel)/latest.json" - 2>/dev/null)" || return 1
_lpJsonStr "$m" version
}
# Download + verify + extract a release into $script_dir. Arg: optional version
# (else the channel's latest). Returns non-zero (and leaves $script_dir untouched)
# on any download/checksum failure — callers must not proceed on failure.
lpFetchRelease() {
local want_ver="${1:-}" base channel manifest tarname want_sha tmp tar got
base="$(lpReleaseBaseUrl)"; channel="$(lpReleaseChannel)"
[[ -n "$(_lpFetchTool)" ]] || { isError "lpFetchRelease: need curl or wget"; return 1; }
if [[ -z "$want_ver" ]]; then
manifest="$(_lpDownload "$base/$channel/latest.json" - 2>/dev/null)" || { isError "lpFetchRelease: cannot fetch channel manifest"; return 1; }
want_ver="$(_lpJsonStr "$manifest" version)"
tarname="$(_lpJsonStr "$manifest" url)"
want_sha="$(_lpJsonStr "$manifest" sha256)"
fi
[[ -n "$want_ver" ]] || { isError "lpFetchRelease: could not determine version"; return 1; }
[[ -n "$tarname" ]] || tarname="libreportal-${want_ver}.tar.gz"
tmp="$(mktemp -d)"; tar="$tmp/$tarname"
if ! _lpDownload "$base/$channel/$tarname" "$tar"; then isError "lpFetchRelease: download failed ($tarname)"; rm -rf "$tmp"; return 1; fi
if [[ -z "$want_sha" ]]; then
_lpDownload "$base/$channel/$tarname.sha256" "$tar.sha256" 2>/dev/null && want_sha="$(cut -d' ' -f1 < "$tar.sha256")"
fi
[[ -n "$want_sha" ]] || { isError "lpFetchRelease: no checksum available — refusing"; rm -rf "$tmp"; return 1; }
got="$(_lpSha256 "$tar")"
if [[ "$got" != "$want_sha" ]]; then isError "lpFetchRelease: CHECKSUM MISMATCH ($tarname) — refusing"; rm -rf "$tmp"; return 1; fi
# Signature: verify against the root-owned footprint key (mandatory once the
# key is real; skipped only for an unsigned/dev build). Shared trust anchor
# with the artifact-index path — see lpVerifyMinisig above. Fetch the .minisig
# first (best-effort) so the verifier can find it; the verifier itself decides
# whether a missing/invalid signature is fatal.
_lpDownload "$base/$channel/$tarname.minisig" "$tar.minisig" 2>/dev/null || true
local sigstate
if ! sigstate="$(lpVerifyMinisig "$tar" "$tar.minisig")"; then rm -rf "$tmp"; return 1; fi
[[ "$sigstate" == "verified" ]] && isNotice "Release signature verified."
# Replace the install tree (code only; configs/logs are in the system tree).
runInstallOp rm -rf "$script_dir"
runInstallOp mkdir -p "$script_dir"
if ! runInstallOp tar xzf "$tar" -C "$script_dir" --strip-components=1; then
isError "lpFetchRelease: extract failed"; rm -rf "$tmp"; return 1
fi
rm -rf "$tmp"
isSuccessful "Fetched LibrePortal $want_ver ($channel) and verified its checksum."
}
# Mode-aware runtime fetch. git/local stay with their existing helpers; this adds
# the release path. Callers (updater/reinstall/reset) decide pre/post (config
# backup, redeploy) around it.
lpFetchSource() {
case "${CFG_INSTALL_MODE:-release}" in
release) lpFetchRelease "$@" ;;
local) declare -f copyFilesFromLocal >/dev/null 2>&1 && copyFilesFromLocal ;;
git) declare -f gitFolderResetAndBackup >/dev/null 2>&1 && gitFolderResetAndBackup ;;
*) isError "lpFetchSource: unknown CFG_INSTALL_MODE '${CFG_INSTALL_MODE}'"; return 1 ;;
esac
}
# Semver-ish compare: lpVersionGt A B → true if A > B (numeric dotted compare,
# trailing non-numeric ignored). Used by the updater + the badge generator.
lpVersionGt() {
local a="${1%%-*}" b="${2%%-*}" i x y
local -a A B; IFS='.' read -r -a A <<< "$a"; IFS='.' read -r -a B <<< "$b"
for ((i=0; i<${#A[@]} || i<${#B[@]}; i++)); do
x=$((10#${A[i]:-0} + 0)) 2>/dev/null; y=$((10#${B[i]:-0} + 0)) 2>/dev/null
(( x > y )) && return 0
(( x < y )) && return 1
done
return 1
}