diff --git a/scripts/cli/commands/app/cli_app_commands.sh b/scripts/cli/commands/app/cli_app_commands.sh index da8bc04..2a58ef0 100755 --- a/scripts/cli/commands/app/cli_app_commands.sh +++ b/scripts/cli/commands/app/cli_app_commands.sh @@ -55,6 +55,35 @@ cliHandleAppCommands() 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 "; 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") # Optional `--delete-images` flag (in any of the trailing # positions) tells the uninstall to also remove the app's diff --git a/scripts/cli/commands/app/cli_app_header.sh b/scripts/cli/commands/app/cli_app_header.sh index d7db419..6f91bfa 100755 --- a/scripts/cli/commands/app/cli_app_header.sh +++ b/scripts/cli/commands/app/cli_app_header.sh @@ -16,6 +16,9 @@ cliShowAppHelp() echo " - Install / reinstall the specified app." echo " On reinstall, IPs and ports are preserved by default." 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 "" echo " libreportal app start [name*] - Start the specified app (Must be installed)" diff --git a/scripts/cli/commands/artifact/cli_artifact_apply.sh b/scripts/cli/commands/artifact/cli_artifact_apply.sh index 1adde75..c17da99 100644 --- a/scripts/cli/commands/artifact/cli_artifact_apply.sh +++ b/scripts/cli/commands/artifact/cli_artifact_apply.sh @@ -69,9 +69,11 @@ _artifactRmFile() { # ---------------------------------------------------------------------------- # 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. -# (LP_INDEX_SIGSTATE is set by lpFetchIndex; artifactApply enforces it.) +# Sets globals: _ART_JSON (the artifact), _ART_INDEX (verified index json), +# _ART_TYPE, _ART_SCOPE, _ART_APP; LP_INDEX_SIGSTATE via lpFetchIndexInto. +# 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() { local id="$1" @@ -81,9 +83,10 @@ _artifactResolve() { [[ -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 + case "$type" in + hotfix|app) _ART_TYPE="$type" ;; + *) isError "artifact: type '$type' is not supported by this build (only 'hotfix' and 'app')."; return 1 ;; + esac # --- trust: publishers map + role gate --- local pub trust role @@ -106,7 +109,14 @@ _artifactResolve() { _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 + 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__*). + 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 fi @@ -128,43 +138,66 @@ _artifactResolve() { fi fi - printf '%s' "$art" + _ART_JSON="$art" } # ---------------------------------------------------------------------------- -# PAYLOAD -- download, sha256-pin (from the signed index), minisig-verify. -# REFUSES an unsigned payload (signing-not-activated) on the apply path. -# Echoes the verified payload JSON; non-zero on any failure. +# PAYLOAD download core -- fetch $1 to $4, sha256-pin against the SIGNED index's +# value ($2), minisig-verify against $3 (sig lands at $4.minisig). REFUSES an +# unsigned payload (signing-not-activated) -- this is the apply path. # ---------------------------------------------------------------------------- -_artifactFetchPayload() { - local art="$1" base channel url want_sha sig_url tmp pf sf got kind pstate - 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")" +_artifactDownloadVerified() { + local url="$1" want_sha="$2" sig_url="$3" dest="$4" + local base got pstate sf="$dest.minisig" + base="$(lpReleaseBaseUrl)" [[ -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 - 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 ! _lpDownload "$url" "$dest" 2>/dev/null; then isError "artifact: payload download failed."; return 1; fi + got="$(_lpSha256 "$dest")" + if [[ "$got" != "$want_sha" ]]; then isError "artifact: payload CHECKSUM MISMATCH -- refusing."; 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 - pstate="$(lpVerifyMinisig "$pf" "$sf")" || { rm -rf "$tmp"; return 1; } + pstate="$(lpVerifyMinisig "$dest" "$sf")" || return 1 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 + 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" } +# 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) ----------------------------- # scope:app -> only under $containers_dir// # 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+$//' } +# ---------------------------------------------------------------------------- +# 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//*.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): .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 # a reason on stderr. This is the dry-run that enforces all-or-nothing. @@ -325,6 +561,35 @@ _artifactOpUndo() { _artifactRmFile "$path" || return 1 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 ;; esac return 0 @@ -384,13 +649,16 @@ artifactApply() { _artifactNeedJq || 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 - 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; } + _ART_JSON="" + _artifactResolve "$id"; rc=$? + 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 serial="$(_lpJsonNum "$_ART_INDEX" index_serial)" @@ -401,10 +669,25 @@ artifactApply() { # must not. if [[ "$LP_INDEX_SIGSTATE" != "verified" ]]; then 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 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) 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")" [[ -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 rtype; rtype="$(jq -r '.type // "hotfix"' <<<"$rec")" + isHeader "Reverting ${rtype}: $id" local app; app="$(jq -r '.app // empty' <<<"$rec")" local serial; serial="$(jq -r '.serial // empty' <<<"$rec")" @@ -507,17 +791,21 @@ artifactRevert() { _artifactReplayUndoFile "$undo_file" "$app" "reverse" || rv_rc=1 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 if [[ "$rv_rc" -eq 0 ]]; then runFileOp rm -f "$rf" 2>/dev/null || true _artifactRegenAppliedManifest - updaterRecordHistory "$app" "hotfix" "$id" "" "reverted" "$id" "$serial" "" - isSuccessful "Hotfix $id reverted." + updaterRecordHistory "$app" "$rtype" "$id" "" "reverted" "$id" "$serial" "" + isSuccessful "${rtype^} $id reverted." return 0 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)." return 1 } @@ -540,6 +828,8 @@ artifactApplyAuto() { fi 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)" while IFS= read -r id; do [[ -z "$id" ]] && continue @@ -563,3 +853,25 @@ artifactApplyAuto() { 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 } + +# ---------------------------------------------------------------------------- +# appAddFromRegistry -- 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" +} diff --git a/scripts/cli/commands/artifact/cli_artifact_header.sh b/scripts/cli/commands/artifact/cli_artifact_header.sh index dcc1112..85f4272 100644 --- a/scripts/cli/commands/artifact/cli_artifact_header.sh +++ b/scripts/cli/commands/artifact/cli_artifact_header.sh @@ -9,16 +9,16 @@ cliShowArtifactHelp() echo "Available Artifact Commands:" echo "" 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 apply - Apply a hotfix (snapshot + reversible declarative ops)" - echo " libreportal artifact revert - Revert a previously-applied hotfix (replays its undo log)" + echo " libreportal artifact applied - List the artifacts currently applied to this install" + echo " libreportal artifact apply - Apply an artifact (reversible; hotfix ops or app bundle)" + echo " libreportal artifact revert - Revert a previously-applied artifact (replays its undo log)" echo "" echo "An 'artifact' is anything LibrePortal pulls from the outside and applies" - echo "reversibly — a hotfix today; apps / themes / components later. They share" - echo "one team-signed catalog (index.json) on the same channel as the version" - echo "check, verified against the root-owned signing key. apply/revert run through" - echo "the task system (never a mutating API): each apply dry-prechecks a bounded," - echo "declarative op list (no scripts), snapshots, applies, records a precise undo," - echo "and auto-rolls-back on failure. See docs/roadmap/updates-and-distribution.md." + echo "reversibly — a hotfix (bounded declarative ops, no scripts) or an app" + echo "definition bundle from the registry catalog (themes / components later)." + echo "They share one team-signed catalog (index.json) on the same channel as the" + echo "version check, verified against the root-owned signing key. apply/revert run" + echo "through the task system (never a mutating API), record a precise undo, and" + echo "auto-roll-back on failure. See docs/roadmap/updates-and-distribution.md." echo "" } diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index 7a7dc00..81493fd 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.sh @@ -15,6 +15,7 @@ declare -gA LP_FN_MAP=( [adguard_install_message_data]="adguard/scripts/adguard_install_hooks.sh" [adguard_install_post_start]="adguard/scripts/adguard_install_hooks.sh" [adoptDockerSubnet]="checks/requirements/check_docker_network.sh" + [appAddFromRegistry]="cli/commands/artifact/cli_artifact_apply.sh" [appAdguardApplyDnsUpdater]="adguard/tools/adguard_apply_dns_updater.sh" [appAdguardResetPassword]="adguard/tools/adguard_reset_password.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" [artifactApply]="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" + [_artifactDownloadVerified]="cli/commands/artifact/cli_artifact_apply.sh" + [_artifactFetchBundle]="cli/commands/artifact/cli_artifact_apply.sh" [_artifactFetchPayload]="cli/commands/artifact/cli_artifact_apply.sh" [_artifactGenDir]="cli/commands/artifact/cli_artifact_apply.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_post_start]="containers" [adoptDockerSubnet]="scripts" + [appAddFromRegistry]="scripts" [appAdguardApplyDnsUpdater]="containers" [appAdguardResetPassword]="containers" [appBookstackCreateAccount]="containers" @@ -1063,7 +1070,12 @@ declare -gA LP_FN_ROOT=( [_artifactAppliedDir]="scripts" [artifactApply]="scripts" [artifactApplyAuto]="scripts" + [_artifactApplyBundleFlow]="scripts" + [_artifactBundleOwner]="scripts" + [_artifactBundleValidate]="scripts" [_artifactComposeImage]="scripts" + [_artifactDownloadVerified]="scripts" + [_artifactFetchBundle]="scripts" [_artifactFetchPayload]="scripts" [_artifactGenDir]="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_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 "$@"; } +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 "$@"; } appAdguardResetPassword() { source "${install_containers_dir}adguard/tools/adguard_reset_password.sh"; appAdguardResetPassword "$@"; } 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 "$@"; } 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 "$@"; } +_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 "$@"; } +_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 "$@"; } _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 "$@"; }