From 96b04392dc19b18a19164f08a4812cd654bae05b Mon Sep 17 00:00:00 2001 From: librelad Date: Sun, 31 May 2026 20:53:54 +0100 Subject: [PATCH] =?UTF-8?q?feat(distribution):=20Phase=203=20=E2=80=94=20h?= =?UTF-8?q?otfix=20scan=20generator=20+=20severity-split=20auto-apply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CFG_HOTFIX_AUTO (security-breakage|all|off, default security-breakage) seeded in general_terminal; reaches existing installs via the add-only config reconciler. - webui_artifact_scan.sh (webuiArtifactScan): fetch+verify the signed index, write artifacts_available.json ATOMICALLY (build in temp → jq-validate → one write; keep the prior file on any failure — never emits broken JSON). Annotates each artifact with applied (a per-id record exists) + applicable (target installed). - artifactApplyAuto + `libreportal artifact apply-auto`: enqueue apply tasks for the eligible signed hotfixes — only when the index is VERIFIED-signed, only auto==true + in the severity policy + applicable + not already applied. Each apply is its own task (visible in the log + History), never applied inline. - `updater check` now also refreshes the index (webuiArtifactScan) and runs artifactApplyAuto — one front door, no second phone-home. Unit-tested 13/13: policy filtering (security-breakage / off / all), auto:false exclusion, already-applied skip, non-installed-app skip, unsigned-index fail-closed, and the scan transform's signed/applied/applicable fields. Co-Authored-By: Claude Opus 4.8 Signed-off-by: librelad --- configs/general/general_terminal | 1 + .../commands/artifact/cli_artifact_apply.sh | 42 ++++++++++ .../artifact/cli_artifact_commands.sh | 4 + .../commands/updater/cli_updater_commands.sh | 10 +++ scripts/source/files/arrays/files_webui.sh | 1 + .../source/files/arrays/function_manifest.sh | 6 ++ .../generators/updater/webui_artifact_scan.sh | 79 +++++++++++++++++++ 7 files changed, 143 insertions(+) create mode 100644 scripts/webui/data/generators/updater/webui_artifact_scan.sh diff --git a/configs/general/general_terminal b/configs/general/general_terminal index e28bb85..b7181f4 100755 --- a/configs/general/general_terminal +++ b/configs/general/general_terminal @@ -2,6 +2,7 @@ # Terminal - System utilities and advanced settings **ADVANCED** # ================================================================================ CFG_UPDATER_CHECK=60 # Update Check Interval - Hours between system update checks +CFG_HOTFIX_AUTO=security-breakage # Hotfix Auto-Apply - Which signed hotfix severities apply automatically on the update check [security-breakage|all|off] CFG_SWAPFILE_SIZE=2G # Swap File Size - Size of swap file for memory management CFG_GENERATED_PASS_LENGTH=14 # Password Length - Length for auto generated passwords CFG_GENERATED_USER_LENGTH=8 # Username Length - Length for auto generated usernames diff --git a/scripts/cli/commands/artifact/cli_artifact_apply.sh b/scripts/cli/commands/artifact/cli_artifact_apply.sh index a8d1d54..c1fc19d 100644 --- a/scripts/cli/commands/artifact/cli_artifact_apply.sh +++ b/scripts/cli/commands/artifact/cli_artifact_apply.sh @@ -521,3 +521,45 @@ artifactRevert() { 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 } + +# ---------------------------------------------------------------------------- +# artifactApplyAuto -- enqueue apply tasks for the auto-eligible hotfixes, gated +# by CFG_HOTFIX_AUTO (security-breakage|all|off). Called from `updater check`. +# Only acts on a VERIFIED-signed index; only artifacts with auto==true, in the +# severity policy, applicable, and not already applied. Each apply is enqueued as +# its own task (visible in the task log + History) -- never applied inline here. +# ---------------------------------------------------------------------------- +artifactApplyAuto() { + _artifactNeedJq || return 0 + local policy="${CFG_HOTFIX_AUTO:-security-breakage}" + [[ "$policy" == "off" ]] && { isNotice "Hotfix auto-apply is off (CFG_HOTFIX_AUTO=off)."; return 0; } + + local index; index="$(lpFetchIndex)" || { isNotice "artifact apply-auto: no index available."; return 0; } + if [[ "$LP_INDEX_SIGSTATE" != "verified" ]]; then + isNotice "artifact apply-auto: index is unsigned (signing not activated) — not auto-applying."; return 0 + fi + + local ids id art sev app installed enqueued=0 + 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 + art="$(printf '%s' "$index" | jq -ce --arg id "$id" '.artifacts[]?|select(.id==$id)' 2>/dev/null)" + [[ -n "$art" ]] || continue + sev="$(jq -r '.severity // "tweak"' <<<"$art")" + case "$policy" in + all) : ;; + *) [[ "$sev" == "security" || "$sev" == "breakage" ]] || continue ;; # security-breakage (default) + esac + # skip if already applied + [[ -f "$(_artifactRecordFile "$id")" ]] && continue + # skip if app-scoped but the app isn't installed (applicable gate; full + # gates re-checked at apply time) + app="$(jq -r '.applies_when.app // empty' <<<"$art")" + [[ -n "$app" && ! -d "${containers_dir%/}/$app" ]] && continue + cliTaskRun "libreportal artifact apply $id" "artifact_apply" "$id" "" + enqueued=$((enqueued + 1)) + done <<< "$ids" + + 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 +} diff --git a/scripts/cli/commands/artifact/cli_artifact_commands.sh b/scripts/cli/commands/artifact/cli_artifact_commands.sh index acd0be8..8a23873 100644 --- a/scripts/cli/commands/artifact/cli_artifact_commands.sh +++ b/scripts/cli/commands/artifact/cli_artifact_commands.sh @@ -59,6 +59,10 @@ cliHandleArtifactCommands() cliTaskRun "libreportal artifact revert $id" "artifact_revert" "$id" "" fi ;; + "apply-auto") + # Decide + enqueue (gated by CFG_HOTFIX_AUTO); each apply is its own task. + artifactApplyAuto + ;; *) cliShowArtifactHelp ;; diff --git a/scripts/cli/commands/updater/cli_updater_commands.sh b/scripts/cli/commands/updater/cli_updater_commands.sh index c57dd39..33b4875 100644 --- a/scripts/cli/commands/updater/cli_updater_commands.sh +++ b/scripts/cli/commands/updater/cli_updater_commands.sh @@ -30,6 +30,16 @@ cliHandleUpdaterCommands() source "$install_scripts_dir/webui/data/generators/updater/webui_updater_scan.sh" 2>/dev/null fi webuiUpdaterScan + # Hotfix channel: refresh the signed artifact index for the WebUI, then + # auto-apply the eligible signed hotfixes (gated by CFG_HOTFIX_AUTO). + if ! declare -F webuiArtifactScan >/dev/null 2>&1; then + source "$install_scripts_dir/webui/data/generators/updater/webui_artifact_scan.sh" 2>/dev/null + fi + declare -F webuiArtifactScan >/dev/null 2>&1 && webuiArtifactScan + if ! declare -F artifactApplyAuto >/dev/null 2>&1; then + source "$install_scripts_dir/cli/commands/artifact/cli_artifact_apply.sh" 2>/dev/null + fi + declare -F artifactApplyAuto >/dev/null 2>&1 && artifactApplyAuto ;; "apply"|"now") diff --git a/scripts/source/files/arrays/files_webui.sh b/scripts/source/files/arrays/files_webui.sh index 76d1749..94c72c6 100755 --- a/scripts/source/files/arrays/files_webui.sh +++ b/scripts/source/files/arrays/files_webui.sh @@ -33,6 +33,7 @@ webui_scripts=( "webui/data/generators/system/webui_system_memory.sh" "webui/data/generators/system/webui_system_metrics.sh" "webui/data/generators/system/webui_system_update.sh" + "webui/data/generators/updater/webui_artifact_scan.sh" "webui/data/generators/updater/webui_updater_scan.sh" "webui/data/lock/webui_check_update_lock.sh" "webui/data/lock/webui_create_update_lock.sh" diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index 82ea5bd..d32931a 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.sh @@ -103,6 +103,7 @@ declare -gA LP_FN_MAP=( [appWebuiRefresh_gluetun]="gluetun/scripts/gluetun_providers.sh" [_artifactAppliedDir]="cli/commands/artifact/cli_artifact_apply.sh" [artifactApply]="cli/commands/artifact/cli_artifact_apply.sh" + [artifactApplyAuto]="cli/commands/artifact/cli_artifact_apply.sh" [_artifactComposeImage]="cli/commands/artifact/cli_artifact_apply.sh" [_artifactFetchPayload]="cli/commands/artifact/cli_artifact_apply.sh" [_artifactGenDir]="cli/commands/artifact/cli_artifact_apply.sh" @@ -889,6 +890,7 @@ declare -gA LP_FN_MAP=( [viewLibrePortalConfigs]="config/core/config_manage_menu.sh" [viewLogs]="logs/installed_apps.sh" [viewLogsAppMenu]="logs/app_log_menu.sh" + [webuiArtifactScan]="webui/data/generators/updater/webui_artifact_scan.sh" [webuiCheckUpdateLock]="webui/data/lock/webui_check_update_lock.sh" [webuiContainerSetup]="webui/data/utils/webui_container_setup.sh" [webuiCreateAppFieldMappings]="webui/data/generators/categories/webui_create_app_field_mappings.sh" @@ -1043,6 +1045,7 @@ declare -gA LP_FN_ROOT=( [appWebuiRefresh_gluetun]="containers" [_artifactAppliedDir]="scripts" [artifactApply]="scripts" + [artifactApplyAuto]="scripts" [_artifactComposeImage]="scripts" [_artifactFetchPayload]="scripts" [_artifactGenDir]="scripts" @@ -1829,6 +1832,7 @@ declare -gA LP_FN_ROOT=( [viewLibrePortalConfigs]="scripts" [viewLogs]="scripts" [viewLogsAppMenu]="scripts" + [webuiArtifactScan]="scripts" [webuiCheckUpdateLock]="scripts" [webuiContainerSetup]="scripts" [webuiCreateAppFieldMappings]="scripts" @@ -2004,6 +2008,7 @@ appUpdateSpecifics_pihole() { source "${install_containers_dir}pihole/scripts/pi appWebuiRefresh_gluetun() { source "${install_containers_dir}gluetun/scripts/gluetun_providers.sh"; appWebuiRefresh_gluetun "$@"; } _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 "$@"; } _artifactComposeImage() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactComposeImage "$@"; } _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 "$@"; } @@ -2790,6 +2795,7 @@ viewConfigs() { source "${install_scripts_dir}config/core/config_main_menu.sh"; viewLibrePortalConfigs() { source "${install_scripts_dir}config/core/config_manage_menu.sh"; viewLibrePortalConfigs "$@"; } viewLogs() { source "${install_scripts_dir}logs/installed_apps.sh"; viewLogs "$@"; } viewLogsAppMenu() { source "${install_scripts_dir}logs/app_log_menu.sh"; viewLogsAppMenu "$@"; } +webuiArtifactScan() { source "${install_scripts_dir}webui/data/generators/updater/webui_artifact_scan.sh"; webuiArtifactScan "$@"; } webuiCheckUpdateLock() { source "${install_scripts_dir}webui/data/lock/webui_check_update_lock.sh"; webuiCheckUpdateLock "$@"; } webuiContainerSetup() { source "${install_scripts_dir}webui/data/utils/webui_container_setup.sh"; webuiContainerSetup "$@"; } webuiCreateAppFieldMappings() { source "${install_scripts_dir}webui/data/generators/categories/webui_create_app_field_mappings.sh"; webuiCreateAppFieldMappings "$@"; } diff --git a/scripts/webui/data/generators/updater/webui_artifact_scan.sh b/scripts/webui/data/generators/updater/webui_artifact_scan.sh new file mode 100644 index 0000000..e95200a --- /dev/null +++ b/scripts/webui/data/generators/updater/webui_artifact_scan.sh @@ -0,0 +1,79 @@ +#!/bin/bash + +# WebUI artifact-index data generator +# --------------------------------------------------------------------------- +# Writes the read-only JSON the updater "Improvements" (hotfix) stream reads: +# frontend/data/updater/generated/artifacts_available.json +# +# It fetches + verifies the signed artifact index (lpFetchIndex), annotates each +# artifact with `applied` (a per-id applied-record exists) and `applicable` (the +# target app is installed / system-scope), and writes ATOMICALLY: build into a +# temp, validate it, then one runFileWrite. On ANY fetch/verify failure it KEEPS +# the prior file — it must never emit broken JSON, and the WebUI degrades +# gracefully when the file is absent. Run by `libreportal updater check`. + +webuiArtifactScan() { + local out_dir="${containers_dir%/}/libreportal/frontend/data/updater/generated" + local out="$out_dir/artifacts_available.json" + local applied_dir="$out_dir/applied" + runFileOp mkdir -p "$out_dir" 2>/dev/null || true + + # Lazy-loader gap: ensure the read primitives are present. + if ! declare -F lpFetchIndex >/dev/null 2>&1; then + source "$install_scripts_dir/source/fetch.sh" 2>/dev/null + source "$install_scripts_dir/source/artifacts.sh" 2>/dev/null + fi + if ! command -v jq >/dev/null 2>&1; then + isNotice "webuiArtifactScan: jq not available — keeping the prior artifacts_available.json." + return 0 + fi + + local index + if ! index="$(lpFetchIndex)"; then + isNotice "webuiArtifactScan: no verified index available — keeping the prior file." + return 0 + fi + local signed="false"; [[ "$LP_INDEX_SIGSTATE" == "verified" ]] && signed="true" + local serial now + serial="$(_lpJsonNum "$index" index_serial)" + now="$(date -Iseconds 2>/dev/null || date)" + + # Applied ids = basenames of applied/*.json, as a JSON array. + local applied_ids="[]" f + if compgen -G "$applied_dir/*.json" >/dev/null 2>&1; then + applied_ids="$(for f in "$applied_dir"/*.json; do basename "$f" .json; done | jq -R . | jq -cs .)" + fi + # Installed apps = dir names under containers_dir, for the applicable check. + local installed="[]" d + if [[ -d "$containers_dir" ]]; then + installed="$(for d in "$containers_dir"/*/; do [[ -d "$d" ]] && basename "$d"; done | jq -R . | jq -cs .)" + fi + + local tmp; tmp="$(mktemp)" + printf '%s' "$index" | jq \ + --arg now "$now" --arg signed "$signed" --arg serial "${serial:-0}" \ + --argjson applied "$applied_ids" --argjson installed "$installed" ' + { generated_at: $now, + signed: ($signed=="true"), + serial: ($serial|tonumber? // 0), + artifacts: [ .artifacts[]? | { + id, type, + severity: (.severity // "tweak"), + auto: (.auto // false), + reversible: (.reversible // true), + title: (.title // ""), + why: (.why // ""), + app: (.applies_when.app // null), + applied: (.id as $i | ($applied | index($i)) != null), + applicable: ((.applies_when.app // null) as $a | if $a == null then true else ($installed | index($a)) != null end) + } ] }' > "$tmp" 2>/dev/null + + if ! jq empty "$tmp" 2>/dev/null; then + isNotice "webuiArtifactScan: generated JSON was invalid — keeping the prior file." + rm -f "$tmp"; return 1 + fi + runFileWrite "$out" < "$tmp" + rm -f "$tmp" + runFileOp chown "$docker_install_user":"$docker_install_user" "$out" 2>/dev/null || true + isSuccessful "Artifact index refreshed (serial=${serial:-?}, signed=$signed)." +}