Compare commits

...

2 Commits

Author SHA1 Message Date
librelad
5b437c0c52 Merge claude/1 2026-06-12 22:07:42 +01:00
librelad
fa47e16cab feat(updater): automatic background scan for versions, CVEs & improvements
Replace the click-to-scan-only flow with a self-throttled auto-scan that
rides the existing task-processor idle poll (the same shape as the
network-drift check — no new daemon, unit, or endpoint):

- 'libreportal updater check auto' gates on the age of the generated
  updates.json vs CFG_UPDATER_SCAN_INTERVAL (minutes, default 30,
  0 disables); a fresh file makes the 60s tick a single stat() + return.
  Manual checks and post-update rescans reset the clock for free, and a
  missing file means the first scan runs ~a minute after install.
- Eligible signed hotfixes keep flowing through artifactApplyAuto, which
  only enqueues ordinary tasks — mutations stay on the task path.
- Open updater surfaces (standalone /updater and the fleet Overview's
  headless UpdaterPage) follow along with a 60s static-JSON re-read that
  repaints only when a generated_at stamp changed; timer released via
  dispose() on unmount, ticks skipped while hidden.
- Empty states now say the first scan happens automatically; Check now
  stays as the immediate manual override.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-06-12 22:07:42 +01:00
10 changed files with 95 additions and 10 deletions

View File

@ -3,6 +3,7 @@
# ================================================================================
CFG_UPDATER_CHECK=60 # Update Check Interval - Hours between system update checks
CFG_HOTFIX_AUTO=security-breakage # Hotfix Auto-Apply - Which signed hotfix severities apply automatically on the update check [security-breakage|all|off]
CFG_UPDATER_SCAN_INTERVAL=30 # App Scan Interval - Minutes between automatic app update/CVE/improvement scans (0 disables)
CFG_SWAPFILE_SIZE=2G # Swap File Size - Size of swap file for memory management
CFG_GENERATED_PASS_LENGTH=14 # Password Length - Length for auto generated passwords
CFG_GENERATED_USER_LENGTH=8 # Username Length - Length for auto generated usernames

View File

