diff --git a/configs/webui/webui_updater b/configs/webui/webui_updater index 89dbbb5..f968b98 100644 --- a/configs/webui/webui_updater +++ b/configs/webui/webui_updater @@ -6,5 +6,6 @@ CFG_UPDATER_SCAN_INTERVAL=30 # App Scan Interval - 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 = only when you press Check now. CFG_HOTFIX_AUTO=security-breakage # Hotfix Auto-Apply - Which signed hotfix severities apply automatically on the update check [security-breakage|all|off] CFG_UPDATER_AUTO=true # Automatic App Updates - Master switch for per-app automatic updates. Each app's own Update Type decides individually; turning this off makes every app manual. Every update snapshots the app first and rolls back on failure. [true:On|false:Off] +CFG_UPDATER_STALE_DAYS=365 # Unmaintained Warning After - Days without an upstream rebuild before an app is flagged as possibly unmaintained. "Up to date" only means the version you track has not moved — if nobody has rebuilt that image in a year, it has had no security patches either, and nothing else would tell you. 0 disables the warning. CFG_UPDATER_UPGRADE_PRUNE=true # Clean Up After Version Upgrades - After a successful stepped upgrade (e.g. Nextcloud 31→34), delete the images it stepped through. Each one can be well over a gigabyte and nothing else removes them, since every step is a real tag rather than a dangling image. The version immediately before the current one is always kept so a roll-back needs no download. [true:On|false:Off] CFG_UPDATER_WINDOW=06:00-08:00 # Automatic Update Window - When automatic updates are allowed to install, in the host's local time (HH:MM-HH:MM; crosses midnight when start > end; 'always' = any time). Checks still run all day so the Updates page stays current — found updates simply wait for the window. Pressing Update yourself always works. diff --git a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js index a638da6..22b5044 100644 --- a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js +++ b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js @@ -604,6 +604,12 @@ class OverviewManager { // no Update button applies it, because moving between versions can carry a // data migration. It is a nudge to go read release notes and change the // Version field, which is why it renders as a quiet informational chip. + // Upstream has stopped rebuilding this image. Deliberately styled as a + // severity chip rather than an update chip: it is the one state where + // "✓ Up to date" is true and reassuring and still hides a security problem. + const stale = (this.updater && this.updater.isStale && this.updater.isStale(a)) + ? `unmaintained?` + : ''; const newer = a.newer_version ? `${esc(a.newer_version)} available` : ''; @@ -613,7 +619,7 @@ class OverviewManager { return `
diff --git a/containers/libreportal/frontend/components/updater/js/updater-page.js b/containers/libreportal/frontend/components/updater/js/updater-page.js index ffe1ad3..6ce0ead 100644 --- a/containers/libreportal/frontend/components/updater/js/updater-page.js +++ b/containers/libreportal/frontend/components/updater/js/updater-page.js @@ -586,10 +586,22 @@ class UpdaterPage { ? `
A newer release line is available: ${this.escape(a.newer_version)} (you track ${this.escape(a.channel || '—')}). Automatic updates keep you current within your line. Upgrading walks the releases one at a time, snapshotting and verifying each — read the release notes first.
` : ''; + // Maintenance, which "up to date" cannot express. Phrased as an + // observation with a date, not an accusation — plenty of small tools are + // simply finished — but it does say what it means for security, because + // that is the part a user cannot infer. + const age = this.imageAgeDays(a); + const staleLine = this.isStale(a) + ? `
possibly unmaintained + Upstream last rebuilt this image ${this.fmtAge(age)} ago + (${this.escape((a.image_updated_at || '').slice(0, 10))}). It is genuinely up to date — that tag has not moved — + but an image nobody rebuilds gets no security patches either. Worth checking whether the project is still active, + or replacing it with a maintained alternative.
` + : ''; versionSection = `

Version

${badge} ${cur}${avail ? ` ${avail}` : ''}
${policyLine}
- ${newerLine}
`; + ${newerLine}${staleLine}
`; } const cves = a.cves || []; const appLabel = this.escape((window.getAppDisplayName ? window.getAppDisplayName(a.name) : null) || a.displayName || a.name || 'the app'); @@ -636,6 +648,34 @@ class UpdaterPage { return `
${versionSection}${security}${recovery}${history}
`; } + // Days since this app's image was last rebuilt upstream, or null if unknown + // (non-Docker-Hub registry, locally built image, or never scanned). + imageAgeDays(a) { + const t = a && a.image_updated_at ? Date.parse(a.image_updated_at) : NaN; + if (!t || isNaN(t)) return null; + return Math.floor((Date.now() - t) / 86400000); + } + + // Has upstream stopped rebuilding this image? This is NOT "an update is + // waiting" — it is the opposite, and far more insidious: the tag never moves, + // so the digest never changes, so the app is genuinely "up to date" forever + // while receiving no security patches at all. Nothing else in the UI can + // distinguish a healthy stable app from an abandoned one. + isStale(a) { + const limit = (this.updates && Number(this.updates.stale_after_days)); + if (!limit || !(limit > 0)) return false; // 0 / missing disables it + const age = this.imageAgeDays(a); + return age != null && age >= limit; + } + + // "2.8 years" reads better than "1037 days" at this scale. + fmtAge(days) { + if (days == null) return 'unknown'; + if (days < 60) return `${days} days`; + if (days < 730) return `${Math.round(days / 30)} months`; + return `${(days / 365).toFixed(1)} years`; + } + // True when this app's AVAILABLE build is the one the auto-updater already // attempted and rolled back (the one-shot no-retry stamp). Such an update is // effectively manual now: it sits until a person retries or a newer build diff --git a/scripts/webui/data/generators/updater/webui_updater_scan.sh b/scripts/webui/data/generators/updater/webui_updater_scan.sh index 04e424f..50de7aa 100644 --- a/scripts/webui/data/generators/updater/webui_updater_scan.sh +++ b/scripts/webui/data/generators/updater/webui_updater_scan.sh @@ -137,6 +137,28 @@ updaterRegistryTags() { | grep -oE '"name":"[^"]+"' | cut -d'"' -f4 } +# When was the image behind this tag last rebuilt? (ISO date, "" if unknown.) +# +# This is the one question that separates "up to date" from "maintained". An +# abandoned project leaves its tag exactly where it is, so the digest never +# changes and the updater honestly reports the app as current — forever. Five +# apps in this catalogue are on images last rebuilt between 1.8 and 4.4 years +# ago, and nothing in the UI said so, because nothing was wrong by the only +# question being asked. +# +# One cheap call per app per registry window, on the same endpoint the ladder +# probes, so it costs nothing extra in practice. +updaterTagLastUpdated() { + local repo="${1%%:*}" tag="${2:-latest}" + repo="${repo#docker.io/}"; repo="${repo#index.docker.io/}" + case "$repo" in *.*/*|localhost/*) return 0 ;; esac # non-Hub: unknown, stay quiet + case "$repo" in */*) : ;; *) repo="library/$repo" ;; esac + command -v curl >/dev/null 2>&1 || return 0 + curl -fsS --connect-timeout 5 --max-time 12 \ + "https://hub.docker.com/v2/repositories/${repo}/tags/${tag}" 2>/dev/null \ + | grep -o '"last_updated":"[^"]*"' | head -1 | cut -d'"' -f4 +} + # Split a tag into its SHAPE and its numbers, so only like-for-like is compared. # "v0.16" -> shape "v#.#" numbers "0 16" # "31-fpm-alpine" -> "#-fpm-alpine" "31" @@ -261,6 +283,18 @@ webuiUpdaterScan() { local update_available=false [ -n "$dig" ] && [ -n "$avail_dig" ] && [ "$dig" != "$avail_dig" ] && update_available=true + # When the tracked tag was last rebuilt. Same window/caching rule as the + # rest of the registry work: look it up live inside the window, otherwise + # carry the previous answer forward so the field never blinks off. + local img_updated="" + if [ "$do_registry" = "1" ]; then + img_updated="$(updaterTagLastUpdated "$(updaterRepoTag "$anchor")" "$channel")" + fi + if [ -z "$img_updated" ] && [ "$have_jq" = "1" ] && [ -f "$prev_json" ]; then + img_updated="$(jq -r --arg n "$app" '(.apps[]?|select(.name==$n)|.image_updated_at)//""' "$prev_json" 2>/dev/null)" + [ "$img_updated" = "null" ] && img_updated="" + fi + # Newer VERSION (a different tag), not just a newer build of this tag. # Versioned tags only, and only inside the registry window — it is one # more network call. Reuses the previous answer between windows so the @@ -332,7 +366,7 @@ webuiUpdaterScan() { --arg available_digest "$avail_dig" --argjson update_available "$update_available" \ --arg last_checked "$now" --argjson services "$svcs" \ --arg update_type "$policy" --arg auto_attempted "$auto_attempted" \ - --arg newer_version "$newer_ver" \ + --arg newer_version "$newer_ver" --arg image_updated "$img_updated" \ '{name:$name,displayName:$displayName,type:$type,channel:$channel, current_image:$current_image,current_version:$current_version,current_digest:$current_digest, available_image:$available_image, @@ -341,6 +375,7 @@ webuiUpdaterScan() { update_type:$update_type, auto_attempted_digest:(if $auto_attempted=="" then null else $auto_attempted end), newer_version:(if $newer_version=="" then null else $newer_version end), + image_updated_at:(if $image_updated=="" then null else $image_updated end), scanned:true,last_checked:$last_checked,services:$services}' \ >> "$objs" 2>/dev/null else @@ -360,12 +395,16 @@ webuiUpdaterScan() { # updates land, not just that they will. Raw value on purpose: a malformed # window (which fails closed in the enqueuer) is then visible on the page. local auto_window="${CFG_UPDATER_WINDOW:-always}" + # How long without an upstream rebuild counts as stale. A threshold, not a + # verdict: emitted so the UI and the config agree on one number. + local stale_days="${CFG_UPDATER_STALE_DAYS:-365}" + [[ "$stale_days" =~ ^[0-9]+$ ]] || stale_days=365 local tmp; tmp="$(mktemp)" if [ "$have_jq" = "1" ]; then - jq -s --arg now "$now" --argjson iv "$scan_interval" --arg win "$auto_window" \ - '{generated_at:$now, scan_interval_minutes:$iv, auto_window:$win, apps:.}' "$objs" > "$tmp" 2>/dev/null \ - || printf '{ "generated_at": "%s", "scan_interval_minutes": %s, "auto_window": "%s", "apps": [] }\n' "$now" "$scan_interval" "$auto_window" > "$tmp" + jq -s --arg now "$now" --argjson iv "$scan_interval" --arg win "$auto_window" --argjson stale "$stale_days" \ + '{generated_at:$now, scan_interval_minutes:$iv, auto_window:$win, stale_after_days:$stale, apps:.}' "$objs" > "$tmp" 2>/dev/null \ + || printf '{ "generated_at": "%s", "scan_interval_minutes": %s, "auto_window": "%s", "stale_after_days": %s, "apps": [] }\n' "$now" "$scan_interval" "$auto_window" "$stale_days" > "$tmp" else { printf '{ "generated_at": "%s", "scan_interval_minutes": %s, "auto_window": "%s", "apps": [' "$now" "$scan_interval" "$auto_window" paste -sd, "$objs"; printf '] }\n'; } > "$tmp"