diff --git a/configs/webui/webui_updater b/configs/webui/webui_updater index 7f7c1c3..69e39f2 100644 --- a/configs/webui/webui_updater +++ b/configs/webui/webui_updater @@ -3,4 +3,5 @@ # @icon 🔄 # ================================================================================ CFG_UPDATER_SCAN_INTERVAL=30 # App Scan Interval - Minutes between automatic app update/CVE/improvement scans. 0 disables. +CFG_UPDATER_REGISTRY_INTERVAL=360 # Registry Check Interval - Minutes between registry lookups for new image builds (the expensive step; the local scan still refreshes every scan). 0 = never (local-only). CFG_HOTFIX_AUTO=security-breakage # Hotfix Auto-Apply - Which signed hotfix severities apply automatically on the update check [security-breakage|all|off] diff --git a/scripts/webui/data/generators/config/webui_update_config.sh b/scripts/webui/data/generators/config/webui_update_config.sh index 59c5b05..748cb58 100755 --- a/scripts/webui/data/generators/config/webui_update_config.sh +++ b/scripts/webui/data/generators/config/webui_update_config.sh @@ -72,7 +72,7 @@ webuiValidateConfigValue() { isError " Invalid crontab format for $var_name" fi ;; - CFG_BACKUP_KEEP_LAST|CFG_BACKUP_KEEP_DAILY|CFG_BACKUP_KEEP_WEEKLY|CFG_BACKUP_KEEP_MONTHLY|CFG_BACKUP_KEEP_YEARLY|CFG_BACKUP_VERIFY_DATA_PERCENT|CFG_BACKUP_DASHBOARD_REFRESH_INTERVAL|CFG_UPDATER_CHECK|CFG_UPDATER_SCAN_INTERVAL|CFG_SWAPFILE_SIZE|CFG_GENERATED_PASS_LENGTH|CFG_WEBUI_LOG_STREAM_IDLE_TIMEOUT_MINUTES|CFG_WEBUI_LOG_STREAM_MAX_DURATION_MINUTES|CFG_WEBUI_LOG_STREAM_MAX_LINES_PER_SEC) + CFG_BACKUP_KEEP_LAST|CFG_BACKUP_KEEP_DAILY|CFG_BACKUP_KEEP_WEEKLY|CFG_BACKUP_KEEP_MONTHLY|CFG_BACKUP_KEEP_YEARLY|CFG_BACKUP_VERIFY_DATA_PERCENT|CFG_BACKUP_DASHBOARD_REFRESH_INTERVAL|CFG_UPDATER_CHECK|CFG_UPDATER_SCAN_INTERVAL|CFG_UPDATER_REGISTRY_INTERVAL|CFG_SWAPFILE_SIZE|CFG_GENERATED_PASS_LENGTH|CFG_WEBUI_LOG_STREAM_IDLE_TIMEOUT_MINUTES|CFG_WEBUI_LOG_STREAM_MAX_DURATION_MINUTES|CFG_WEBUI_LOG_STREAM_MAX_LINES_PER_SEC) # Validate numeric values if ! echo "$var_value" | grep -qE '^[0-9]+$'; then isError " $var_name must be a positive integer" diff --git a/scripts/webui/data/generators/updater/webui_updater_scan.sh b/scripts/webui/data/generators/updater/webui_updater_scan.sh index 498d42d..c94e5bc 100644 --- a/scripts/webui/data/generators/updater/webui_updater_scan.sh +++ b/scripts/webui/data/generators/updater/webui_updater_scan.sh @@ -31,17 +31,85 @@ # hyphens in the service name, e.g. libreportal_catalog → libreportal-catalog- # service), then take the first `image:` under it. Falls back to the first # image line for anything that doesn't follow the convention. +# Strips `image:`, quotes, and any trailing ` #comment` (e.g. the version +# sentinel added to anchor lines) plus surrounding whitespace. +_updaterCleanImageRef() { sed -E 's/^\s*image:\s*//; s/["'"'"']//g; s/[[:space:]]*#.*$//; s/[[:space:]]*$//'; } updaterPrimaryImage() { local app="$1" compose="$2" svc img svc="${app//_/-}-service" img="$(awk -v s="$svc" ' m && /^[[:space:]]*image:/ { print; exit } $0 ~ ("^[[:space:]]*" s ":") { m=1 } - ' "$compose" 2>/dev/null | sed -E 's/^\s*image:\s*//; s/["'"'"']//g')" - [ -n "$img" ] || img="$(grep -m1 -E '^\s*image:' "$compose" 2>/dev/null | sed -E 's/^\s*image:\s*//; s/["'"'"']//g')" + ' "$compose" 2>/dev/null | _updaterCleanImageRef)" + [ -n "$img" ] || img="$(grep -m1 -E '^\s*image:' "$compose" 2>/dev/null | _updaterCleanImageRef)" printf '%s' "$img" } +# All service→image pairs in an app's compose, one "serviceimage" per line, +# in file order (skips commented lines). Used to build updates.json services[]. +updaterAllServiceImages() { + awk ' + /^[[:space:]]*#/ { next } + /^[[:space:]]{2,4}[a-z0-9_-]+:[[:space:]]*(#|$)/ { + s=$1; sub(/:.*/,"",s); gsub(/[[:space:]]/,"",s); svc=s; next + } + /^[[:space:]]*image:/ { + img=$0; sub(/^[[:space:]]*image:[[:space:]]*/,"",img); gsub(/["'"'"']/,"",img); sub(/[[:space:]]*#.*/,"",img) + if (svc!="") { print svc "\t" img; svc="" } + } + ' "$1" 2>/dev/null +} + +# Tag portion of an image ref (after the last colon IN the final path segment; +# empty when untagged). Strips any @digest first. +updaterTagOf() { + local ref="${1%%@*}" last; last="${ref##*/}" + case "$last" in *:*) printf '%s' "${last##*:}";; *) printf '';; esac +} +# repo:tag with any @digest suffix removed +updaterRepoTag() { printf '%s' "${1%%@*}"; } + +# Classify a tag: rolling (floating channel / OS-variant / untagged) vs +# versioned (carries a comparable version number). See the roadmap doc §2. +updaterClassifyTag() { + local tag; tag="$(printf '%s' "$1" | tr 'A-Z' 'a-z')" + [ -z "$tag" ] && { echo rolling; return; } + case "$tag" in + latest|stable|main|master|edge|nightly|rolling|dev|develop|canary|release) echo rolling; return;; + latest-*|stable-*|main-*|master-*|edge-*|nightly-*|develop-*|canary-*|release-*) echo rolling; return;; + esac + case "$tag" in *[0-9]*) echo versioned;; *) echo rolling;; esac +} + +# One docker inspect of a LOCAL image → "sha256:|". +# Empty (both sides) when the image isn't present. Rootless-aware. +updaterInspectLocal() { + local ref="$1" out + out="$(dockerCommandRun "docker inspect --format '{{index .RepoDigests 0}}|{{index .Config.Labels \"org.opencontainers.image.version\"}}' $ref" 2>/dev/null | tr -d '\r' | head -1)" + local dig="${out%%|*}" lbl="${out#*|}" + dig="${dig##*@}"; case "$dig" in sha256:*) : ;; *) dig="";; esac + case "$lbl" in ""|""|"$out") lbl="";; esac + printf '%s|%s' "$dig" "$lbl" +} + +# Registry index digest for repo:tag WITHOUT pulling (buildx imagetools). This is +# the same identity docker records as the local RepoDigest, so a straight compare +# is exact. "" on any failure (offline / rate-limited) — caller treats "" as +# "unknown", never as "changed". Rootless-aware. +updaterRegistryDigest() { + dockerCommandRun "docker buildx imagetools inspect $1 --format '{{.Manifest.Digest}}'" 2>/dev/null \ + | tr -d '\r' | grep -oE 'sha256:[0-9a-f]{64}' | head -1 +} + +# Human-readable version for display: OCI label → tag (versioned) → tag·shortdigest. +updaterDisplayVersion() { + local oci="$1" channel="$2" vtype="$3" digest="$4" + if [ -n "$oci" ]; then printf '%s' "$oci"; return; fi + if [ "$vtype" = "versioned" ] && [ -n "$channel" ]; then printf '%s' "$channel"; return; fi + local short="${digest#sha256:}"; short="${short:0:7}" + if [ -n "$short" ]; then printf '%s · %s' "${channel:-latest}" "$short"; else printf '%s' "${channel:-latest}"; fi +} + webuiUpdaterScan() { local out_dir="$containers_dir/libreportal/frontend/data/updater/generated" runFileOp mkdir -p "$out_dir" 2>/dev/null || mkdir -p "$out_dir" 2>/dev/null @@ -65,35 +133,110 @@ webuiUpdaterScan() { done < <(runFileOp find "$containers_dir" -mindepth 1 -maxdepth 1 -type d 2>/dev/null) fi - # Build the per-app updates array. - local entries="" first=1 + # Registry lookups are the expensive step, so throttle them SEPARATELY from + # the cheap local scan: at most once per CFG_UPDATER_REGISTRY_INTERVAL + # minutes (default 360 = 6h; 0 = never, local-only). Between windows we reuse + # the previous available_digest from the existing updates.json, so the app + # list + running versions still refresh every scan. UPDATER_REGISTRY_FORCE=1 + # (the "Check now" button) forces a live pull. + local prev_json="$out_dir/updates.json" + local reg_interval="${CFG_UPDATER_REGISTRY_INTERVAL:-360}" + local reg_stamp="/tmp/libreportal_updater_registry_checked" + local do_registry=1 + [ "$reg_interval" = "0" ] && do_registry=0 + if [ -z "${UPDATER_REGISTRY_FORCE:-}" ] && [ "$do_registry" = "1" ] && [ -f "$reg_stamp" ]; then + local _rn _rl; _rn=$(date +%s); _rl=$(stat -c '%Y' "$reg_stamp" 2>/dev/null || echo 0) + (( _rn - _rl < reg_interval * 60 )) && do_registry=0 + fi + local have_jq=0; command -v jq >/dev/null 2>&1 && have_jq=1 + + # Build the per-app updates array (one compact JSON object per line → jq -s). + local objs; objs="$(mktemp)" for app in "${apps[@]}"; do - local image="" compose="$containers_dir/$app/docker-compose.yml" - [ -f "$compose" ] && image="$(updaterPrimaryImage "$app" "$compose")" - # --- available-version hook ------------------------------------------- - # Wire a registry check here (e.g. `docker manifest inspect`/skopeo) to - # set available_image + update_available. Honest default: up to date. - local available="$image" update_available="false" + local compose="$containers_dir/$app/docker-compose.yml" + [ -f "$compose" ] || continue + local anchor; anchor="$(updaterPrimaryImage "$app" "$compose")" + local channel; channel="$(updaterTagOf "$anchor")"; [ -n "$channel" ] || channel="latest" + local vtype; vtype="$(updaterClassifyTag "$channel")" - [ $first -eq 0 ] && entries+="," - first=0 - entries+=$(cat </dev/null)" + fi + + local update_available=false + [ -n "$dig" ] && [ -n "$avail_dig" ] && [ "$dig" != "$avail_dig" ] && update_available=true + + local cur_ver; cur_ver="$(updaterDisplayVersion "$oci" "$channel" "$vtype" "$dig")" + local avail_ver="" + if [ "$update_available" = "true" ]; then + local sa="${avail_dig#sha256:}"; sa="${sa:0:7}" + avail_ver="${channel} · ${sa}" + fi + + # services[] — every image line, anchored flag + per-service digest + local svcs="[]" + if [ "$have_jq" = "1" ]; then + local sobjs; sobjs="$(mktemp)" + local anchor_svc="${app//_/-}-service" + while IFS="$(printf '\t')" read -r sname simg; do + [ -n "$simg" ] || continue + local sli sdig; sli="$(updaterInspectLocal "$simg")"; sdig="${sli%%|*}" + local stag; stag="$(updaterTagOf "$simg")"; [ -n "$stag" ] || stag="latest" + local is_anchor=false; [ "$sname" = "$anchor_svc" ] && is_anchor=true + jq -cn --arg service "$sname" --arg image "$simg" --arg channel "$stag" \ + --arg type "$(updaterClassifyTag "$stag")" --arg pinned "$sdig" \ + --argjson anchor "$is_anchor" \ + '{service:$service,image:$image,channel:$channel,type:$type,pinned_digest:$pinned,is_anchor:$anchor}' \ + >> "$sobjs" 2>/dev/null + done < <(updaterAllServiceImages "$compose") + svcs="$(jq -cs '.' "$sobjs" 2>/dev/null || echo '[]')"; rm -f "$sobjs" + fi + + if [ "$have_jq" = "1" ]; then + jq -cn \ + --arg name "$app" --arg displayName "$app" --arg type "$vtype" \ + --arg channel "$channel" --arg current_image "$anchor" \ + --arg current_version "$cur_ver" --arg current_digest "$dig" \ + --arg available_image "$anchor" --arg available_version "$avail_ver" \ + --arg available_digest "$avail_dig" --argjson update_available "$update_available" \ + --arg last_checked "$now" --argjson services "$svcs" \ + '{name:$name,displayName:$displayName,type:$type,channel:$channel, + current_image:$current_image,current_version:$current_version,current_digest:$current_digest, + available_image:$available_image, + available_version:($available_version|select(.!="")), + available_digest:$available_digest,update_available:$update_available, + scanned:true,last_checked:$last_checked,services:$services}' \ + >> "$objs" 2>/dev/null + else + # jq-less fallback: minimal but valid object + printf '{"name":"%s","displayName":"%s","current_image":"%s","current_version":"%s","update_available":false,"scanned":true,"last_checked":"%s"}\n' \ + "$app" "$app" "$(printf '%s' "$anchor" | sed 's/"/\\"/g')" \ + "$(printf '%s' "$cur_ver" | sed 's/"/\\"/g')" "$now" >> "$objs" + fi done + [ "$do_registry" = "1" ] && touch "$reg_stamp" 2>/dev/null || true local tmp; tmp="$(mktemp)" - cat > "$tmp" < "$tmp" 2>/dev/null \ + || printf '{ "generated_at": "%s", "apps": [] }\n' "$now" > "$tmp" + else + { printf '{ "generated_at": "%s", "apps": [' "$now" + paste -sd, "$objs"; printf '] }\n'; } > "$tmp" + fi + rm -f "$objs" + # Write as the container user that owns out_dir (runFileWrite reads the + # manager-owned tmp here and tees it as the container user — works in both + # rootless and rooted modes; a plain cp would EACCES on the container dir). runFileWrite "$out_dir/updates.json" < "$tmp" rm -f "$tmp"