#!/bin/bash # # Artifact APPLY pipeline — Phase 2 of the unified distribution primitive # (docs/roadmap/updates-and-distribution.md §8.3). The MUTATING side: it takes a # verified artifact from the signed index and applies it reversibly, then can # revert it. Runs ONLY under the task system (cli_artifact_commands.sh enqueues; # the processor re-invokes with LIBREPORTAL_TASK_EXEC=1, which is when these run). # # Design contracts (all enforced below, fail-closed): # * The trust core is the same anchor as releases — the index is minisign- # verified against the root-owned footprint key by lpFetchIndex (source/ # artifacts.sh); the payload is sha256-pinned by that signed index and itself # minisign-verified. The publishers map + role gate makes a community key # unable to masquerade as official (registry-ready; first-party-only today). # * The op vocabulary is a CLOSED allowlist (no run-script/exec/shell, ever). # An unsupported op rejects the WHOLE artifact at validation, before any write. # * ALL-OR-NOTHING: every op is dry-prechecked first; one failed precondition # skips the whole artifact untouched (recorded as a skip, so coverage gaps # are visible — a customised box may legitimately miss a fix). # * Two-tier reversibility: every op records a precise inverse into a per-id # applied-record (the revert path); a best-effort recovery snapshot is also # taken when a backup location exists (the dirty-op fallback). # * Mutations write only through the de-sudo funnels (runFileOp / runInstallOp / # updateConfigOption), never raw sudo. The install tree (our own code) is # off-limits to hotfixes — code fixes ride signed releases (fork 1). # --- paths ------------------------------------------------------------------- _artifactGenDir() { echo "${containers_dir%/}/libreportal/frontend/data/updater/generated"; } _artifactAppliedDir() { echo "$(_artifactGenDir)/applied"; } _artifactRecordFile() { echo "$(_artifactAppliedDir)/$1.json"; } # $1=id # Require jq for the apply path. The TRUST core (index sig, payload sha256+sig) is # jq-free; only walking the bounded op list / envelope fields needs structured # parsing, and apply is a heavy, rare, privileged path where requiring jq is fine. _artifactNeedJq() { command -v jq >/dev/null 2>&1 && return 0 isError "artifact: jq is required to apply/revert artifacts (the op interpreter needs it) — refusing." return 1 } # ---------------------------------------------------------------------------- # RESOLVE — fetch+verify the index, pull out one artifact, check the gates. # Echoes the artifact JSON on success; non-zero (nothing usable) otherwise. # Sets globals: _ART_INDEX (verified index json), _ART_SCOPE, _ART_APP. # ---------------------------------------------------------------------------- _artifactResolve() { local id="$1" _ART_INDEX="$(lpFetchIndex)" || { isError "artifact: could not fetch/verify the index."; return 1; } local art; art="$(printf '%s' "$_ART_INDEX" | jq -ce --arg id "$id" '.artifacts[]? | select(.id==$id)' 2>/dev/null)" [[ -n "$art" ]] || { isError "artifact: id '$id' not found in the signed index."; return 1; } local type; type="$(jq -r '.type // empty' <<<"$art")" if [[ "$type" != "hotfix" ]]; then isError "artifact: type '$type' is not supported by this build (only 'hotfix')."; return 1 fi # --- trust: publishers map + role gate --- local pub trust role pub="$(jq -r '.publisher // empty' <<<"$art")" trust="$(jq -r '.trust // "official"' <<<"$art")" role="$(printf '%s' "$_ART_INDEX" | jq -r --arg p "$pub" '.publishers[$p].role // empty' 2>/dev/null)" if [[ -z "$pub" || -z "$role" ]]; then isError "artifact: publisher '$pub' is not in the index publishers map — refusing."; return 1 fi if [[ "$trust" == "official" && "$role" != "official" ]]; then isError "artifact: '$id' claims trust=official but publisher '$pub' has role '$role' — refusing."; return 1 fi if [[ "$role" != "official" ]]; then # Community/custom would require the artifact's own canonical-bytes signature # against the publisher's key. Not enabled in this first-party-only build. isError "artifact: publisher role '$role' (non-official) is not enabled yet — refusing."; return 1 fi # --- gates (applies_when) --- _ART_APP="$(jq -r '.applies_when.app // empty' <<<"$art")" _ART_SCOPE="system"; [[ -n "$_ART_APP" ]] && _ART_SCOPE="app" if [[ "$_ART_SCOPE" == "app" && ! -d "${containers_dir%/}/$_ART_APP" ]]; then isNotice "artifact: '$id' targets '$_ART_APP' which is not installed — not applicable."; return 2 fi local minlp maxlp maxfp curlp curfp minlp="$(jq -r '.applies_when.min_lp // empty' <<<"$art")" maxlp="$(jq -r '.applies_when.max_lp // empty' <<<"$art")" maxfp="$(jq -r '.applies_when.max_footprint // empty' <<<"$art")" curlp="${CFG_LIBREPORTAL_VERSION:-$(cat "$script_dir/version" 2>/dev/null | tr -dc '0-9.')}" if [[ -n "$minlp" && -n "$curlp" ]] && lpVersionGt "$minlp" "$curlp"; then isNotice "artifact: '$id' needs LibrePortal >= $minlp (have ${curlp:-?}) — not applicable."; return 2 fi if [[ -n "$maxlp" && -n "$curlp" ]] && lpVersionGt "$curlp" "$maxlp"; then isNotice "artifact: '$id' applies only up to LibrePortal $maxlp (have $curlp) — not applicable."; return 2 fi if [[ -n "$maxfp" ]]; then curfp="$(lpInstalledFootprintVersion)" if (( curfp > maxfp )); then isNotice "artifact: '$id' applies only up to footprint $maxfp (have $curfp) — not applicable."; return 2 fi fi printf '%s' "$art" } # ---------------------------------------------------------------------------- # PAYLOAD — download, sha256-pin (from the signed index), minisig-verify. # Echoes the verified payload JSON; non-zero on any failure. # ---------------------------------------------------------------------------- _artifactFetchPayload() { local art="$1" base channel url want_sha sig_url tmp pf sf got kind base="$(lpReleaseBaseUrl)"; channel="$(lpReleaseChannel)" kind="$(jq -r '.payload.kind // empty' <<<"$art")" if [[ "$kind" != "ops" ]]; then isError "artifact: payload.kind '$kind' is not supported by this build (only 'ops')."; return 1 fi url="$(jq -r '.payload.url // empty' <<<"$art")" want_sha="$(jq -r '.payload.sha256 // empty' <<<"$art")" sig_url="$(jq -r '.payload.sig // empty' <<<"$art")" [[ -n "$url" && -n "$want_sha" ]] || { isError "artifact: payload missing url/sha256 — refusing."; return 1; } tmp="$(mktemp -d)"; pf="$tmp/payload.json"; sf="$tmp/payload.sig" # url/sig may be channel-relative ("stable/payloads/..") or absolute. case "$url" in http*://*) : ;; *) url="$base/$url" ;; esac if ! _lpDownload "$url" "$pf" 2>/dev/null; then isError "artifact: payload download failed."; rm -rf "$tmp"; return 1; fi got="$(_lpSha256 "$pf")" if [[ "$got" != "$want_sha" ]]; then isError "artifact: payload CHECKSUM MISMATCH — refusing."; rm -rf "$tmp"; return 1; fi if [[ -n "$sig_url" ]]; then case "$sig_url" in http*://*) : ;; *) sig_url="$base/$sig_url" ;; esac _lpDownload "$sig_url" "$sf" 2>/dev/null || true fi if ! lpVerifyMinisig "$pf" "$sf" >/dev/null; then rm -rf "$tmp"; return 1; fi cat "$pf"; rm -rf "$tmp" } # --- path allowlist (the write-target firewall) ----------------------------- # scope:app -> only under $containers_dir// # scope:system -> only under $configs_dir/ (the install/code tree is OFF-LIMITS # to hotfixes — code rides signed releases; fork 1) _artifactPathAllowed() { local path="$1" scope="$2" app="$3" real root [[ "$path" == *".."* ]] && return 1 real="$(realpath -m -- "$path" 2>/dev/null)"; [[ -n "$real" ]] || return 1 if [[ "$scope" == "app" ]]; then root="$(realpath -m -- "${containers_dir%/}/$app" 2>/dev/null)" [[ "$real" == "$root/"* ]] && return 0 return 1 fi root="$(realpath -m -- "${configs_dir%/}" 2>/dev/null)" [[ -n "$root" && "$real" == "$root/"* ]] && return 0 return 1 } # current image of an app's compose (first image: line), quotes stripped. _artifactComposeImage() { local app="$1" f="${containers_dir%/}/$1/docker-compose.yml" [[ -f "$f" ]] || return 1 grep -m1 -E '^\s*image:' "$f" 2>/dev/null | sed -E 's/^\s*image:\s*//; s/["'"'"']//g; s/\s+#.*$//; s/\s+$//' } # ---------------------------------------------------------------------------- # OP PRECHECK — pure read; returns 0 if the op can be applied as-is, else 1 with # a reason on stderr. This is the dry-run that enforces all-or-nothing. # ---------------------------------------------------------------------------- _artifactOpPrecheck() { local op_json="$1" scope="$2" app="$3" op op="$(jq -r '.op // empty' <<<"$op_json")" case "$op" in set-config-key) local key expect cur key="$(jq -r '.key // empty' <<<"$op_json")" [[ "$key" =~ ^CFG_[A-Z0-9_]+$ ]] || { isError "op set-config-key: bad key '$key'"; return 1; } expect="$(jq -r 'if has("expect_current") then .expect_current else "NONE" end' <<<"$op_json")" if [[ "$expect" != $'NONE' ]]; then cur="$(grep -m1 "^$key=" "$(findConfigFileForOption "$key" 2>/dev/null)" 2>/dev/null | sed -E "s/^$key=//; s/^\"//; s/\".*$//; s/\s+#.*$//")" [[ "$cur" == "$expect" ]] || { isError "op set-config-key: $key is '$cur', expected '$expect' (drift)"; return 1; } fi ;; set-compose-image) local from cur from="$(jq -r '.from // empty' <<<"$op_json")" [[ -d "${containers_dir%/}/$app" ]] || { isError "op set-compose-image: app '$app' not installed"; return 1; } cur="$(_artifactComposeImage "$app")" [[ -n "$from" && "$cur" == "$from" ]] || { isError "op set-compose-image: image is '$cur', expected '$from' (drift)"; return 1; } ;; patch-file-if-checksum-matches) local path want got path="$(jq -r '.path // empty' <<<"$op_json")" _artifactPathAllowed "$path" "$scope" "$app" || { isError "op patch-file: path '$path' not in the allowlist"; return 1; } [[ -f "$path" ]] || { isError "op patch-file: '$path' does not exist"; return 1; } want="$(jq -r '.expect_sha256 // empty' <<<"$op_json")" got="$(_lpSha256 "$path")" [[ -n "$want" && "$got" == "$want" ]] || { isError "op patch-file: '$path' sha mismatch (drift) — skipping"; return 1; } ;; set-data-file) local path path="$(jq -r '.path // empty' <<<"$op_json")" _artifactPathAllowed "$path" "$scope" "$app" || { isError "op set-data-file: path '$path' not in the allowlist"; return 1; } ;; *) isError "op '$op' is not supported by this build — rejecting the whole artifact (fail-closed)." return 1 ;; esac return 0 } # ---------------------------------------------------------------------------- # OP APPLY — mutate, and APPEND the precise inverse op (as compact JSON) to the # file named by $4 (the undo log). Returns non-zero on failure. # ---------------------------------------------------------------------------- _artifactOpApply() { local op_json="$1" scope="$2" app="$3" undo_file="$4" op op="$(jq -r '.op // empty' <<<"$op_json")" case "$op" in set-config-key) local key value cur f key="$(jq -r '.key' <<<"$op_json")"; value="$(jq -r '.value' <<<"$op_json")" f="$(findConfigFileForOption "$key" 2>/dev/null)" cur="$(grep -m1 "^$key=" "$f" 2>/dev/null | sed -E "s/^$key=//; s/^\"//; s/\".*$//; s/\s+#.*$//")" updateConfigOption "$key" "$value" || return 1 jq -cn --arg k "$key" --arg v "$cur" '{op:"set-config-key", key:$k, value:$v}' >> "$undo_file" ;; set-compose-image) local image cur f esc_cur esc_img image="$(jq -r '.image' <<<"$op_json")" f="${containers_dir%/}/$app/docker-compose.yml" cur="$(_artifactComposeImage "$app")" esc_cur="$(printf '%s' "$cur" | sed -e 's/[\/&]/\\&/g')" esc_img="$(printf '%s' "$image" | sed -e 's/[\/&]/\\&/g')" # Replace the first image: line's value, preserving indentation. runFileOp sed -i "0,/^\([[:space:]]*\)image:.*/s//\1image: $esc_img/" "$f" || return 1 jq -cn --arg img "$cur" '{op:"set-compose-image", image:$img}' >> "$undo_file" ;; patch-file-if-checksum-matches|set-data-file) local path existed prior_b64 content_b64 path="$(jq -r '.path' <<<"$op_json")" content_b64="$(jq -r '.content_b64 // empty' <<<"$op_json")" [[ -n "$content_b64" ]] || { isError "op $op: missing content_b64"; return 1; } if [[ -f "$path" ]]; then existed=true; prior_b64="$(base64 -w0 < "$path" 2>/dev/null)"; else existed=false; prior_b64=""; fi if ! printf '%s' "$content_b64" | base64 -d 2>/dev/null | runFileWrite "$path"; then isError "op $op: write to '$path' failed"; return 1 fi jq -cn --arg p "$path" --arg e "$existed" --arg b "$prior_b64" \ '{op:"restore-file", path:$p, existed:($e=="true"), content_b64:$b}' >> "$undo_file" ;; *) isError "op '$op': no apply handler (should have been rejected at precheck)"; return 1 ;; esac return 0 } # ---------------------------------------------------------------------------- # OP UNDO — apply one inverse op recorded by _artifactOpApply (the revert path). # ---------------------------------------------------------------------------- _artifactOpUndo() { local op_json="$1" app="$2" op op="$(jq -r '.op // empty' <<<"$op_json")" case "$op" in set-config-key) local key value; key="$(jq -r '.key' <<<"$op_json")"; value="$(jq -r '.value' <<<"$op_json")" updateConfigOption "$key" "$value" || return 1 ;; set-compose-image) local image f esc_img; image="$(jq -r '.image' <<<"$op_json")" f="${containers_dir%/}/$app/docker-compose.yml" esc_img="$(printf '%s' "$image" | sed -e 's/[\/&]/\\&/g')" runFileOp sed -i "0,/^\([[:space:]]*\)image:.*/s//\1image: $esc_img/" "$f" || return 1 ;; restore-file) local path existed content_b64 path="$(jq -r '.path' <<<"$op_json")"; existed="$(jq -r '.existed' <<<"$op_json")" content_b64="$(jq -r '.content_b64 // empty' <<<"$op_json")" if [[ "$existed" == "true" ]]; then printf '%s' "$content_b64" | base64 -d 2>/dev/null | runFileWrite "$path" || return 1 else runFileOp rm -f "$path" || return 1 fi ;; *) isError "undo: unknown inverse op '$op'"; return 1 ;; esac return 0 } # ---------------------------------------------------------------------------- # artifactApply — the full pipeline (steps 0-9). Idempotent-ish: a # re-apply re-prechecks (drift guards skip already-applied ops cleanly). # ---------------------------------------------------------------------------- artifactApply() { local id="$1" _artifactNeedJq || return 1 [[ -n "$id" ]] || { isError "artifactApply: no id"; return 1; } isHeader "Applying hotfix: $id" # 0. RESOLVE + gates local art rc art="$(_artifactResolve "$id")"; rc=$? if [[ $rc -eq 2 ]]; then updaterRecordHistory "${_ART_APP:-}" "hotfix" "" "$id" "not-applicable" "$id" "$(_lpJsonNum "$_ART_INDEX" index_serial)" ""; return 0; fi [[ $rc -eq 0 && -n "$art" ]] || { updaterRecordHistory "" "hotfix" "" "$id" "rejected" "$id" "" ""; return 1; } local app="$_ART_APP" scope="$_ART_SCOPE" serial title serial="$(_lpJsonNum "$_ART_INDEX" index_serial)" title="$(jq -r '.title // empty' <<<"$art")" isNotice "$title (scope=$scope${app:+, app=$app})" # 4. PAYLOAD (fetch+verify) local payload; payload="$(_artifactFetchPayload "$art")" || { updaterRecordHistory "$app" "hotfix" "" "$id" "verify-failed" "$id" "$serial" ""; return 1; } # collect ops local ops_count; ops_count="$(jq '.ops | length' <<<"$payload" 2>/dev/null || echo 0)" if ! [[ "$ops_count" =~ ^[0-9]+$ ]] || (( ops_count == 0 )); then isError "artifact: payload has no ops."; updaterRecordHistory "$app" "hotfix" "" "$id" "empty" "$id" "$serial" ""; return 1 fi # 6a. DRY-PRECHECK ALL (all-or-nothing) local i op_json for (( i=0; i/dev/null 2>&1 || isNotice "No snapshot taken (no backup location?) — relying on the precise undo log." else backupSystemConfig >/dev/null 2>&1 || isNotice "No system-config snapshot taken — relying on the precise undo log." fi # 6b. APPLY (collect inverse ops into the undo log) local undo_file; undo_file="$(mktemp)" local applied_ok=1 for (( i=0; i/dev/null 2>&1 || applied_ok=0 else declare -F webuiGenerateSystemConfigs >/dev/null 2>&1 && webuiGenerateSystemConfigs >/dev/null 2>&1 || true fi if [[ "$applied_ok" != "1" ]]; then # 8. AUTO-ROLLBACK — replay the undo log LIFO, then the snapshot as fallback. isNotice "Apply failed — rolling back…" _artifactReplayUndoFile "$undo_file" "$app" "reverse" [[ "$scope" == "app" ]] && { restoreAppStart "$app" latest "" >/dev/null 2>&1 || true; dockerComposeUp "$app" >/dev/null 2>&1 || true; } rm -f "$undo_file" updaterRecordHistory "$app" "hotfix" "" "$id" "rolled-back" "$id" "$serial" "" isError "Hotfix $id failed and was rolled back." return 1 fi # 9. RECORD (applied-record with the undo log) + History + serial high-water _artifactWriteRecord "$id" "$art" "$serial" "$undo_file" rm -f "$undo_file" _artifactRegenAppliedManifest updaterRecordHistory "$app" "hotfix" "" "$id" "applied" "$id" "$serial" "$id" isSuccessful "Hotfix $id applied. Reversible via: libreportal artifact revert $id" return 0 } # Replay every inverse op in $1; "reverse" applies them LIFO (for rollback). _artifactReplayUndoFile() { local file="$1" app="$2" order="$3" [[ -s "$file" ]] || return 0 local lines; mapfile -t lines < "$file" if [[ "$order" == "reverse" ]]; then local n=${#lines[@]} j for (( j=n-1; j>=0; j-- )); do [[ -n "${lines[j]}" ]] && _artifactOpUndo "${lines[j]}" "$app" || true; done else local l; for l in "${lines[@]}"; do [[ -n "$l" ]] && _artifactOpUndo "$l" "$app" || true; done fi } # Persist the applied-record (metadata + the undo log as a JSON array). _artifactWriteRecord() { local id="$1" art="$2" serial="$3" undo_file="$4" local dir; dir="$(_artifactAppliedDir)" runFileOp mkdir -p "$dir" 2>/dev/null || true local ts; ts="$(date -Iseconds 2>/dev/null || date)" local undo_arr="[]" [[ -s "$undo_file" ]] && undo_arr="$(jq -cs '.' < "$undo_file" 2>/dev/null || echo '[]')" local rec rec="$(jq -cn --argjson art "$art" --arg ts "$ts" --arg serial "$serial" --argjson undo "$undo_arr" \ '{id:$art.id, type:$art.type, version:($art.version//1), app:($art.applies_when.app//null), severity:($art.severity//"tweak"), title:($art.title//""), why:($art.why//""), applied_at:$ts, serial:$serial, undo:$undo}')" printf '%s' "$rec" | runFileWrite "$(_artifactRecordFile "$id")" } # Rebuild artifacts_applied.json (the WebUI-read manifest) from applied/*.json. _artifactRegenAppliedManifest() { local dir; dir="$(_artifactAppliedDir)" local out; out="$(_artifactGenDir)/artifacts_applied.json" local body="[]" if compgen -G "$dir/*.json" >/dev/null 2>&1; then body="$(jq -cs '[.[] | {id,type,app,severity,title,why,applied_at,serial,version}]' "$dir"/*.json 2>/dev/null || echo '[]')" fi local ts; ts="$(date -Iseconds 2>/dev/null || date)" printf '%s' "$(jq -cn --argjson a "$body" --arg ts "$ts" '{generated_at:$ts, applied:$a}')" | runFileWrite "$out" } # ---------------------------------------------------------------------------- # artifactRevert — replay the applied-record's undo log (LIFO), then bring # the app up / regen config. Removes the record + logs to History. # ---------------------------------------------------------------------------- artifactRevert() { local id="$1" _artifactNeedJq || return 1 [[ -n "$id" ]] || { isError "artifactRevert: no id"; return 1; } local rf; rf="$(_artifactRecordFile "$id")" [[ -f "$rf" ]] || { isError "artifact: '$id' is not recorded as applied — nothing to revert."; return 1; } isHeader "Reverting hotfix: $id" local rec; rec="$(cat "$rf")" local app; app="$(jq -r '.app // empty' <<<"$rec")" local serial; serial="$(jq -r '.serial // empty' <<<"$rec")" # Replay the undo ops in reverse order (LIFO). local undo_file; undo_file="$(mktemp)" jq -c '.undo[]?' <<<"$rec" > "$undo_file" 2>/dev/null _artifactReplayUndoFile "$undo_file" "$app" "reverse" rm -f "$undo_file" if [[ -n "$app" ]]; then dockerComposeUp "$app" >/dev/null 2>&1 || true else declare -F webuiGenerateSystemConfigs >/dev/null 2>&1 && webuiGenerateSystemConfigs >/dev/null 2>&1 || true; fi runFileOp rm -f "$rf" 2>/dev/null || true _artifactRegenAppliedManifest updaterRecordHistory "$app" "hotfix" "$id" "" "reverted" "$id" "$serial" "" isSuccessful "Hotfix $id reverted." return 0 }