feat(updater): flag apps whose image upstream has stopped rebuilding

"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 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-13 01:09:03 +01:00
parent 2c589b2a51
commit ca266e9391
4 changed files with 92 additions and 6 deletions

View File

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

View File

@ -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))
? `<span class="updater-badge sev-medium" title="Upstream last rebuilt this image ${esc(this.updater.fmtAge(this.updater.imageAgeDays(a)))} ago — no security patches since">unmaintained?</span>`
: '';
const newer = a.newer_version
? `<span class="updater-badge updater-badge-unknown" title="You track ${esc(a.channel || '')}${esc(a.newer_version)} has been published. Change Version on the app's Advanced tab to move.">${esc(a.newer_version)} available</span>`
: '';
@ -613,7 +619,7 @@ class OverviewManager {
return `<div class="updater-row ov-row" data-app="${slug}">
<div class="updater-row-head ov-row-head" data-overview-action="toggle" data-app="${slug}" role="button" tabindex="0" aria-expanded="false" aria-controls="ov-detail-${slug}">
<img class="ov-row-icon" src="/core/icons/apps/${slug}.svg" alt="" onerror="this.style.display='none'">
<span class="updater-row-name">${name}</span> ${status} ${sev} ${pol} ${newer}
<span class="updater-row-name">${name}</span> ${status} ${sev} ${pol} ${newer} ${stale}
<span class="updater-row-ver">${cur}${avail ? ` <span class="updater-arrow">→</span> <strong>${avail}</strong>` : ''}</span>
<span class="ov-row-actions">${updBtn}<button class="ov-details-btn" data-overview-action="toggle" data-app="${slug}" tabindex="-1" aria-hidden="true"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6,9 12,15 18,9"></polyline></svg><span>Details</span></button></span>
</div>

View File

@ -586,10 +586,22 @@ class UpdaterPage {
? `<div class="updater-detail-row"><span class="updater-detail-meta">A newer release line is available: <strong>${this.escape(a.newer_version)}</strong> (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.</span>
<button class="updater-btn" data-updater-action="upgrade" data-app="${this.escape(a.name)}" data-version="${this.escape(a.newer_version)}">Upgrade to ${this.escape(a.newer_version)}</button></div>`
: '';
// 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)
? `<div class="updater-detail-row"><span class="updater-badge sev-medium">possibly unmaintained</span>
<span class="updater-detail-meta">Upstream last rebuilt this image <strong>${this.fmtAge(age)}</strong> 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.</span></div>`
: '';
versionSection = `<div class="updater-detail-section"><h4>Version</h4>
<div class="updater-detail-row">${badge} <span class="updater-row-ver">${cur}${avail ? ` <span class="updater-arrow">→</span> <strong>${avail}</strong>` : ''}</span></div>
<div class="updater-detail-row"><span class="updater-detail-meta">${policyLine}</span></div>
${newerLine}</div>`;
${newerLine}${staleLine}</div>`;
}
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 `<div class="updater-detail">${versionSection}${security}${recovery}${history}</div>`;
}
// 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

View File

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