From 3014965b66721c428c88dad71e240145c035f529 Mon Sep 17 00:00:00 2001 From: librelad Date: Mon, 25 May 2026 19:07:16 +0100 Subject: [PATCH] =?UTF-8?q?feat(update):=20FOOTPRINT=5FVERSION=20drift=20d?= =?UTF-8?q?etection=20=E2=80=94=20flag=20when=20a=20root=20re-install=20is?= =?UTF-8?q?=20needed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A manager-run 'update apply' refreshes code/apps/WebUI but CANNOT rewrite the root-owned footprint (helpers/wrapper/uninstall/unit/sudoers) — that immutability is the de-sudo boundary. Previously a release that changed those would silently leave them stale. Make it explicit: - init.sh: footprint_version=1 constant, baked at install into /usr/local/lib/libreportal/.footprint_version (root:root 0644) by initRootHelpers. Bump it whenever a root component changes. - make_release.sh: publishes footprint_version in latest.json. - fetch.sh: lpInstalledFootprintVersion (marker) + lpReleaseLatestFootprint (manifest). - check_update.sh: 'update apply' REFUSES when the release's footprint_version exceeds the installed one, directing to a root re-install (which fetches + re-bakes everything atomically). No half-applied updates. - webui_system_update.sh: badge sets footprint_update_needed + clears can_update so the WebUI won't offer a one-click apply for a footprint-bumping release. - docs/DEVELOPMENT.md: the bump rule + the footprint exception explained. Verified: manifest carries footprint_version; drift decision correct both ways (no marker/older -> needs re-install; equal -> no drift). Co-Authored-By: Claude Opus 4.7 Signed-off-by: librelad --- docs/DEVELOPMENT.md | 18 +++++++++++++++++ init.sh | 13 ++++++++++++ scripts/release/make_release.sh | 6 ++++++ scripts/source/fetch.sh | 11 ++++++++++ scripts/update/check_update.sh | 13 ++++++++++++ .../generators/system/webui_system_update.sh | 20 ++++++++++++++++--- 6 files changed, 78 insertions(+), 3 deletions(-) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index d3bc92e..4372cb2 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -118,6 +118,24 @@ is *code only* (configs/logs/backups live in the other roots), the update just replaces it — no backup/restore dance. `git`/`local` modes keep their existing git-based update path. +**The footprint exception (important).** `update apply` runs as the *manager*, and +the manager is deliberately forbidden from rewriting the **root-owned footprint** +(the helpers in `/usr/local/lib/libreportal/`, the CLI wrapper, the uninstall +launcher, the systemd unit, the sudoers) — that immutability *is* the de-sudo +security boundary. So a manager-run update can refresh **code/apps/WebUI**, but not +those. To track when an update touches them, `init.sh` carries a `footprint_version` +integer, baked at install into `/usr/local/lib/libreportal/.footprint_version` and +published in `latest.json`. When the channel's `footprint_version` exceeds the +installed one, the updater **refuses the WebUI apply** and the badge flags +`footprint_update_needed` — the user re-runs the installer as root +(`curl … install.sh | sudo bash`), which fetches *and* re-bakes the footprint +atomically. (Re-running the installer is idempotent.) + +➡️ **BUMP `footprint_version` in `init.sh` whenever you change anything in +`scripts/system/*`, the CLI wrapper, the uninstall launcher, the systemd unit, or +the sudoers.** Forgetting it means those root components silently stay stale until +the next full reinstall. + ## Conventions - **Versioning:** semver in `VERSION`. Bump before building; `latest.json` carries it. diff --git a/init.sh b/init.sh index 4fbd077..25268b8 100755 --- a/init.sh +++ b/init.sh @@ -125,6 +125,13 @@ hostname_file="/etc/hostname" lp_lib_dir="/usr/local/lib/libreportal" command_script="$lp_lib_dir/libreportal" command_symlink="/usr/local/bin/libreportal" +# Version of the ROOT-OWNED footprint (helpers, CLI wrapper, uninstall launcher, +# systemd unit, sudoers). BUMP THIS whenever you change any of those — a plain +# `update apply` runs as the manager and CANNOT rewrite root-owned files, so a bump +# tells the updater the new release needs a root re-install (which re-bakes them). +# Recorded at install in $lp_lib_dir/.footprint_version. See docs/DEVELOPMENT.md. +footprint_version=1 +footprint_marker="$lp_lib_dir/.footprint_version" # Directories — three independently-relocatable roots (see scripts/source/paths.sh # for the canonical description). Defaults below; overridden by --system-dir / @@ -940,6 +947,12 @@ initRootHelpers() fi rm -f "$helper_tmp" done + + # Record the footprint version now installed (root-owned, world-readable) so the + # manager-run updater can tell when a release bumps it and a root re-install is + # needed. Written here because this re-runs on every install/reinstall. + echo "$footprint_version" | sudo tee "$footprint_marker" >/dev/null + sudo chmod 0644 "$footprint_marker" } initFolders() diff --git a/scripts/release/make_release.sh b/scripts/release/make_release.sh index 8c367b2..6703b11 100644 --- a/scripts/release/make_release.sh +++ b/scripts/release/make_release.sh @@ -26,6 +26,11 @@ REF="${2:-HEAD}" VERSION="$(tr -d ' \t\n\r' < VERSION 2>/dev/null || true)" [[ -n "$VERSION" ]] || { echo "make_release: VERSION file is empty or missing" >&2; exit 1; } +# Root-owned-footprint version (helpers/wrapper/unit/sudoers). Published in the +# manifest so the updater can detect when an update needs a root re-install. +FOOTPRINT_VERSION="$(grep -oE '^footprint_version=[0-9]+' init.sh | head -1 | cut -d= -f2)" +[[ -n "$FOOTPRINT_VERSION" ]] || FOOTPRINT_VERSION=0 + TARBALL="libreportal-${VERSION}.tar.gz" PREFIX="libreportal-${VERSION}/" # top-level dir inside the tarball OUT="$REPO_ROOT/dist/$CHANNEL" @@ -43,6 +48,7 @@ cat > "$OUT/latest.json" < } _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]+$'; } + +# 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() { diff --git a/scripts/update/check_update.sh b/scripts/update/check_update.sh index 371f88b..f34865c 100755 --- a/scripts/update/check_update.sh +++ b/scripts/update/check_update.sh @@ -46,6 +46,19 @@ webuiRunUpdate() isSuccessful "LibrePortal is already up to date (v${cur})." return 0 fi + # If the release bumps the root-owned footprint (helpers/wrapper/unit), a + # manager-run apply can't install it — those are deliberately not + # manager-writable. Require a root re-install, which fetches + re-bakes + # everything atomically. + local inst_fp rel_fp + inst_fp=$(lpInstalledFootprintVersion 2>/dev/null) + rel_fp=$(lpReleaseLatestFootprint 2>/dev/null) + if [[ -n "$rel_fp" && "${rel_fp:-0}" -gt "${inst_fp:-0}" ]]; then + isError "Update v${lat} changes system components — it must be applied as root, not from the WebUI." + isNotice "Run on the host: curl -fsSL $(lpReleaseBaseUrl)/install.sh | sudo bash" + isNotice "(or, from the install tree: sudo ${script_dir}/init.sh --unattended init ... )" + return 1 + fi isNotice "Update found — v${cur} → v${lat}. Fetching the verified release..." lpFetchRelease "$lat" || { isError "Release fetch failed — install unchanged."; return 1; } isNotice "Redeploying LibrePortal with the new version..." diff --git a/scripts/webui/data/generators/system/webui_system_update.sh b/scripts/webui/data/generators/system/webui_system_update.sh index 88ecbfa..9b912a4 100755 --- a/scripts/webui/data/generators/system/webui_system_update.sh +++ b/scripts/webui/data/generators/system/webui_system_update.sh @@ -62,11 +62,15 @@ webuiSystemUpdateCheck() { # 1 update_available 2 can_update 3 current_version 4 latest_version # 5 current_commit 6 latest_commit 7 behind 8 ahead 9 branch # 10 source 11 error_message (empty = null) + # 12 footprint_update_needed (optional, default false) — the release bumps a + # root-owned component, so applying it needs a root re-install, not a + # manager-run `update apply`. _webuiWriteUpdateStatus() { local _update_available="$1" _can_update="$2" local _current_version="$3" _latest_version="$4" local _current_commit="$5" _latest_commit="$6" local _behind="$7" _ahead="$8" _branch="$9" _source="${10}" _error="${11}" + local _footprint_needed="${12:-false}" local _error_json="null" [[ -n "$_error" ]] && _error_json="\"$_error\"" @@ -87,6 +91,7 @@ webuiSystemUpdateCheck() { "git_updates_enabled": ${git_updates}, "auto_updates": ${auto_updates}, "source": "${_source}", + "footprint_update_needed": ${_footprint_needed}, "error": ${_error_json}, "checked_at": "$(date -Iseconds)" } @@ -109,28 +114,37 @@ EOF local _now _last; _now=$(date +%s); _last=$(stat -c '%Y' "$stamp_file" 2>/dev/null || echo 0) (( _now - _last >= fetch_interval )) && do_fetch="true" fi + local footprint_needed="false" if [[ "$do_fetch" == "true" ]] && declare -f lpReleaseLatestVersion >/dev/null 2>&1; then local _lv; _lv=$(lpReleaseLatestVersion 2>/dev/null) if [[ -n "$_lv" ]]; then latest_version="$_lv" runAsManager touch "$stamp_file" 2>/dev/null || touch "$stamp_file" 2>/dev/null + # Does the published release bump the root-owned footprint? If so, + # applying it needs a root re-install, not a WebUI `update apply`. + if declare -f lpReleaseLatestFootprint >/dev/null 2>&1; then + local _rfp _ifp; _rfp=$(lpReleaseLatestFootprint 2>/dev/null); _ifp=$(lpInstalledFootprintVersion 2>/dev/null) + [[ -n "$_rfp" && "${_rfp:-0}" -gt "${_ifp:-0}" ]] && footprint_needed="true" + fi else rel_error="Could not reach the update server." fi elif [[ -f "$final_file" ]]; then - # Throttled: reuse the last-known latest so the badge doesn't flicker. + # Throttled: reuse the last-known latest + footprint flag (no flicker). local _prev; _prev=$(grep -oE '"latest_version"[^,]*' "$final_file" 2>/dev/null | head -1 | sed -E 's/.*"([^"]*)"$/\1/') [[ -n "$_prev" ]] && latest_version="$_prev" + grep -q '"footprint_update_needed": true' "$final_file" 2>/dev/null && footprint_needed="true" fi local update_available="false" if declare -f lpVersionGt >/dev/null 2>&1 && lpVersionGt "$latest_version" "$current_version"; then update_available="true" fi + # A footprint-bumping release can't be applied from the WebUI (needs root). local can_update="false" - [[ "$git_updates" == "true" ]] && can_update="true" + [[ "$git_updates" == "true" && "$footprint_needed" != "true" ]] && can_update="true" _webuiWriteUpdateStatus "$update_available" "$can_update" \ "$current_version" "$latest_version" \ - "" "" "0" "0" "${CFG_RELEASE_CHANNEL:-stable}" "release" "$rel_error" + "" "" "0" "0" "${CFG_RELEASE_CHANNEL:-stable}" "release" "$rel_error" "$footprint_needed" return 0 fi