From ca266e9391824eadd40c447062df9614552888fe Mon Sep 17 00:00:00 2001 From: librelad Date: Thu, 13 Aug 2026 01:09:03 +0100 Subject: [PATCH] feat(updater): flag apps whose image upstream has stopped rebuilding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Up to date" answers one question — has the tag I track moved? — and an abandoned project answers it reassuringly forever. The tag stays put, the digest never changes, and the app reports as current while receiving no security patches at all. Nothing in the UI could tell a healthy stable app from a dead one. An audit of all 34 anchor images found five in exactly that state: speedtest (4.4y since rebuild), focalboard (2.8y — Mattermost dropped support in 2023), pihole-unbound (2.3y), trilium (2.2y), unbound (1.8y). The scan now records image_updated_at per app (one cheap Hub call inside the existing registry window, cached between windows like everything else) and emits stale_after_days from CFG_UPDATER_STALE_DAYS (365, 0 disables) so the UI and the config agree on one number. Surfaced as an "unmaintained?" severity chip on the fleet row and a dated explanation in the app detail. Phrased as an observation rather than an accusation — plenty of small tools are simply finished — but it does spell out the security consequence, because that is the part a user cannot infer from "up to date". Deliberately NOT a "needs action" row on the Overview board: it is not fixable by pressing anything, and a permanently amber board teaches people to ignore the board. Co-Authored-By: Claude Opus 5 --- configs/webui/webui_updater | 1 + .../apps/overview/js/overview-manager.js | 8 +++- .../components/updater/js/updater-page.js | 42 ++++++++++++++++- .../generators/updater/webui_updater_scan.sh | 47 +++++++++++++++++-- 4 files changed, 92 insertions(+), 6 deletions(-) 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"