@ -123,6 +123,9 @@ LP.features.register({
// overviewManager singleton + its DOM persist with the layout; its run()
// self-guards, but unregistering is the clean release.
try { ctx && ctx.services.tasks.refresh && ctx.services.tasks.refresh.unregister('overview'); } catch (_) {}
// Same for the headless updater's auto-refresh timer (re-armed on the next
// Overview mount).
try { window.overviewManager && window.overviewManager.updater && window.overviewManager.updater.dispose(); } catch (_) {}
// The Backups tab embeds a BackupPage; release its document listeners +
// task-refresh registration on the way out (the "stacks on revisit" bug).
try { window.overviewBackupPage && window.overviewBackupPage.dispose && window.overviewBackupPage.dispose(); } catch (_) {}

View File

@ -53,6 +53,20 @@ class OverviewManager {
const initial = this.parseTabFromUrl() || 'overview';
await this.refreshAll();
this._applyTab(initial); // active + header + render, no history push
// Keep an open Overview in step with the host-side auto-scan (no task event
// fires for it). The headless updater owns the timer + change detection;
// re-armed here on every mount, released by the apps feature's unmount.
// isActive: the pane element persists hidden in the shared layout, so gate
// on it actually being shown (offsetParent is null while display:none).
if (this.updater && this.updater.startAutoRefresh) {
this.updater.startAutoRefresh(
() => this._applyTab(this.current || 'overview'),
() => {
const pane = window.overviewManager === this && document.getElementById('overview-view');
return !!(pane && pane.offsetParent);
}
);
}
}
bindEvents(root) {
@ -327,7 +341,7 @@ class OverviewManager {
`<button class="updater-btn" data-overview-action="goto" data-tab="backups">Backups</button>`)}
${card('system', u.apps, 'Apps tracked', `last scan: ${checked}`)}
</div>
${(this.updater && this.updater.updates) ? '' : `<div class="updater-hint">No scan data yet — run <strong>Check now</strong> to fetch versions, CVEs &amp; improvements.</div>`}`;
${(this.updater && this.updater.updates) ? '' : `<div class="updater-hint">No scan data yet — the first automatic scan runs within a couple of minutes, or hit <strong>Check now</strong>.</div>`}`;
}
backupSummary() {

View File

@ -27,6 +27,9 @@ LP.features.register({
// doesn't repaint a torn-down page. The page self-guards via
// (window.updaterPage === this); nulling it neutralises any pending work.
try { ctx.services.tasks.refresh && ctx.services.tasks.refresh.unregister('updater'); } catch (_) {}
// Release the auto-refresh timer that keeps the page in step with the
// host-side auto-scan.
try { window.updaterPage && window.updaterPage.dispose && window.updaterPage.dispose(); } catch (_) {}
window.updaterPage = null;
},
});

View File

@ -18,6 +18,7 @@ class UpdaterPage {
this.apps = []; // merged per-app view rendered in the table
this._pushedAnyTab = false;
this._eventBound = false;
this._poll = null; // auto-refresh timer (startAutoRefresh/dispose)
}
// ---- lifecycle -----------------------------------------------------------
@ -29,6 +30,42 @@ class UpdaterPage {
await this.refreshAll();
this.render();
this.updateHeader();
// Keep the open page in step with the host-side auto-scan (the task
// processor refreshes the generated JSON on its own schedule, with no task
// event to hook). Repaints only when the data actually changed.
this.startAutoRefresh(
() => this.render(),
() => window.updaterPage === this && !!document.getElementById('updater-page')
);
}
// The host auto-scan (`libreportal updater check auto`, run from the task
// processor's idle poll) rewrites the generated JSON without a task event,
// so an open page re-reads it on a slow timer: a few static-file GETs per
// minute, and onChange() only when a generated_at stamp moved. isActive lets
// the hosting surface skip ticks while it isn't visible; dispose() releases
// the timer (also called by the feature unmount).
startAutoRefresh(onChange, isActive) {
if (this._poll) return;
this._poll = setInterval(() => {
if (document.hidden || (isActive && !isActive())) return;
const before = this._dataStamp();
this.refreshAll().then(() => {
if (this._poll && this._dataStamp() !== before && onChange) onChange();
});
}, 60000);
}
dispose() {
if (this._poll) { clearInterval(this._poll); this._poll = null; }
}
// Cheap change detector for the auto-refresh: the generators stamp each file
// with generated_at; history has no stamp, so its newest entry stands in.
_dataStamp() {
const g = (d) => (d && d.generated_at) || '';
const h = this.history && this.history.entries && this.history.entries[0];
return [g(this.updates), g(this.cves), g(this.artifacts), (h && h.ts) || ''].join('|');
}
parseTabFromUrl() {
@ -272,7 +309,7 @@ class UpdaterPage {
${card('system', c.apps, 'Apps tracked', `last scan: ${checked}`,
`<button class="updater-btn" data-updater-action="check">Check now</button>`)}
</div>
${this.updates ? '' : `<div class="updater-hint">No scan data yet — showing your installed apps. Run <strong>Check now</strong> to fetch versions & vulnerabilities.</div>`}`;
${this.updates ? '' : `<div class="updater-hint">No scan data yet — showing your installed apps. The first automatic scan runs within a couple of minutes, or hit <strong>Check now</strong>.</div>`}`;
}
renderUpdates() {
@ -305,7 +342,7 @@ class UpdaterPage {
// withToolbar=false lets an embedding surface (the fleet Overview tab) skip
// the inline Check button because it provides one in its own header.
renderImprovements(withToolbar = true) {
if (!this.artifacts) return this.empty('No hotfix data yet. Run a check to fetch the signed improvements index.', true);
if (!this.artifacts) return this.empty('No hotfix data yet — the automatic scan fetches the signed improvements index within a couple of minutes.', true);
const list = Array.isArray(this.artifacts.artifacts) ? this.artifacts.artifacts : [];
const signed = !!this.artifacts.signed;
if (!list.length) return this.empty('No improvements available right now — you are all caught up. 🎉');
@ -339,7 +376,7 @@ class UpdaterPage {
renderSecurity() {
const withCves = this.apps.filter(a => (a.cves || []).length);
if (!this.cves) return this.empty('No vulnerability scan yet. Run a check to scan your app images for known CVEs.', true);
if (!this.cves) return this.empty('No vulnerability scan yet — one runs automatically within a couple of minutes.', true);
if (!withCves.length) return this.empty('No known vulnerabilities in your installed apps. 🎉');
const blocks = withCves.map(a => {
const items = (a.cves || []).map(c => `

View File

@ -22,6 +22,22 @@ cliHandleUpdaterCommands()
case "$sub" in
""|"check")
# `check auto` — the task processor's idle poll calls this every
# ~60s; self-throttle on the age of the generated updates.json so a
# full scan only happens once per CFG_UPDATER_SCAN_INTERVAL minutes
# (0 disables automatic scans). A manual check / post-update rescan
# rewrites updates.json, which resets this clock for free. A missing
# file means never scanned -> run now (first scan needs no click).
if [[ "$app" == "auto" ]]; then
local scan_interval="${CFG_UPDATER_SCAN_INTERVAL:-30}"
[[ "$scan_interval" =~ ^[0-9]+$ ]] || scan_interval=30
(( scan_interval == 0 )) && return 0
local scan_file="${containers_dir%/}/libreportal/frontend/data/updater/generated/updates.json"
if [[ -f "$scan_file" ]]; then
local _now _last; _now=$(date +%s); _last=$(stat -c '%Y' "$scan_file" 2>/dev/null || echo 0)
(( _now - _last < scan_interval * 60 )) && return 0
fi
fi
# Quick + safe — just regenerates the read-only data files. Source
# the generator explicitly if the lazy loader hasn't mapped it yet
# (new file; the array regen self-heals it on deploy, this covers

View File

@ -8,7 +8,9 @@ cliShowUpdaterHelp()
echo ""
echo "Available App Updater Commands:"
echo ""
echo " libreportal updater check - Refresh per-app version & vulnerability data"
echo " libreportal updater check [auto] - Refresh per-app version & vulnerability data"
echo " (auto = background mode: only scans when the data is"
echo " older than CFG_UPDATER_SCAN_INTERVAL minutes)"
echo " libreportal updater apply <app> - Update one app (snapshots it first; auto-rollback on failure)"
echo " libreportal updater apply-all [a,b] - Update a comma-list of apps (each snapshotted first)"
echo " libreportal updater rollback <app> - Restore an app's most recent pre-update snapshot"

View File

@ -459,6 +459,12 @@ maybeRegenPoll() {
# config file — so it runs every poll, self-throttled to its own interval
# (cheap no-op most ticks), writing network_status.json when a scan is due.
command -v libreportal >/dev/null 2>&1 && libreportal system network check >/dev/null 2>&1 || true
# App updater auto-scan (versions / CVEs / signed improvements). Same shape as
# the drift check: runs every poll, self-throttled inside the CLI to
# CFG_UPDATER_SCAN_INTERVAL minutes via the age of the generated updates.json,
# so almost every tick is a single stat() and an early return. Eligible signed
# hotfixes are enqueued as ordinary tasks by the scan, never applied inline.
command -v libreportal >/dev/null 2>&1 && libreportal updater check auto >/dev/null 2>&1 || true
}
# ============================================================================

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_UPDATER_CHECK|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_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)
# Validate numeric values
if ! echo "$var_value" | grep -qE '^[0-9]+$'; then
isError " $var_name must be a positive integer"

View File

@ -7,10 +7,13 @@
# frontend/data/updater/generated/cves.json (per-app known CVEs)
# frontend/data/updater/generated/history.json (created/owned by `apply`)
#
# Run on demand by `libreportal updater check` (NOT on every regen) so a slow
# registry/scanner call never stalls the periodic WebUI refresh. The WebUI
# degrades gracefully when these files are absent (it derives the app list from
# the installed-apps data), so this generator is a bonus, never a dependency.
# Run by `libreportal updater check` — manually, or automatically from the task
# processor's idle poll via `updater check auto`, which only falls through to a
# real scan when updates.json is older than CFG_UPDATER_SCAN_INTERVAL minutes.
# Kept OFF the regen path so a slow registry/scanner call never stalls the
# periodic WebUI refresh. The WebUI degrades gracefully when these files are
# absent (it derives the app list from the installed-apps data), so this
# generator is a bonus, never a dependency.
#
# Version + CVE discovery is intentionally pluggable: the loop below records
# each installed app's CURRENT image (from its compose file) and leaves clearly