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>
This commit is contained in:
parent
86f84a62d3
commit
fa47e16cab
@ -3,6 +3,7 @@
|
|||||||
# ================================================================================
|
# ================================================================================
|
||||||
CFG_UPDATER_CHECK=60 # Update Check Interval - Hours between system update checks
|
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_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_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_PASS_LENGTH=14 # Password Length - Length for auto generated passwords
|
||||||
CFG_GENERATED_USER_LENGTH=8 # Username Length - Length for auto generated usernames
|
CFG_GENERATED_USER_LENGTH=8 # Username Length - Length for auto generated usernames
|
||||||
|
|||||||
@ -123,6 +123,9 @@ LP.features.register({
|
|||||||
// overviewManager singleton + its DOM persist with the layout; its run()
|
// overviewManager singleton + its DOM persist with the layout; its run()
|
||||||
// self-guards, but unregistering is the clean release.
|
// self-guards, but unregistering is the clean release.
|
||||||
try { ctx && ctx.services.tasks.refresh && ctx.services.tasks.refresh.unregister('overview'); } catch (_) {}
|
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 +
|
// The Backups tab embeds a BackupPage; release its document listeners +
|
||||||
// task-refresh registration on the way out (the "stacks on revisit" bug).
|
// task-refresh registration on the way out (the "stacks on revisit" bug).
|
||||||
try { window.overviewBackupPage && window.overviewBackupPage.dispose && window.overviewBackupPage.dispose(); } catch (_) {}
|
try { window.overviewBackupPage && window.overviewBackupPage.dispose && window.overviewBackupPage.dispose(); } catch (_) {}
|
||||||
|
|||||||
@ -53,6 +53,20 @@ class OverviewManager {
|
|||||||
const initial = this.parseTabFromUrl() || 'overview';
|
const initial = this.parseTabFromUrl() || 'overview';
|
||||||
await this.refreshAll();
|
await this.refreshAll();
|
||||||
this._applyTab(initial); // active + header + render, no history push
|
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) {
|
bindEvents(root) {
|
||||||
@ -327,7 +341,7 @@ class OverviewManager {
|
|||||||
`<button class="updater-btn" data-overview-action="goto" data-tab="backups">Backups</button>`)}
|
`<button class="updater-btn" data-overview-action="goto" data-tab="backups">Backups</button>`)}
|
||||||
${card('system', u.apps, 'Apps tracked', `last scan: ${checked}`)}
|
${card('system', u.apps, 'Apps tracked', `last scan: ${checked}`)}
|
||||||
</div>
|
</div>
|
||||||
${(this.updater && this.updater.updates) ? '' : `<div class="updater-hint">No scan data yet — run <strong>Check now</strong> to fetch versions, CVEs & 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() {
|
backupSummary() {
|
||||||
|
|||||||
@ -27,6 +27,9 @@ LP.features.register({
|
|||||||
// doesn't repaint a torn-down page. The page self-guards via
|
// doesn't repaint a torn-down page. The page self-guards via
|
||||||
// (window.updaterPage === this); nulling it neutralises any pending work.
|
// (window.updaterPage === this); nulling it neutralises any pending work.
|
||||||
try { ctx.services.tasks.refresh && ctx.services.tasks.refresh.unregister('updater'); } catch (_) {}
|
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;
|
window.updaterPage = null;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -18,6 +18,7 @@ class UpdaterPage {
|
|||||||
this.apps = []; // merged per-app view rendered in the table
|
this.apps = []; // merged per-app view rendered in the table
|
||||||
this._pushedAnyTab = false;
|
this._pushedAnyTab = false;
|
||||||
this._eventBound = false;
|
this._eventBound = false;
|
||||||
|
this._poll = null; // auto-refresh timer (startAutoRefresh/dispose)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- lifecycle -----------------------------------------------------------
|
// ---- lifecycle -----------------------------------------------------------
|
||||||
@ -29,6 +30,42 @@ class UpdaterPage {
|
|||||||
await this.refreshAll();
|
await this.refreshAll();
|
||||||
this.render();
|
this.render();
|
||||||
this.updateHeader();
|
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() {
|
parseTabFromUrl() {
|
||||||
@ -272,7 +309,7 @@ class UpdaterPage {
|
|||||||
${card('system', c.apps, 'Apps tracked', `last scan: ${checked}`,
|
${card('system', c.apps, 'Apps tracked', `last scan: ${checked}`,
|
||||||
`<button class="updater-btn" data-updater-action="check">Check now</button>`)}
|
`<button class="updater-btn" data-updater-action="check">Check now</button>`)}
|
||||||
</div>
|
</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() {
|
renderUpdates() {
|
||||||
@ -305,7 +342,7 @@ class UpdaterPage {
|
|||||||
// withToolbar=false lets an embedding surface (the fleet Overview tab) skip
|
// withToolbar=false lets an embedding surface (the fleet Overview tab) skip
|
||||||
// the inline Check button because it provides one in its own header.
|
// the inline Check button because it provides one in its own header.
|
||||||
renderImprovements(withToolbar = true) {
|
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 list = Array.isArray(this.artifacts.artifacts) ? this.artifacts.artifacts : [];
|
||||||
const signed = !!this.artifacts.signed;
|
const signed = !!this.artifacts.signed;
|
||||||
if (!list.length) return this.empty('No improvements available right now — you are all caught up. 🎉');
|
if (!list.length) return this.empty('No improvements available right now — you are all caught up. 🎉');
|
||||||
@ -339,7 +376,7 @@ class UpdaterPage {
|
|||||||
|
|
||||||
renderSecurity() {
|
renderSecurity() {
|
||||||
const withCves = this.apps.filter(a => (a.cves || []).length);
|
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. 🎉');
|
if (!withCves.length) return this.empty('No known vulnerabilities in your installed apps. 🎉');
|
||||||
const blocks = withCves.map(a => {
|
const blocks = withCves.map(a => {
|
||||||
const items = (a.cves || []).map(c => `
|
const items = (a.cves || []).map(c => `
|
||||||
|
|||||||
@ -22,6 +22,22 @@ cliHandleUpdaterCommands()
|
|||||||
|
|
||||||
case "$sub" in
|
case "$sub" in
|
||||||
""|"check")
|
""|"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
|
# Quick + safe — just regenerates the read-only data files. Source
|
||||||
# the generator explicitly if the lazy loader hasn't mapped it yet
|
# the generator explicitly if the lazy loader hasn't mapped it yet
|
||||||
# (new file; the array regen self-heals it on deploy, this covers
|
# (new file; the array regen self-heals it on deploy, this covers
|
||||||
|
|||||||
@ -8,7 +8,9 @@ cliShowUpdaterHelp()
|
|||||||
echo ""
|
echo ""
|
||||||
echo "Available App Updater Commands:"
|
echo "Available App Updater Commands:"
|
||||||
echo ""
|
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 <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 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"
|
echo " libreportal updater rollback <app> - Restore an app's most recent pre-update snapshot"
|
||||||
|
|||||||
@ -459,6 +459,12 @@ maybeRegenPoll() {
|
|||||||
# config file — so it runs every poll, self-throttled to its own interval
|
# 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.
|
# (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
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@ -72,7 +72,7 @@ webuiValidateConfigValue() {
|
|||||||
isError " Invalid crontab format for $var_name"
|
isError " Invalid crontab format for $var_name"
|
||||||
fi
|
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
|
# Validate numeric values
|
||||||
if ! echo "$var_value" | grep -qE '^[0-9]+$'; then
|
if ! echo "$var_value" | grep -qE '^[0-9]+$'; then
|
||||||
isError " $var_name must be a positive integer"
|
isError " $var_name must be a positive integer"
|
||||||
|
|||||||
@ -7,10 +7,13 @@
|
|||||||
# frontend/data/updater/generated/cves.json (per-app known CVEs)
|
# frontend/data/updater/generated/cves.json (per-app known CVEs)
|
||||||
# frontend/data/updater/generated/history.json (created/owned by `apply`)
|
# frontend/data/updater/generated/history.json (created/owned by `apply`)
|
||||||
#
|
#
|
||||||
# Run on demand by `libreportal updater check` (NOT on every regen) so a slow
|
# Run by `libreportal updater check` — manually, or automatically from the task
|
||||||
# registry/scanner call never stalls the periodic WebUI refresh. The WebUI
|
# processor's idle poll via `updater check auto`, which only falls through to a
|
||||||
# degrades gracefully when these files are absent (it derives the app list from
|
# real scan when updates.json is older than CFG_UPDATER_SCAN_INTERVAL minutes.
|
||||||
# the installed-apps data), so this generator is a bonus, never a dependency.
|
# 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
|
# Version + CVE discovery is intentionally pluggable: the loop below records
|
||||||
# each installed app's CURRENT image (from its compose file) and leaves clearly
|
# each installed app's CURRENT image (from its compose file) and leaves clearly
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user