Merge claude/2

This commit is contained in:
librelad 2026-07-03 21:14:14 +01:00
commit d1958f6033
5 changed files with 411 additions and 49 deletions

View File

@ -55,6 +55,35 @@ cliHandleAppCommands()
fi fi
;; ;;
"add")
# Add an app DEFINITION from the signed registry catalog (the
# marketplace verb -- docs/roadmap/updates-and-distribution.md §8).
# Mutating, so it routes through the task system like install.
if [[ -z "$app_name" ]]; then isError "Usage: libreportal app add <app|artifact-id>"; return 1; fi
if [[ ! "$app_name" =~ ^[A-Za-z0-9._-]+$ ]]; then isError "app add: argument has unsafe characters"; return 1; fi
if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
# Lazy-loader gap: the registry read + apply pipeline live in
# their own files; mirror the artifact handler's checked source.
if ! declare -F lpFetchIndex >/dev/null 2>&1; then
local _f
for _f in source/fetch.sh source/artifacts.sh; do
if [[ ! -f "$install_scripts_dir/$_f" ]] || ! source "$install_scripts_dir/$_f"; then
isError "app add: failed to load the read pipeline ($_f) — try: libreportal regen"; return 1
fi
done
fi
if ! declare -F appAddFromRegistry >/dev/null 2>&1; then
local _af="cli/commands/artifact/cli_artifact_apply.sh"
if [[ ! -f "$install_scripts_dir/$_af" ]] || ! source "$install_scripts_dir/$_af"; then
isError "app add: failed to load the apply pipeline ($_af) — try: libreportal regen"; return 1
fi
fi
appAddFromRegistry "$app_name"
else
cliTaskRun "libreportal app add $app_name" "app_add" "$app_name" ""
fi
;;
"uninstall") "uninstall")
# Optional `--delete-images` flag (in any of the trailing # Optional `--delete-images` flag (in any of the trailing
# positions) tells the uninstall to also remove the app's # positions) tells the uninstall to also remove the app's

View File

@ -16,6 +16,9 @@ cliShowAppHelp()
echo " - Install / reinstall the specified app." echo " - Install / reinstall the specified app."
echo " On reinstall, IPs and ports are preserved by default." echo " On reinstall, IPs and ports are preserved by default."
echo " Pass --reset-network to re-randomize them." echo " Pass --reset-network to re-randomize them."
echo " libreportal app add [name*] - Add an app definition from the signed registry"
echo " catalog (marketplace). It then installs like any"
echo " other app. Browse: libreportal artifact index"
echo " libreportal app uninstall [name*] - Uninstall the specified app" echo " libreportal app uninstall [name*] - Uninstall the specified app"
echo "" echo ""
echo " libreportal app start [name*] - Start the specified app (Must be installed)" echo " libreportal app start [name*] - Start the specified app (Must be installed)"

View File

