feat(update): FOOTPRINT_VERSION drift detection — flag when a root re-install is needed

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 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-05-25 19:07:16 +01:00
parent d95560c48c
commit 3014965b66
6 changed files with 78 additions and 3 deletions

View File

@ -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.

13
init.sh
View File

@ -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()

View File

@ -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" <<EOF
"channel": "$CHANNEL",
"url": "$TARBALL",
"sha256": "$SHA",
"footprint_version": $FOOTPRINT_VERSION,
"notes": ""
}
EOF

View File

@ -29,6 +29,17 @@ _lpDownload() { # _lpDownload <url> <outfile|->
}
_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() {

View File

@ -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..."

View File

@ -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