LibrePortal/scripts/source/artifacts.sh
librelad 8b5e02c760 refactor(storage): resolve every app directory through appDir
The main sweep — ~260 call sites across ~100 files move from string
concatenation on a single root to appDir/storageAppDirs/storageAppConfigs.
On a single-root install the resolved paths are identical, so this is a
no-op until a location is registered.

Enumerators were the interesting half. `for d in "$containers_dir"/*/`
appears in the menus, the registry/artifact scanners and the DNS setup —
and a shell glob cannot list a rootless 751 tree at all, which is the
same bug config_find_file.sh already documents in a comment. Routing them
through storageAppDirs (which enumerates as the owning user) fixes that
alongside the multi-root work.

Three places needed judgement rather than substitution:

db_app_scan.sh deletes database rows and port allocations for apps whose
folder is missing, and reaps "empty" app dirs. With a storage location
unmounted, every app on it looks exactly like that. Each of those
branches now gates on appStorageAvailable first — an app on an unplugged
drive is skipped with a notice, never deleted.

instance_create.sh rewrites cloned hooks so an instance touches its own
directory instead of the base app's. Its sed matched ${containers_dir}<type>,
which this sweep just replaced with $(appDir <type>) — so it would have
silently stopped redirecting, and an instance would have written to the
original's files (the adguard auth adapter case its own comment warns
about). Now matches both appDir forms, verified against bare, quoted,
unrelated-app, legacy and prose cases.

peer_shell/peer_pull streamed and extracted relative to the primary root.
Both now use the app's own root, and peer_shell keeps a single-root
fallback since it runs as a restricted SSH shell with no LibrePortal env.

Also fixes a pre-existing bug found on the way: webui_app_config.sh
tested "$containers_dir/frontend/data/last_update", one level short of the
real tree under the libreportal app dir, so the WebUI refresh trigger
after a config update has never once fired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 04:09:51 +01:00

150 lines
7.8 KiB
Bash

