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.
`;
}
+ // 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"