librelad b28268a61f feat(system): "Verified" integrity check against the signed release manifest
Adds per-file integrity attestation on top of the existing signed-tarball
release flow. make_release now generates a SHA256SUMS manifest over the shipped
tree and (when a key is configured) signs it, riding both inside the release
tarball so they land in the install tree with no extra download.

lpVerifyInstall (scripts/source/verify.sh) re-hashes the install tree against
that manifest and verifies the manifest's minisign signature against the
root-owned footprint pubkey, yielding states: verified / modified / tampered /
unsigned / unverifiable / development. webuiSystemVerify writes verify_status.json
(throttled daily, force on demand, also after each update apply), surfaced as an
Integrity line + "Verify now" button on the Admin → Overview Updates card and a
row in the update details panel. `libreportal verify` exposes the same check on
the CLI.

Honest framing: this is a self-check (run by the software it verifies), so red
fires only for genuine modified/tampered states; the badge tooltip points to
out-of-band `minisign -Vm` for an independent guarantee.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-28 19:41:22 +01:00

369 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Update Notifier
// -----------------------------------------------------------------------------
// Surfaces LibrePortal's "out of date" state across the WebUI:
// * a persistent badge in the global topbar (visible on every page), and
// * a banner on the dashboard,
// both driven by /data/system/update_status.json (written host-side by
// webuiSystemUpdateCheck — see scripts/webui/data/generators/system/).
//
// The two actions both go through the normal task pipeline so the user can
// watch them stream on the Tasks page, exactly like an app install:
// * "Update now" -> task `libreportal update apply`
// * "Check for updates" -> task `libreportal update check`
//
// This file owns no detection logic of its own. When get.libreportal.org is
// wired up, only the host-side generator changes; this stays as-is.
class UpdateNotifier {
constructor() {
this.status = null;
this.fetching = null; // de-dupe concurrent fetches
this.pollMs = 5 * 60 * 1000; // re-read the status file every 5 min
this.pollTimer = null;
this.started = false;
}
// ---- data ----------------------------------------------------------------
async fetchStatus() {
if (this.fetching) return this.fetching;
this.fetching = (async () => {
try {
const [s, v] = await Promise.all([
fetch('/data/system/update_status.json', { cache: 'no-store' }).then(r => r.ok ? r.json() : null).catch(() => null),
fetch('/data/system/verify_status.json', { cache: 'no-store' }).then(r => r.ok ? r.json() : null).catch(() => null),
]);
if (s !== null) this.status = s; // keep last-good on a failed fetch
if (v !== null) this.verify = v;
return this.status;
} catch {
return null;
} finally {
this.fetching = null;
}
})();
return this.fetching;
}
async refresh() {
await this.fetchStatus();
this.renderTopbarBadge();
this.renderDashboardBanner();
}
// ---- lifecycle -----------------------------------------------------------
start() {
if (this.started) return;
this.started = true;
this.refresh();
// The topbar HTML and this script load independently; if the topbar
// mounted first, the authoritative onTopbarReady() call already no-op'd.
// Briefly retry until .topbar-controls exists so the badge appears on
// first load regardless of which won the race.
let tries = 0;
const ensure = setInterval(() => {
if (document.querySelector('.topbar-controls')) { this.renderTopbarBadge(); clearInterval(ensure); }
else if (++tries > 30) clearInterval(ensure); // ~15s ceiling
}, 500);
if (this.pollTimer) clearInterval(this.pollTimer);
this.pollTimer = setInterval(() => this.refresh(), this.pollMs);
// Re-read the status as soon as an update/check task finishes so the badge
// clears (or the version updates) without waiting for the next poll.
const onTask = (event) => {
const cmd = event?.detail?.command || event?.detail?.task?.command || '';
const action = event?.detail?.action;
if (/^libreportal update\b/.test(cmd) || action === 'update') {
// Give the host a beat to finish writing update_status.json.
setTimeout(() => this.refresh(), 1500);
}
};
window.addEventListener('taskCompleted', onTask);
window.addEventListener('taskUpdated', onTask);
}
// Called by TopbarComponent.init() once the topbar DOM exists.
onTopbarReady() {
this.renderTopbarBadge();
this.refresh();
}
// ---- topbar badge --------------------------------------------------------
renderTopbarBadge() {
const controls = document.querySelector('.topbar-controls');
if (!controls) return;
let badge = document.getElementById('update-badge');
const show = this.status && this.status.update_available === true;
if (!show) {
if (badge) badge.remove();
return;
}
if (!badge) {
badge = document.createElement('button');
badge.id = 'update-badge';
badge.className = 'update-badge';
badge.type = 'button';
badge.addEventListener('click', () => this.openPanel());
controls.insertBefore(badge, controls.firstChild);
}
const to = this._versionLabel();
badge.title = `Update available${to ? ' — ' + to : ''}`;
badge.setAttribute('aria-label', badge.title);
badge.innerHTML = `
<span class="update-badge-dot" aria-hidden="true"></span>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M21 2v6h-6"></path>
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
<path d="M3 22v-6h6"></path>
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path>
</svg>
<span class="update-badge-text">Update</span>`;
}
// ---- dashboard banner ----------------------------------------------------
renderDashboardBanner() {
const main = document.querySelector('.dashboard-main');
if (!main) return; // not on the dashboard
let banner = document.getElementById('update-banner');
const s = this.status;
// No status file yet (fresh install before the first check) — show nothing.
if (!s) {
if (banner) banner.remove();
return;
}
if (!banner) {
banner = document.createElement('div');
banner.id = 'update-banner';
main.insertBefore(banner, main.firstChild);
}
const refreshIcon = `
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 2v6h-6"></path>
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
<path d="M3 22v-6h6"></path>
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path>
</svg>`;
const checkIcon = `
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
<polyline points="22 4 12 14.01 9 11.01"></polyline>
</svg>`;
if (s.update_available) {
// Prominent: an update is waiting.
const versionLine = this._versionLabel() || `${s.behind} update${s.behind === 1 ? '' : 's'} behind`;
const behindLine = s.behind > 0 ? `${s.behind} commit${s.behind === 1 ? '' : 's'} behind` : '';
banner.className = 'update-banner';
banner.innerHTML = `
<div class="update-banner-icon" aria-hidden="true">${refreshIcon}</div>
<div class="update-banner-text">
<div class="update-banner-title">A LibrePortal update is available</div>
<div class="update-banner-sub">${this._escape(versionLine)}${behindLine ? ` &middot; ${this._escape(behindLine)}` : ''}</div>
</div>
<div class="update-banner-actions">
<button type="button" class="update-btn-secondary" id="update-banner-details">Details</button>
${s.can_update ? '<button type="button" class="update-btn-primary" id="update-banner-update">Update now</button>' : ''}
</div>`;
} else {
// Subtle: up to date (or a local install). Still gives a version readout
// and a manual "Check for updates" entry point.
const local = s.source === 'local' || s.install_mode === 'local';
const verText = (s.current_version && s.current_version !== 'unknown') ? `LibrePortal v${s.current_version}` : 'LibrePortal';
const subText = local ? 'Local installation — updates are managed manually' : 'Up to date';
banner.className = 'update-banner update-banner-ok';
banner.innerHTML = `
<div class="update-banner-icon" aria-hidden="true">${checkIcon}</div>
<div class="update-banner-text">
<div class="update-banner-title">${this._escape(verText)}</div>
<div class="update-banner-sub">${this._escape(subText)}</div>
</div>
<div class="update-banner-actions">
<button type="button" class="update-btn-secondary" id="update-banner-details">Details</button>
${local ? '' : '<button type="button" class="update-btn-secondary" id="update-banner-check">Check for updates</button>'}
</div>`;
}
const details = banner.querySelector('#update-banner-details');
if (details) details.addEventListener('click', () => this.openPanel());
const updateBtn = banner.querySelector('#update-banner-update');
if (updateBtn) updateBtn.addEventListener('click', () => this.runUpdate());
const checkBtn = banner.querySelector('#update-banner-check');
if (checkBtn) checkBtn.addEventListener('click', () => this.checkNow());
}
// ---- details panel (self-contained modal) --------------------------------
openPanel() {
this.closePanel();
const s = this.status || {};
const overlay = document.createElement('div');
overlay.id = 'update-panel-overlay';
overlay.className = 'update-panel-overlay';
overlay.addEventListener('click', (e) => { if (e.target === overlay) this.closePanel(); });
const local = s.source === 'local' || s.install_mode === 'local';
const rows = [
['Installed version', s.current_version || 'unknown'],
['Latest version', s.latest_version || 'unknown'],
['Commits behind', (s.behind ?? 0).toString()],
['Branch', s.branch || '—'],
['Last checked', this._formatTime(s.checked_at)],
];
const integrity = this._integrityLabel();
if (integrity) rows.splice(2, 0, ['Integrity', integrity]);
const statusLine = local
? 'This is a local installation — updates are managed manually on the host.'
: (s.update_available
? 'An update is available.'
: 'LibrePortal is up to date.');
overlay.innerHTML = `
<div class="update-panel" role="dialog" aria-modal="true" aria-label="LibrePortal updates">
<div class="update-panel-header">
<h3>LibrePortal Updates</h3>
<button type="button" class="update-panel-close" id="update-panel-close" aria-label="Close">&times;</button>
</div>
<div class="update-panel-status ${s.update_available ? 'is-outdated' : ''}">${this._escape(statusLine)}</div>
${s.error ? `<div class="update-panel-error">${this._escape(s.error)}</div>` : ''}
<dl class="update-panel-rows">
${rows.map(([k, v]) => `<div class="update-panel-row"><dt>${this._escape(k)}</dt><dd>${this._escape(v)}</dd></div>`).join('')}
</dl>
${s.can_update && s.update_available ? '<p class="update-panel-note">Updating backs up your configuration, pulls the latest version, and restarts LibrePortal. Progress streams on the Tasks page.</p>' : ''}
<div class="update-panel-actions">
<button type="button" class="update-btn-secondary" id="update-panel-check">Check for updates</button>
${s.can_update && s.update_available ? '<button type="button" class="update-btn-primary" id="update-panel-update">Update now</button>' : ''}
</div>
</div>`;
document.body.appendChild(overlay);
overlay.querySelector('#update-panel-close').addEventListener('click', () => this.closePanel());
overlay.querySelector('#update-panel-check').addEventListener('click', () => this.checkNow());
const upd = overlay.querySelector('#update-panel-update');
if (upd) upd.addEventListener('click', () => this.runUpdate());
this._escHandler = (e) => { if (e.key === 'Escape') this.closePanel(); };
document.addEventListener('keydown', this._escHandler);
}
closePanel() {
const overlay = document.getElementById('update-panel-overlay');
if (overlay) overlay.remove();
if (this._escHandler) {
document.removeEventListener('keydown', this._escHandler);
this._escHandler = null;
}
}
// ---- actions -------------------------------------------------------------
async runUpdate() {
this.closePanel();
try {
await this._createTask('libreportal update apply');
this._toast('LibrePortal update started — follow progress on the Tasks page.', 'info');
this._goToTasks();
} catch (e) {
this._toast('Could not start the update: ' + e.message, 'error');
}
}
async checkNow() {
try {
await this._createTask('libreportal update check');
this._toast('Checking for updates…', 'info');
// The check task rewrites update_status.json; refresh shortly after.
setTimeout(() => this.refresh(), 4000);
} catch (e) {
this._toast('Could not check for updates: ' + e.message, 'error');
}
}
// ---- helpers -------------------------------------------------------------
async _createTask(command) {
if (window.tasksManager?.taskManager?.createTask) {
return window.tasksManager.taskManager.createTask(command, 'update', null, '');
}
if (typeof TaskManager !== 'undefined') {
return new TaskManager().createTask(command, 'update', null, '');
}
const res = await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command, type: 'update', app: null, config: '' })
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
_goToTasks() {
if (window.librePortalSPA?.navigate) window.librePortalSPA.navigate('/tasks');
else if (typeof navigateToRoute === 'function') navigateToRoute('/tasks');
else window.location.href = '/tasks';
}
_toast(message, type = 'info') {
const ns = window.notificationSystem || window.ensureNotificationSystem?.();
if (ns?.show) ns.show(message, type);
else console.log(`[update] ${message}`);
}
_integrityLabel() {
switch (this.verify && this.verify.state) {
case 'verified': return 'Verified · matches signed release';
case 'modified': return `Modified (${this.verify.files_modified || 0} changed)`;
case 'tampered': return 'Signature invalid';
case 'unsigned': return 'Unsigned build';
case 'unverifiable': return 'Cant verify';
case 'development': return 'Development build';
default: return '';
}
}
_versionLabel() {
const s = this.status;
if (!s) return '';
const cur = s.current_version, latest = s.latest_version;
if (cur && latest && cur !== latest && latest !== 'unknown') return `v${cur} → v${latest}`;
if (latest && latest !== 'unknown') return `v${latest}`;
return '';
}
_formatTime(iso) {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d.getTime())) return '—';
return d.toLocaleString();
}
_escape(str) {
return String(str).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
}
window.updateNotifier = window.updateNotifier || new UpdateNotifier();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => window.updateNotifier.start());
} else {
window.updateNotifier.start();
}