#!/bin/bash
#
# LibrePortal artifact-index helpers — the READ side of the unified distribution
# primitive (see docs/roadmap/updates-and-distribution.md).
#
# An "artifact" is anything LibrePortal pulls from the outside and applies
# reversibly: a HOTFIX today; apps / themes / components later. They all share
# ONE team-signed catalog — the INDEX — published in the SAME release tree as
# latest.json: $base/$channel/index.json (+ index.json.minisig).
#
# This file is PHASE 1 of that primitive: fetch + verify + parse the index. It
# performs NO mutation. The apply pipeline (snapshot → declarative ops → verify →
# auto-rollback → History) is Phase 2 (scripts/cli/commands/artifact). Keeping
# the read side here means the trust core is testable on its own and the WebUI
# scan can surface "available artifacts" before any apply machinery exists.
#
# Trust chain — fail-closed at every step once the footprint key is real:
# footprint pubkey --signs--> index.json --lists--> per-artifact {sha256, sig}
# Verification reuses lpVerifyMinisig (fetch.sh) — the EXACT anchor the release
# fetch uses — so the manager can't bless a forged catalog any more than a forged
# release. Two transparency guarantees, both jq-free so the trust core never
# depends on jq being present:
# valid_until — refuse a stale/withheld feed. A signed feed that simply stops
# advancing is the silent-withholding / targeting attack the
# warrant-canary model exists to defeat; treat a frozen feed as
# a signal, not as "no updates".
# index_serial — monotonic counter; refuse a serial below the highest we have
# already accepted (a rollback that re-introduces a pulled or
# again-vulnerable entry).
# The index sits next to latest.json on the same channel; reuse those resolvers
# (lpReleaseBaseUrl/lpReleaseChannel live in fetch.sh).
lpArtifactIndexUrl() { echo "$(lpReleaseBaseUrl)/$(lpReleaseChannel)/index.json"; }
# Runtime-owned high-water mark for index_serial (the anti-rollback anchor). It
# lives alongside the other generated updater data so it ships/clears with that
# state; the dir is in the container tree, so writes go through the container
# funnel. Reads are fine as any user (world-readable).
lpArtifactSerialFile() { echo "$(webuiDir)/frontend/data/updater/generated/.index_serial"; }
lpArtifactLastSerial() { local v; v=$(cat "$(lpArtifactSerialFile)" 2>/dev/null | tr -dc '0-9'); echo "${v:-0}"; }
lpArtifactRecordSerial() {
local serial="$1" f; f="$(lpArtifactSerialFile)"
[[ "$serial" =~ ^[0-9]+$ ]] || return 0
runFileOp mkdir -p "$(dirname "$f")" 2>/dev/null || true
printf '%s\n' "$serial" | runFileWrite "$f"
}
# Fetch + verify the signed artifact index.
# $1 (optional): also cache the verified JSON to this path (for the WebUI scan).
# Echoes the verified JSON to stdout on success. Returns non-zero (printing
# nothing usable) on ANY download / signature / freshness / rollback failure —
# callers MUST NOT proceed on a non-zero return (fail-closed).
# On success, sets the global LP_INDEX_SIGSTATE to "verified" or "unsigned" so
# callers can distinguish a real signature from signing-not-activated (dev). The
# READ path tolerates "unsigned" (dev/git installs); the MUTATING apply path must
# refuse it (see artifactApply) — that asymmetry is the whole point of surfacing it.
LP_INDEX_SIGSTATE=""
lpFetchIndex() {
local cache="${1:-}" base channel tmp idx sig json valid_until nowts serial last
base="$(lpReleaseBaseUrl)"; channel="$(lpReleaseChannel)"
LP_INDEX_SIGSTATE=""
[[ -n "$(_lpFetchTool)" ]] || { isError "lpFetchIndex: need curl or wget"; return 1; }
tmp="$(mktemp -d)"; idx="$tmp/index.json"; sig="$tmp/index.json.minisig"
# Silence the downloader's own stderr (curl's "could not resolve host" / 404
# noise) — the caller's clean error message covers the failure. Consistent
# with the .minisig fetch below.
if ! _lpDownload "$base/$channel/index.json" "$idx" 2>/dev/null; then
isError "lpFetchIndex: could not download the artifact index"; rm -rf "$tmp"; return 1
fi
# Signature FIRST — never parse an unverified document to make trust
# decisions. Fetch the .minisig best-effort; lpVerifyMinisig decides whether
# a missing/invalid signature is fatal (it is, once the key is real) and
# echoes the resulting state, which we record for the apply-path gate.
_lpDownload "$base/$channel/index.json.minisig" "$sig" 2>/dev/null || true
LP_INDEX_SIGSTATE="$(lpVerifyMinisig "$idx" "$sig")" || { rm -rf "$tmp"; return 1; }
json="$(cat "$idx")"
# Freshness — a valid index MUST carry a future valid_until. Missing / non-
# numeric / elapsed all refuse (a withheld or undated feed is the attack the
# anti-withholding guarantee exists to defeat — fail-closed, not fail-open).
valid_until="$(_lpJsonNum "$json" valid_until)"
nowts="$(date +%s 2>/dev/null)"
if [[ -z "$valid_until" ]]; then
isError "lpFetchIndex: index has no numeric valid_until — refusing (anti-withholding)"; rm -rf "$tmp"; return 1
fi
if [[ -n "$nowts" ]] && (( valid_until < nowts )); then
isError "lpFetchIndex: artifact index is stale (valid_until elapsed) — refusing"; rm -rf "$tmp"; return 1
fi
# Anti-rollback — a valid index MUST carry index_serial, and it must not go
# backwards from the highest accepted (missing serial = missing anchor = refuse).
serial="$(_lpJsonNum "$json" index_serial)"
if [[ -z "$serial" ]]; then
isError "lpFetchIndex: index has no numeric index_serial — refusing (anti-rollback anchor missing)"; rm -rf "$tmp"; return 1
fi
last="$(lpArtifactLastSerial)"
if (( serial < last )); then
isError "lpFetchIndex: index_serial $serial below last-seen $last (rollback) — refusing"; rm -rf "$tmp"; return 1
fi
lpArtifactRecordSerial "$serial"
[[ -n "$cache" ]] && printf '%s' "$json" | runFileWrite "$cache"
printf '%s' "$json"
rm -rf "$tmp"
return 0
}
# lpFetchIndexInto <varname> [cache_path] — lpFetchIndex run IN THIS SHELL so
# the LP_INDEX_SIGSTATE global actually reaches the caller. A plain
# var="$(lpFetchIndex)" capture strands that assignment in the substitution's
# subshell: the caller then reads the file-scope "" and the apply-path gate
# refuses even a correctly signed index. Any caller that inspects
# LP_INDEX_SIGSTATE after fetching MUST use this wrapper, not $(…).
lpFetchIndexInto() {
local __lpfi_var="$1" __lpfi_tmp __lpfi_rc=0
__lpfi_tmp="$(mktemp)"
lpFetchIndex "${2:-}" > "$__lpfi_tmp" || __lpfi_rc=$?
if (( __lpfi_rc == 0 )); then
printf -v "$__lpfi_var" '%s' "$(cat "$__lpfi_tmp")"
fi
rm -f "$__lpfi_tmp"
return "$__lpfi_rc"
}
# --- Parsing accessors -------------------------------------------------------
# The trust-critical fields (index_serial / valid_until / signature) are read
# jq-free above so the security core has no jq dependency. Enumerating the
# artifacts ARRAY for display is best-effort: jq when present (the runtime path
# has it — updaterRecordHistory already relies on it), with a flat grep fallback.
lpIndexTop() { _lpJsonStr "$2" "$1"; } # lpIndexTop <field> <json> -> top-level scalar
lpIndexArtifactIds() { # echo one artifact id per line
local json="$1"
if command -v jq >/dev/null 2>&1; then
printf '%s' "$json" | jq -r '.artifacts[]?.id // empty' 2>/dev/null
return 0
fi
printf '%s' "$json" | grep -oE '"id"[[:space:]]*:[[:space:]]*"[^"]*"' | sed -E 's/.*"([^"]*)"$/\1/'
}
lpArtifactById() { # lpArtifactById <json> <id> -> the artifact object (jq only)
local json="$1" id="$2"
command -v jq >/dev/null 2>&1 || return 1
printf '%s' "$json" | jq -ce --arg id "$id" '.artifacts[]? | select(.id==$id)' 2>/dev/null
}