Merge claude/2

This commit is contained in:
librelad 2026-07-17 23:30:28 +01:00
commit 06bd49d768
3 changed files with 170 additions and 26 deletions

View File

@ -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]

View File

@ -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"

View File

@ -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 "service<TAB>image" 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:<repodigest>|<oci-version-label>".
# 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 "<no value>"|"<nil>"|"$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 <<EOF
# local running identity of the anchor image
local li dig oci; li="$(updaterInspectLocal "$anchor")"; dig="${li%%|*}"; oci="${li#*|}"
{ "name": "$app", "displayName": "$app", "current_image": "$(printf '%s' "$image" | sed 's/"/\\"/g')", "current_version": "$(printf '%s' "${image##*:}" | sed 's/"/\\"/g')", "available_image": "$(printf '%s' "$available" | sed 's/"/\\"/g')", "available_version": "$(printf '%s' "${available##*:}" | sed 's/"/\\"/g')", "update_available": $update_available, "scanned": true, "last_checked": "$now" }
EOF
)
# available (registry) digest for the anchor's channel
local avail_dig=""
if [ "$do_registry" = "1" ]; then
avail_dig="$(updaterRegistryDigest "$(updaterRepoTag "$anchor")")"
fi
# reuse the prior value when we didn't (or couldn't) reach the registry
if [ -z "$avail_dig" ] && [ "$have_jq" = "1" ] && [ -f "$prev_json" ]; then
avail_dig="$(jq -r --arg n "$app" '(.apps[]?|select(.name==$n)|.available_digest)//""' "$prev_json" 2>/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" <<EOF
{ "generated_at": "$now", "apps": [${entries}
] }
EOF
# Write as the container user that owns out_dir. cp'ing the manager-owned
# mktemp would fail (the container user can't read a 600 /tmp file), so the
# old `runFileOp cp || cp` fell through to a manager cp that EACCES'd on the
# container-owned dir. runFileWrite reads the tmp in this (manager) shell and
# tees it as the container user — works in both modes.
if [ "$have_jq" = "1" ]; then
jq -s --arg now "$now" '{generated_at:$now, apps:.}' "$objs" > "$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"