@ -69,9 +69,11 @@ _artifactRmFile() {
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
# RESOLVE -- fetch+verify the index, pull out one artifact, check the gates. # 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_JSON (the artifact), _ART_INDEX (verified index json),
# Sets globals: _ART_INDEX (verified index json), _ART_SCOPE, _ART_APP. # _ART_TYPE, _ART_SCOPE, _ART_APP; LP_INDEX_SIGSTATE via lpFetchIndexInto.
# (LP_INDEX_SIGSTATE is set by lpFetchIndex; artifactApply enforces it.) # MUST be called directly, never as var="$(_artifactResolve …)" -- a command
# substitution strands every one of those globals (incl. the sigstate the
# apply gate enforces) in the subshell. Non-zero = refuse; 2 = not-applicable.
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
_artifactResolve() { _artifactResolve() {
local id="$1" local id="$1"
@ -81,9 +83,10 @@ _artifactResolve() {
[[ -n "$art" ]] || { isError "artifact: id '$id' not found in the signed index."; return 1; } [[ -n "$art" ]] || { isError "artifact: id '$id' not found in the signed index."; return 1; }
local type; type="$(jq -r '.type // empty' <<<"$art")" local type; type="$(jq -r '.type // empty' <<<"$art")"
if [[ "$type" != "hotfix" ]]; then case "$type" in
isError "artifact: type '$type' is not supported by this build (only 'hotfix')."; return 1 hotfix|app) _ART_TYPE="$type" ;;
fi *) isError "artifact: type '$type' is not supported by this build (only 'hotfix' and 'app')."; return 1 ;;
esac
# --- trust: publishers map + role gate --- # --- trust: publishers map + role gate ---
local pub trust role local pub trust role
@ -106,7 +109,14 @@ _artifactResolve() {
_ART_APP="$(jq -r '.applies_when.app // empty' <<<"$art")" _ART_APP="$(jq -r '.applies_when.app // empty' <<<"$art")"
_ART_SCOPE="system"; [[ -n "$_ART_APP" ]] && _ART_SCOPE="app" _ART_SCOPE="system"; [[ -n "$_ART_APP" ]] && _ART_SCOPE="app"
if [[ "$_ART_SCOPE" == "app" && ! -d "${containers_dir%/}/$_ART_APP" ]]; then if [[ "$_ART_TYPE" == "app" ]]; then
# For type:"app" the target IS the thing being added -- presence on the
# box is the bundle collision policy's call, not an applicability gate.
# The slug must be a safe shell-identifier folder name (CFG_<SLUG^^>_*).
if [[ ! "$_ART_APP" =~ ^[a-z0-9][a-z0-9_]{0,31}$ ]]; then
isError "artifact: app artifact '$id' has a missing/unsafe applies_when.app slug -- refusing."; return 1
fi
elif [[ "$_ART_SCOPE" == "app" && ! -d "${containers_dir%/}/$_ART_APP" ]]; then
isNotice "artifact: '$id' targets '$_ART_APP' which is not installed -- not applicable."; return 2 isNotice "artifact: '$id' targets '$_ART_APP' which is not installed -- not applicable."; return 2
fi fi
@ -128,43 +138,66 @@ _artifactResolve() {
fi fi
fi fi
printf '%s' "$art" _ART_JSON="$art"
} }
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
# PAYLOAD -- download, sha256-pin (from the signed index), minisig-verify. # PAYLOAD download core -- fetch $1 to $4, sha256-pin against the SIGNED index's
# REFUSES an unsigned payload (signing-not-activated) on the apply path. # value ($2), minisig-verify against $3 (sig lands at $4.minisig). REFUSES an
# Echoes the verified payload JSON; non-zero on any failure. # unsigned payload (signing-not-activated) -- this is the apply path.
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
_artifactFetchPayload() { _artifactDownloadVerified() {
local art="$1" base channel url want_sha sig_url tmp pf sf got kind pstate local url="$1" want_sha="$2" sig_url="$3" dest="$4"
base="$(lpReleaseBaseUrl)"; channel="$(lpReleaseChannel)" local base got pstate sf="$dest.minisig"
kind="$(jq -r '.payload.kind // empty' <<<"$art")" base="$(lpReleaseBaseUrl)"
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; } [[ -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"
case "$url" in http*://*) : ;; *) url="$base/$url" ;; esac 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 if ! _lpDownload "$url" "$dest" 2>/dev/null; then isError "artifact: payload download failed."; return 1; fi
got="$(_lpSha256 "$pf")" got="$(_lpSha256 "$dest")"
if [[ "$got" != "$want_sha" ]]; then isError "artifact: payload CHECKSUM MISMATCH -- refusing."; rm -rf "$tmp"; return 1; fi if [[ "$got" != "$want_sha" ]]; then isError "artifact: payload CHECKSUM MISMATCH -- refusing."; return 1; fi
if [[ -n "$sig_url" ]]; then if [[ -n "$sig_url" ]]; then
case "$sig_url" in http*://*) : ;; *) sig_url="$base/$sig_url" ;; esac case "$sig_url" in http*://*) : ;; *) sig_url="$base/$sig_url" ;; esac
_lpDownload "$sig_url" "$sf" 2>/dev/null || true _lpDownload "$sig_url" "$sf" 2>/dev/null || true
fi fi
pstate="$(lpVerifyMinisig "$pf" "$sf")" || { rm -rf "$tmp"; return 1; } pstate="$(lpVerifyMinisig "$dest" "$sf")" || return 1
if [[ "$pstate" != "verified" ]]; then if [[ "$pstate" != "verified" ]]; then
isError "artifact: refusing to APPLY an unsigned payload (signing not activated / footprint key missing)."; rm -rf "$tmp"; return 1 isError "artifact: refusing to APPLY an unsigned payload (signing not activated / footprint key missing)."; return 1
fi fi
return 0
}
# Fetch + verify an ops payload (payload.kind:"ops" -- the hotfix body).
# Echoes the verified payload JSON; non-zero on any failure.
_artifactFetchPayload() {
local art="$1" url want_sha sig_url tmp pf kind
kind="$(jq -r '.payload.kind // empty' <<<"$art")"
if [[ "$kind" != "ops" ]]; then
isError "artifact: payload.kind '$kind' has no ops interpreter -- refusing."; 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")"
tmp="$(mktemp -d)"; pf="$tmp/payload.json"
_artifactDownloadVerified "$url" "$want_sha" "$sig_url" "$pf" || { rm -rf "$tmp"; return 1; }
cat "$pf"; rm -rf "$tmp" cat "$pf"; rm -rf "$tmp"
} }
# Fetch + verify a bundle payload (payload.kind:"bundle" -- an app definition
# tarball) into $2 (caller-owned workdir). Echoes the tarball path.
_artifactFetchBundle() {
local art="$1" workdir="$2" url want_sha sig_url kind tb
kind="$(jq -r '.payload.kind // empty' <<<"$art")"
if [[ "$kind" != "bundle" ]]; then
isError "artifact: payload.kind '$kind' is not a bundle -- refusing."; 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")"
tb="$workdir/bundle.tar.gz"
_artifactDownloadVerified "$url" "$want_sha" "$sig_url" "$tb" || return 1
printf '%s' "$tb"
}
# --- path allowlist (the write-target firewall) ----------------------------- # --- path allowlist (the write-target firewall) -----------------------------
# scope:app -> only under $containers_dir/<app>/ # scope:app -> only under $containers_dir/<app>/
# scope:system -> only under $configs_dir/ (the install/code tree is OFF-LIMITS # scope:system -> only under $configs_dir/ (the install/code tree is OFF-LIMITS
@ -193,6 +226,209 @@ _artifactComposeImage() {
grep -m1 -E '^\s*image:' "$f" 2>/dev/null | sed -E 's/^\s*image:\s*//; s/["'"'"']//g; s/\s+#.*$//; s/\s+$//' grep -m1 -E '^\s*image:' "$f" 2>/dev/null | sed -E 's/^\s*image:\s*//; s/["'"'"']//g; s/\s+#.*$//; s/\s+$//'
} }
# ----------------------------------------------------------------------------
# BUNDLE (payload.kind:"bundle", type:"app") -- add an app DEFINITION to the
# App Center catalog: verify + quarantine-validate the tarball, place it in the
# definition tree (install_containers_dir), regen so it surfaces as a normal
# installable app.
#
# ORDERING CONTRACT (why this section is paranoid): the container scan
# live-sources every install_containers_dir/<app>/*.sh on EVERY CLI start, so
# placing a definition IS deferred code execution. Nothing may land there until
# (a) the trust gates upstream have passed (official publisher role at resolve,
# index sha256 pin + payload minisig in _artifactDownloadVerified) AND (b)
# every quarantine check below has passed. Community publishers are refused at
# resolve time; their host-script quarantine tier is a deferred phase.
# ----------------------------------------------------------------------------
# Validate tarball $1 for app slug $2 by extracting into quarantine dir $3.
# Fail-closed on ANY irregularity; writes nothing outside $3.
_artifactBundleValidate() {
local tarball="$1" slug="$2" qdir="$3"
local max_tar=$((5*1024*1024)) max_extracted=$((20*1024*1024)) max_entries=400
[[ "$slug" =~ ^[a-z0-9][a-z0-9_]{0,31}$ ]] || { isError "bundle: unsafe app slug '$slug'"; return 1; }
case " template libreportal tools scripts resources " in
*" $slug "*) isError "bundle: slug '$slug' is reserved"; return 1 ;;
esac
local size; size="$(stat -c %s "$tarball" 2>/dev/null || echo 0)"
(( size > 0 && size <= max_tar )) || { isError "bundle: tarball size ${size}B outside limits (max ${max_tar}B)"; return 1; }
# Listing pass -- names, entry types, declared sizes. Nothing extracted yet,
# so a crafted archive can't touch the disk before it is judged.
local names; names="$(tar -tzf "$tarball" 2>/dev/null)" || { isError "bundle: unreadable tarball"; return 1; }
[[ -n "$names" ]] || { isError "bundle: empty tarball"; return 1; }
local count; count="$(wc -l <<<"$names")"
(( count <= max_entries )) || { isError "bundle: $count entries (max $max_entries)"; return 1; }
local n
while IFS= read -r n; do
[[ "$n" =~ ^[A-Za-z0-9._/@:+-]+$ ]] || { isError "bundle: entry '$n' has unsafe characters"; return 1; }
[[ "$n" == /* ]] && { isError "bundle: absolute path '$n'"; return 1; }
[[ "$n" == ".."* || "$n" == *"/../"* || "$n" == *"/.." ]] && { isError "bundle: path traversal in '$n'"; return 1; }
[[ "$n" == "$slug" || "$n" == "$slug/"* ]] || { isError "bundle: entry '$n' outside the '$slug/' top-level dir"; return 1; }
done <<<"$names"
local badtype; badtype="$(tar -tvzf "$tarball" 2>/dev/null | awk '$1 !~ /^[d-]/ {print; exit}')"
[[ -z "$badtype" ]] || { isError "bundle: non-regular entry (link/device/fifo): $badtype"; return 1; }
local total; total="$(tar -tvzf "$tarball" 2>/dev/null | awk '{s+=$3} END{print s+0}')"
(( total <= max_extracted )) || { isError "bundle: declared extracted size ${total}B exceeds max ${max_extracted}B"; return 1; }
tar -xzf "$tarball" --no-same-owner --no-same-permissions -C "$qdir" 2>/dev/null || { isError "bundle: extraction failed"; return 1; }
# Re-check on disk (belt-and-braces with the listing pass), drop set-id bits.
local special; special="$(find "$qdir" \( -type l -o -type p -o -type s -o -type b -o -type c \) -print -quit 2>/dev/null)"
[[ -z "$special" ]] || { isError "bundle: non-regular file after extraction: $special"; return 1; }
chmod -R u-s,g-s "$qdir" 2>/dev/null || true
# The drop-in contract (docs/contributing/development.md): <slug>.config with
# TITLE + CATEGORY (parsed line-wise, NEVER sourced) and a compose template.
local cfg="$qdir/$slug/$slug.config" upper="${slug^^}"
[[ -f "$cfg" ]] || { isError "bundle: $slug.config missing"; return 1; }
grep -q "^CFG_${upper}_TITLE=" "$cfg" 2>/dev/null || { isError "bundle: CFG_${upper}_TITLE missing from $slug.config"; return 1; }
grep -q "^CFG_${upper}_CATEGORY=" "$cfg" 2>/dev/null || { isError "bundle: CFG_${upper}_CATEGORY missing from $slug.config"; return 1; }
[[ -f "$qdir/$slug/docker-compose.yml" ]] || { isError "bundle: docker-compose.yml missing"; return 1; }
# A syntax-broken .sh would break every future CLI start via the live-source.
local sh
while IFS= read -r sh; do
bash -n "$sh" 2>/dev/null || { isError "bundle: $sh fails bash -n -- refusing"; return 1; }
done < <(find "$qdir/$slug" -type f -name '*.sh' 2>/dev/null)
return 0
}
# The applied-record (if any) that owns app $1's definition -- echoes the record
# path. A definition WITHOUT one is local (hand-made / shipped with the install)
# and always wins over the catalog.
_artifactBundleOwner() {
local slug="$1" dir f
dir="$(_artifactAppliedDir)"
compgen -G "$dir/*.json" >/dev/null 2>&1 || return 1
for f in "$dir"/*.json; do
if [[ "$(jq -r 'select(.type=="app") | .app // empty' "$f" 2>/dev/null)" == "$slug" ]]; then
printf '%s' "$f"; return 0
fi
done
return 1
}
# The APPLY flow for a bundle. RESOLVE/trust/sigstate ran in artifactApply;
# everything after differs from ops (no live app to snapshot or compose-up --
# the undo log alone is the reversibility anchor, and the target is the
# definition tree, written only through the manager funnel).
_artifactApplyBundleFlow() {
local id="$1" art="$2" serial="$3"
local slug="$_ART_APP"
local live_dir="${containers_dir%/}/$slug"
local def_root="${install_containers_dir%/}"
local def_dir="$def_root/$slug"
# --- collision policy: local state always beats the catalog ---
if [[ -d "$live_dir" ]]; then
isError "app '$slug' is installed -- updating a live app is the updater's job. Uninstall first to re-add its definition."
updaterRecordHistory "$slug" "app" "" "$id" "refused-installed" "$id" "$serial" ""
return 1
fi
local owner_rec="" prior_b64=""
if [[ -d "$def_dir" ]]; then
if ! owner_rec="$(_artifactBundleOwner "$slug")"; then
isError "a local definition for '$slug' already exists -- local definitions win; not overwriting."
updaterRecordHistory "$slug" "app" "" "$id" "refused-local-definition" "$id" "$serial" ""
return 1
fi
# Registry-owned -> definition update: pack the prior tree so the undo
# restores it precisely. Definitions are KB-scale; cap the record at ~1MB.
prior_b64="$(tar -czf - -C "$def_root" "$slug" 2>/dev/null | base64 -w0)"
if [[ -z "$prior_b64" ]] || (( ${#prior_b64} > 1400000 )); then
isError "existing '$slug' definition could not be snapshotted for undo (missing or too large) -- refusing."
updaterRecordHistory "$slug" "app" "" "$id" "refused-prior-snapshot" "$id" "$serial" ""
return 1
fi
fi
# --- fetch + quarantine-validate (nothing placed yet) ---
local work; work="$(mktemp -d)"
local tb
if ! tb="$(_artifactFetchBundle "$art" "$work")"; then
rm -rf "$work"
updaterRecordHistory "$slug" "app" "" "$id" "verify-failed" "$id" "$serial" ""
return 1
fi
local qdir="$work/quarantine"; mkdir -p "$qdir"
if ! _artifactBundleValidate "$tb" "$slug" "$qdir"; then
rm -rf "$work"
isNotice "Bundle validation failed -- nothing was written."
updaterRecordHistory "$slug" "app" "" "$id" "rejected-bundle" "$id" "$serial" ""
return 1
fi
# --- undo log first (the reversibility anchor) ---
local undo_file; undo_file="$(mktemp)"
if [[ -n "$prior_b64" ]]; then
jq -cn --arg app "$slug" --arg b "$prior_b64" '{op:"restore-app-definition", app:$app, tar_b64:$b}' >> "$undo_file"
else
jq -cn --arg app "$slug" '{op:"remove-app-definition", app:$app}' >> "$undo_file"
fi
# --- place: stage INSIDE the definition tree, then one rename (atomic vs
# the regen poll -- a half-copied definition is never scanned) ---
local staging="$def_root/.staging.$slug.$$" placed=1
runInstallOp rm -rf "$staging" 2>/dev/null || true
runInstallOp cp -a "$qdir/$slug" "$staging" || placed=0
if [[ "$placed" == "1" && -d "$def_dir" ]]; then
runInstallOp rm -rf "$def_dir" || placed=0
fi
if [[ "$placed" == "1" ]]; then
runInstallOp mv "$staging" "$def_dir" || placed=0
fi
rm -rf "$work"
# --- regen + verify the app actually surfaced in the App Center data ---
if [[ "$placed" == "1" ]]; then
isNotice "Definition placed -- refreshing the App Center catalog..."
{ declare -F lpRegenWebui >/dev/null 2>&1 && lpRegenWebui force >/dev/null 2>&1; } || true
local apps_json="${containers_dir%/}/libreportal/frontend/data/apps/generated/apps.json"
jq -e --arg app "$slug" '.apps[]? | select((.command // "") | endswith(" " + $app))' "$apps_json" >/dev/null 2>&1 || placed=0
fi
if [[ "$placed" != "1" ]]; then
isNotice "Bundle placement/verify failed -- rolling back..."
local rb_rc=0
_artifactReplayUndoFile "$undo_file" "$slug" "reverse" || rb_rc=1
runInstallOp rm -rf "$staging" 2>/dev/null || true
{ declare -F lpRegenWebui >/dev/null 2>&1 && lpRegenWebui force >/dev/null 2>&1; } || true
rm -f "$undo_file"
if [[ "$rb_rc" -eq 0 ]]; then
updaterRecordHistory "$slug" "app" "" "$id" "rolled-back" "$id" "$serial" ""
isError "App bundle $id failed and was rolled back cleanly."
else
updaterRecordHistory "$slug" "app" "" "$id" "rollback-incomplete" "$id" "$serial" ""
isError "App bundle $id failed AND rollback was incomplete -- MANUAL cleanup of $def_dir may be needed (see History)."
fi
return 1
fi
# --- record (same contract as ops: no persisted undo trail, no change) ---
if ! _artifactWriteRecord "$id" "$art" "$serial" "$undo_file"; then
isError "Could not persist the applied-record -- rolling back to stay reversible."
_artifactReplayUndoFile "$undo_file" "$slug" "reverse" || true
{ declare -F lpRegenWebui >/dev/null 2>&1 && lpRegenWebui force >/dev/null 2>&1; } || true
rm -f "$undo_file"
updaterRecordHistory "$slug" "app" "" "$id" "record-failed-rolled-back" "$id" "$serial" ""
return 1
fi
# A definition update retires the prior owning record -- one owner per app.
if [[ -n "$owner_rec" && "$owner_rec" != "$(_artifactRecordFile "$id")" ]]; then
runFileOp rm -f "$owner_rec" 2>/dev/null || true
fi
rm -f "$undo_file"
_artifactRegenAppliedManifest
updaterRecordHistory "$slug" "app" "" "$id" "applied" "$id" "$serial" "$id"
isSuccessful "App '$slug' added to the App Center catalog. Install it with: libreportal app install $slug (undo: libreportal artifact revert $id)"
return 0
}
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
# OP PRECHECK -- pure read; returns 0 if the op can be applied as-is, else 1 with # 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. # a reason on stderr. This is the dry-run that enforces all-or-nothing.
@ -325,6 +561,35 @@ _artifactOpUndo() {
_artifactRmFile "$path" || return 1 _artifactRmFile "$path" || return 1
fi fi
;; ;;
remove-app-definition)
# Inverse of a fresh bundle add. Refuses while the app is installed
# live -- a live app depends on its definition (uninstall first).
local slug live def
slug="$(jq -r '.app // empty' <<<"$op_json")"
[[ "$slug" =~ ^[a-z0-9][a-z0-9_]{0,31}$ ]] || { isError "undo: unsafe app slug '$slug'"; return 1; }
live="${containers_dir%/}/$slug"; def="${install_containers_dir%/}/$slug"
if [[ -d "$live" ]]; then
isError "undo: app '$slug' is INSTALLED -- uninstall it before removing its definition."; return 1
fi
[[ -d "$def" ]] && { runInstallOp rm -rf "$def" || return 1; }
;;
restore-app-definition)
# Inverse of a registry definition update: put the packed prior
# definition tree back (unpack to a temp, then swap into place).
local slug tarb64 def rstage
slug="$(jq -r '.app // empty' <<<"$op_json")"
tarb64="$(jq -r '.tar_b64 // empty' <<<"$op_json")"
{ [[ "$slug" =~ ^[a-z0-9][a-z0-9_]{0,31}$ && -n "$tarb64" ]]; } || { isError "undo: bad restore-app-definition record"; return 1; }
def="${install_containers_dir%/}/$slug"
rstage="$(mktemp -d)"
if ! printf '%s' "$tarb64" | base64 -d 2>/dev/null | tar -xzf - --no-same-owner --no-same-permissions -C "$rstage" 2>/dev/null \
|| [[ ! -d "$rstage/$slug" ]]; then
isError "undo: could not unpack the prior '$slug' definition"; rm -rf "$rstage"; return 1
fi
runInstallOp rm -rf "$def" 2>/dev/null || true
runInstallOp cp -a "$rstage/$slug" "$def" || { rm -rf "$rstage"; return 1; }
rm -rf "$rstage"
;;
*) isError "undo: unknown inverse op '$op'"; return 1 ;; *) isError "undo: unknown inverse op '$op'"; return 1 ;;
esac esac
return 0 return 0
@ -384,13 +649,16 @@ artifactApply() {
_artifactNeedJq || return 1 _artifactNeedJq || return 1
[[ -n "$id" ]] || { isError "artifactApply: no id"; return 1; } [[ -n "$id" ]] || { isError "artifactApply: no id"; return 1; }
isHeader "Applying hotfix: $id" isHeader "Applying artifact: $id"
# 0. RESOLVE + gates # 0. RESOLVE + gates (direct call -- the resolve globals + sigstate must
# land in THIS shell, not a substitution subshell)
local art rc local art rc
art="$(_artifactResolve "$id")"; rc=$? _ART_JSON=""
if [[ $rc -eq 2 ]]; then updaterRecordHistory "${_ART_APP:-}" "hotfix" "" "$id" "not-applicable" "$id" "$(_lpJsonNum "$_ART_INDEX" index_serial)" ""; return 0; fi _artifactResolve "$id"; rc=$?
[[ $rc -eq 0 && -n "$art" ]] || { updaterRecordHistory "" "hotfix" "" "$id" "rejected" "$id" "" ""; return 1; } art="$_ART_JSON"
if [[ $rc -eq 2 ]]; then updaterRecordHistory "${_ART_APP:-}" "${_ART_TYPE:-hotfix}" "" "$id" "not-applicable" "$id" "$(_lpJsonNum "$_ART_INDEX" index_serial)" ""; return 0; fi
[[ $rc -eq 0 && -n "$art" ]] || { updaterRecordHistory "" "${_ART_TYPE:-hotfix}" "" "$id" "rejected" "$id" "" ""; return 1; }
local app="$_ART_APP" scope="$_ART_SCOPE" serial title local app="$_ART_APP" scope="$_ART_SCOPE" serial title
serial="$(_lpJsonNum "$_ART_INDEX" index_serial)" serial="$(_lpJsonNum "$_ART_INDEX" index_serial)"
@ -401,10 +669,25 @@ artifactApply() {
# must not. # must not.
if [[ "$LP_INDEX_SIGSTATE" != "verified" ]]; then if [[ "$LP_INDEX_SIGSTATE" != "verified" ]]; then
isError "artifact: the index is not signature-verified (signing not activated) -- refusing to APPLY." isError "artifact: the index is not signature-verified (signing not activated) -- refusing to APPLY."
updaterRecordHistory "$app" "hotfix" "" "$id" "refused-unsigned" "$id" "$serial" "" updaterRecordHistory "$app" "${_ART_TYPE:-hotfix}" "" "$id" "refused-unsigned" "$id" "$serial" ""
return 1 return 1
fi fi
isNotice "$title (scope=$scope${app:+, app=$app})" isNotice "$title (type=${_ART_TYPE}, scope=$scope${app:+, app=$app})"
# 4-bundle. type:"app" rides payload.kind:"bundle" through its own flow --
# RESOLVE/trust/sigstate above are shared; everything after differs (no live
# app to snapshot or compose-up; the definition tree is the target). The
# type/kind pairing is strict: any other combination is refused.
local kind; kind="$(jq -r '.payload.kind // "ops"' <<<"$art")"
if [[ "$_ART_TYPE" == "app" || "$kind" == "bundle" ]]; then
if [[ "$_ART_TYPE" != "app" || "$kind" != "bundle" ]]; then
isError "artifact: type '$_ART_TYPE' + payload.kind '$kind' is not a supported combination."
updaterRecordHistory "$app" "${_ART_TYPE:-hotfix}" "" "$id" "rejected" "$id" "$serial" ""
return 1
fi
_artifactApplyBundleFlow "$id" "$art" "$serial"
return $?
fi
# 4. PAYLOAD (fetch + sha256-pin + minisig-verify; refuses unsigned) # 4. PAYLOAD (fetch + sha256-pin + minisig-verify; refuses unsigned)
local payload; payload="$(_artifactFetchPayload "$art")" || { updaterRecordHistory "$app" "hotfix" "" "$id" "verify-failed" "$id" "$serial" ""; return 1; } local payload; payload="$(_artifactFetchPayload "$art")" || { updaterRecordHistory "$app" "hotfix" "" "$id" "verify-failed" "$id" "$serial" ""; return 1; }
@ -496,8 +779,9 @@ artifactRevert() {
local rf; rf="$(_artifactRecordFile "$id")" local rf; rf="$(_artifactRecordFile "$id")"
[[ -f "$rf" ]] || { isError "artifact: '$id' is not recorded as applied -- nothing to revert."; return 1; } [[ -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 rec; rec="$(cat "$rf")"
local rtype; rtype="$(jq -r '.type // "hotfix"' <<<"$rec")"
isHeader "Reverting ${rtype}: $id"
local app; app="$(jq -r '.app // empty' <<<"$rec")" local app; app="$(jq -r '.app // empty' <<<"$rec")"
local serial; serial="$(jq -r '.serial // empty' <<<"$rec")" local serial; serial="$(jq -r '.serial // empty' <<<"$rec")"
@ -507,17 +791,21 @@ artifactRevert() {
_artifactReplayUndoFile "$undo_file" "$app" "reverse" || rv_rc=1 _artifactReplayUndoFile "$undo_file" "$app" "reverse" || rv_rc=1
rm -f "$undo_file" rm -f "$undo_file"
if [[ -n "$app" ]]; then dockerComposeUp "$app" >/dev/null 2>&1 || true # Bring-up: an app record changed the DEFINITION tree (regen the catalog);
# a hotfix changed a live app (compose up) or system configs (regen those).
if [[ "$rtype" == "app" ]]; then
{ declare -F lpRegenWebui >/dev/null 2>&1 && lpRegenWebui force >/dev/null 2>&1; } || true
elif [[ -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 else declare -F webuiGenerateSystemConfigs >/dev/null 2>&1 && webuiGenerateSystemConfigs >/dev/null 2>&1 || true; fi
if [[ "$rv_rc" -eq 0 ]]; then if [[ "$rv_rc" -eq 0 ]]; then
runFileOp rm -f "$rf" 2>/dev/null || true runFileOp rm -f "$rf" 2>/dev/null || true
_artifactRegenAppliedManifest _artifactRegenAppliedManifest
updaterRecordHistory "$app" "hotfix" "$id" "" "reverted" "$id" "$serial" "" updaterRecordHistory "$app" "$rtype" "$id" "" "reverted" "$id" "$serial" ""
isSuccessful "Hotfix $id reverted." isSuccessful "${rtype^} $id reverted."
return 0 return 0
fi fi
updaterRecordHistory "$app" "hotfix" "$id" "" "revert-incomplete" "$id" "$serial" "$id" updaterRecordHistory "$app" "$rtype" "$id" "" "revert-incomplete" "$id" "$serial" "$id"
isError "Revert of $id was incomplete -- the applied-record is kept so you can retry: libreportal artifact revert $id (MANUAL recovery may be needed)." isError "Revert of $id was incomplete -- the applied-record is kept so you can retry: libreportal artifact revert $id (MANUAL recovery may be needed)."
return 1 return 1
} }
@ -540,6 +828,8 @@ artifactApplyAuto() {
fi fi
local ids id art sev app installed enqueued=0 local ids id art sev app installed enqueued=0
# type=="hotfix" is a hard filter: apps are ALWAYS a deliberate user action
# (the publisher also forces auto:false on them -- belt and braces).
ids="$(printf '%s' "$index" | jq -r '.artifacts[]? | select(.auto==true and (.type=="hotfix")) | .id' 2>/dev/null)" ids="$(printf '%s' "$index" | jq -r '.artifacts[]? | select(.auto==true and (.type=="hotfix")) | .id' 2>/dev/null)"
while IFS= read -r id; do while IFS= read -r id; do
[[ -z "$id" ]] && continue [[ -z "$id" ]] && continue
@ -563,3 +853,25 @@ artifactApplyAuto() {
if (( enqueued > 0 )); then isSuccessful "Queued $enqueued auto-hotfix(es) for apply (policy: $policy)." if (( enqueued > 0 )); then isSuccessful "Queued $enqueued auto-hotfix(es) for apply (policy: $policy)."
else isNotice "No new auto-hotfixes to apply (policy: $policy)."; fi else isNotice "No new auto-hotfixes to apply (policy: $policy)."; fi
} }
# ----------------------------------------------------------------------------
# appAddFromRegistry <slug-or-artifact-id> -- the `libreportal app add` verb.
# Resolves a type:"app" artifact by exact id or by its applies_when.app slug,
# then runs the standard apply pipeline (which re-fetches + re-verifies).
# ----------------------------------------------------------------------------
appAddFromRegistry() {
local arg="$1"
_artifactNeedJq || return 1
[[ -n "$arg" ]] || { isError "app add: need an app slug or artifact id."; return 1; }
[[ "$arg" =~ ^[A-Za-z0-9._-]+$ ]] || { isError "app add: argument has unsafe characters."; return 1; }
local index; lpFetchIndexInto index || { isError "app add: could not fetch/verify the artifact index."; return 1; }
local ids; ids="$(printf '%s' "$index" | jq -r --arg a "$arg" \
'[.artifacts[]? | select(.type=="app" and (.id==$a or (.applies_when.app // "")==$a)) | .id] | unique | .[]' 2>/dev/null)"
if [[ -z "$ids" ]]; then
isError "app add: no app '$arg' in the catalog. Browse it with: libreportal artifact index"; return 1
fi
if (( $(wc -l <<<"$ids") > 1 )); then
isError "app add: '$arg' matches more than one artifact -- use an exact id: $(tr '\n' ' ' <<<"$ids")"; return 1
fi
artifactApply "$ids"
}

View File

@ -9,16 +9,16 @@ cliShowArtifactHelp()
echo "Available Artifact Commands:" echo "Available Artifact Commands:"
echo "" echo ""
echo " libreportal artifact index - Fetch + verify the signed index; list what's available" echo " libreportal artifact index - Fetch + verify the signed index; list what's available"
echo " libreportal artifact applied - List the hotfixes currently applied to this install" echo " libreportal artifact applied - List the artifacts currently applied to this install"
echo " libreportal artifact apply <id> - Apply a hotfix (snapshot + reversible declarative ops)" echo " libreportal artifact apply <id> - Apply an artifact (reversible; hotfix ops or app bundle)"
echo " libreportal artifact revert <id> - Revert a previously-applied hotfix (replays its undo log)" echo " libreportal artifact revert <id> - Revert a previously-applied artifact (replays its undo log)"
echo "" echo ""
echo "An 'artifact' is anything LibrePortal pulls from the outside and applies" echo "An 'artifact' is anything LibrePortal pulls from the outside and applies"
echo "reversibly — a hotfix today; apps / themes / components later. They share" echo "reversibly — a hotfix (bounded declarative ops, no scripts) or an app"
echo "one team-signed catalog (index.json) on the same channel as the version" echo "definition bundle from the registry catalog (themes / components later)."
echo "check, verified against the root-owned signing key. apply/revert run through" echo "They share one team-signed catalog (index.json) on the same channel as the"
echo "the task system (never a mutating API): each apply dry-prechecks a bounded," echo "version check, verified against the root-owned signing key. apply/revert run"
echo "declarative op list (no scripts), snapshots, applies, records a precise undo," echo "through the task system (never a mutating API), record a precise undo, and"
echo "and auto-rolls-back on failure. See docs/roadmap/updates-and-distribution.md." echo "auto-roll-back on failure. See docs/roadmap/updates-and-distribution.md."
echo "" echo ""
} }

View File

@ -15,6 +15,7 @@ declare -gA LP_FN_MAP=(
[adguard_install_message_data]="adguard/scripts/adguard_install_hooks.sh" [adguard_install_message_data]="adguard/scripts/adguard_install_hooks.sh"
[adguard_install_post_start]="adguard/scripts/adguard_install_hooks.sh" [adguard_install_post_start]="adguard/scripts/adguard_install_hooks.sh"
[adoptDockerSubnet]="checks/requirements/check_docker_network.sh" [adoptDockerSubnet]="checks/requirements/check_docker_network.sh"
[appAddFromRegistry]="cli/commands/artifact/cli_artifact_apply.sh"
[appAdguardApplyDnsUpdater]="adguard/tools/adguard_apply_dns_updater.sh" [appAdguardApplyDnsUpdater]="adguard/tools/adguard_apply_dns_updater.sh"
[appAdguardResetPassword]="adguard/tools/adguard_reset_password.sh" [appAdguardResetPassword]="adguard/tools/adguard_reset_password.sh"
[appBookstackCreateAccount]="bookstack/tools/bookstack_create_account.sh" [appBookstackCreateAccount]="bookstack/tools/bookstack_create_account.sh"
@ -104,7 +105,12 @@ declare -gA LP_FN_MAP=(
[_artifactAppliedDir]="cli/commands/artifact/cli_artifact_apply.sh" [_artifactAppliedDir]="cli/commands/artifact/cli_artifact_apply.sh"
[artifactApply]="cli/commands/artifact/cli_artifact_apply.sh" [artifactApply]="cli/commands/artifact/cli_artifact_apply.sh"
[artifactApplyAuto]="cli/commands/artifact/cli_artifact_apply.sh" [artifactApplyAuto]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactApplyBundleFlow]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactBundleOwner]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactBundleValidate]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactComposeImage]="cli/commands/artifact/cli_artifact_apply.sh" [_artifactComposeImage]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactDownloadVerified]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactFetchBundle]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactFetchPayload]="cli/commands/artifact/cli_artifact_apply.sh" [_artifactFetchPayload]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactGenDir]="cli/commands/artifact/cli_artifact_apply.sh" [_artifactGenDir]="cli/commands/artifact/cli_artifact_apply.sh"
[artifactListApplied]="cli/commands/artifact/cli_artifact_commands.sh" [artifactListApplied]="cli/commands/artifact/cli_artifact_commands.sh"
@ -974,6 +980,7 @@ declare -gA LP_FN_ROOT=(
[adguard_install_message_data]="containers" [adguard_install_message_data]="containers"
[adguard_install_post_start]="containers" [adguard_install_post_start]="containers"
[adoptDockerSubnet]="scripts" [adoptDockerSubnet]="scripts"
[appAddFromRegistry]="scripts"
[appAdguardApplyDnsUpdater]="containers" [appAdguardApplyDnsUpdater]="containers"
[appAdguardResetPassword]="containers" [appAdguardResetPassword]="containers"
[appBookstackCreateAccount]="containers" [appBookstackCreateAccount]="containers"
@ -1063,7 +1070,12 @@ declare -gA LP_FN_ROOT=(
[_artifactAppliedDir]="scripts" [_artifactAppliedDir]="scripts"
[artifactApply]="scripts" [artifactApply]="scripts"
[artifactApplyAuto]="scripts" [artifactApplyAuto]="scripts"
[_artifactApplyBundleFlow]="scripts"
[_artifactBundleOwner]="scripts"
[_artifactBundleValidate]="scripts"
[_artifactComposeImage]="scripts" [_artifactComposeImage]="scripts"
[_artifactDownloadVerified]="scripts"
[_artifactFetchBundle]="scripts"
[_artifactFetchPayload]="scripts" [_artifactFetchPayload]="scripts"
[_artifactGenDir]="scripts" [_artifactGenDir]="scripts"
[artifactListApplied]="scripts" [artifactListApplied]="scripts"
@ -1954,6 +1966,7 @@ acquireSingletonLock() { source "${install_scripts_dir}task/crontab_task_process
adguard_install_message_data() { source "${install_containers_dir}adguard/scripts/adguard_install_hooks.sh"; adguard_install_message_data "$@"; } adguard_install_message_data() { source "${install_containers_dir}adguard/scripts/adguard_install_hooks.sh"; adguard_install_message_data "$@"; }
adguard_install_post_start() { source "${install_containers_dir}adguard/scripts/adguard_install_hooks.sh"; adguard_install_post_start "$@"; } adguard_install_post_start() { source "${install_containers_dir}adguard/scripts/adguard_install_hooks.sh"; adguard_install_post_start "$@"; }
adoptDockerSubnet() { source "${install_scripts_dir}checks/requirements/check_docker_network.sh"; adoptDockerSubnet "$@"; } adoptDockerSubnet() { source "${install_scripts_dir}checks/requirements/check_docker_network.sh"; adoptDockerSubnet "$@"; }
appAddFromRegistry() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; appAddFromRegistry "$@"; }
appAdguardApplyDnsUpdater() { source "${install_containers_dir}adguard/tools/adguard_apply_dns_updater.sh"; appAdguardApplyDnsUpdater "$@"; } appAdguardApplyDnsUpdater() { source "${install_containers_dir}adguard/tools/adguard_apply_dns_updater.sh"; appAdguardApplyDnsUpdater "$@"; }
appAdguardResetPassword() { source "${install_containers_dir}adguard/tools/adguard_reset_password.sh"; appAdguardResetPassword "$@"; } appAdguardResetPassword() { source "${install_containers_dir}adguard/tools/adguard_reset_password.sh"; appAdguardResetPassword "$@"; }
appBookstackCreateAccount() { source "${install_containers_dir}bookstack/tools/bookstack_create_account.sh"; appBookstackCreateAccount "$@"; } appBookstackCreateAccount() { source "${install_containers_dir}bookstack/tools/bookstack_create_account.sh"; appBookstackCreateAccount "$@"; }
@ -2043,7 +2056,12 @@ appWebuiRefresh_gluetun() { source "${install_containers_dir}gluetun/scripts/glu
_artifactAppliedDir() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactAppliedDir "$@"; } _artifactAppliedDir() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactAppliedDir "$@"; }
artifactApply() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; artifactApply "$@"; } artifactApply() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; artifactApply "$@"; }
artifactApplyAuto() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; artifactApplyAuto "$@"; } artifactApplyAuto() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; artifactApplyAuto "$@"; }
_artifactApplyBundleFlow() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactApplyBundleFlow "$@"; }
_artifactBundleOwner() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactBundleOwner "$@"; }
_artifactBundleValidate() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactBundleValidate "$@"; }
_artifactComposeImage() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactComposeImage "$@"; } _artifactComposeImage() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactComposeImage "$@"; }
_artifactDownloadVerified() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactDownloadVerified "$@"; }
_artifactFetchBundle() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactFetchBundle "$@"; }
_artifactFetchPayload() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactFetchPayload "$@"; } _artifactFetchPayload() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactFetchPayload "$@"; }
_artifactGenDir() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactGenDir "$@"; } _artifactGenDir() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactGenDir "$@"; }
artifactListApplied() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_commands.sh"; artifactListApplied "$@"; } artifactListApplied() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_commands.sh"; artifactListApplied "$@"; }