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

262 lines
12 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.

// Admin Overview — the Admin area's landing page. An ops/health board (distinct
// from the user Dashboard, which is app-centric): it summarises updates,
// backups, SSH/security and system health from data we already generate, and
// each card links to where you act on it. Renders into #config-section.
class AdminOverview {
constructor(rootId = 'config-section') {
this.rootId = rootId;
this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null;
this._bound = false;
}
root() { return document.getElementById(this.rootId); }
async init() {
const r = this.root();
if (r) r.innerHTML = '<div class="admin-page"><div class="backup-empty-state">Loading…</div></div>';
this.bindEvents();
const [upd, verify, backup, ssh, disk, mem, info] = await Promise.all([
this.fetchJson('/data/system/update_status.json'),
this.fetchJson('/data/system/verify_status.json'),
this.fetchJson('/data/backup/generated/dashboard.json'),
this.fetchJson('/data/ssh/access.json'),
this.fetchJson('/data/system/disk_usage.json'),
this.fetchJson('/data/system/memory_usage.json'),
this.fetchJson('/data/system/system_info.json')
]);
this.d = { upd, verify, backup, ssh, disk, mem, info };
this.render();
}
async fetchJson(url) {
try { const r = await fetch(`${url}?t=${Date.now()}`); if (!r.ok) return null; return await r.json(); }
catch { return null; }
}
escape(s) {
return String(s ?? '').replace(/[&<>"']/g, c => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
notify(msg, type) {
if (window.notificationSystem) window.notificationSystem.show(msg, type || 'info');
else console.log(`[admin ${type || 'info'}] ${msg}`);
}
bindEvents() {
if (this._bound) return;
this._bound = true;
document.addEventListener('click', (e) => {
const go = e.target.closest('[data-admin-go]');
if (go) { this.go(go.dataset.adminGo); return; }
if (e.target.closest('[data-admin-update]')) { this.runUpdate(); return; }
if (e.target.closest('[data-admin-verify]')) { this.runVerify(); return; }
});
// When a verify (or update) task finishes, re-read the integrity status
// and re-render so the badge reflects reality without a manual reload.
const onTask = (ev) => {
const cmd = ev?.detail?.command || ev?.detail?.task?.command || '';
if (/^libreportal (verify|update)\b/.test(cmd)) {
setTimeout(() => this.refreshVerify(), 1500);
}
};
window.addEventListener('taskCompleted', onTask);
window.addEventListener('taskUpdated', onTask);
}
go(where) {
if (where === 'backup') {
window.spaClean?.navigate('/backup', true);
} else if (where === 'ssh' || where === 'security' || where === 'system') {
const target = where === 'ssh' ? 'ssh-access' : where;
window.history.pushState({}, '', window.adminPath(target));
window.configCategory = target;
window.configManager?.renderConfig?.(target);
}
}
async runUpdate() {
if (!this.taskManager) { this.notify('Task system unavailable', 'error'); return; }
if (!confirm('Update LibrePortal now? The WebUI may restart briefly.')) return;
try { await this.taskManager.createTask('libreportal update apply', 'update', null); }
catch (e) { this.notify(`Failed to start update: ${e.message || e}`, 'error'); }
}
async runVerify() {
if (!this.taskManager) { this.notify('Task system unavailable', 'error'); return; }
try {
await this.taskManager.createTask('libreportal verify', 'verify', null);
this.notify('Verifying installation…', 'info');
} catch (e) { this.notify(`Failed to start verification: ${e.message || e}`, 'error'); }
}
async refreshVerify() {
const verify = await this.fetchJson('/data/system/verify_status.json');
if (this.d) { this.d.verify = verify; this.render(); }
}
/* Map verify_status.json → { kind (dot colour), label, note }. Red (warn)
only for genuine problems (modified/tampered); everything else neutral. */
verifyDisplay(v) {
switch (v && v.state) {
case 'verified': return { kind: 'ok', label: 'Verified', note: 'Files match the signed release' };
case 'modified': return { kind: 'warn', label: 'Modified', note: `${v.files_modified || 0} changed, ${v.files_missing || 0} missing` };
case 'tampered': return { kind: 'warn', label: 'Signature invalid', note: v.error || 'Manifest signature failed' };
case 'unsigned': return { kind: 'none', label: 'Unsigned build', note: 'Matches an unsigned manifest' };
case 'unverifiable': return { kind: 'none', label: 'Cant verify', note: v.error || 'minisign unavailable' };
case 'development': return { kind: 'none', label: 'Development build', note: 'No signed manifest to check' };
default: return null;
}
}
// Integrity readout line: a coloured dot + label, with an honest tooltip
// about the limits of a self-check.
integrityLine(disp) {
const tip = 'Confirms your installed files match the signed release manifest. '
+ 'This is a self-check — for an independent guarantee, verify the release with `minisign -Vm`.';
return `<div class="admin-card-line" title="${this.escape(tip)}">`
+ `<span>Integrity</span>`
+ `<strong><span class="admin-integrity"><span class="admin-status-dot ${disp.kind}"></span>${this.escape(disp.label)}</span></strong>`
+ `</div>`;
}
/* A status card: kind sets the dot colour (ok/warn/none). actionsHtml is
the footer (Manage link / button). */
card(title, kind, lines, actionsHtml) {
return `
<div class="admin-card">
<div class="admin-card-head">
<span class="admin-card-title">${this.escape(title)}</span>
<span class="admin-status-dot ${kind}"></span>
</div>
<div class="admin-card-body">${lines}</div>
<div class="admin-card-actions">${actionsHtml || ''}</div>
</div>`;
}
line(label, value) {
return `<div class="admin-card-line"><span>${this.escape(label)}</span><strong>${this.escape(value)}</strong></div>`;
}
render() {
const root = this.root();
if (!root) return;
const d = this.d || {};
// Updates (+ integrity)
const upd = d.upd || {};
const updAvail = upd.update_available === true;
const vDisp = this.verifyDisplay(d.verify);
const integrityBad = vDisp && vDisp.kind === 'warn';
let updBody = updAvail
? this.line('Status', 'Update available') + this.line('Current → latest', `${upd.current_version || '?'}${upd.latest_version || '?'}`)
: this.line('Status', 'Up to date') + this.line('Version', upd.current_version || '—');
if (vDisp) updBody += this.integrityLine(vDisp);
// Update now takes priority when one's available; otherwise offer Verify now.
const updActions = (updAvail && upd.can_update)
? `<button type="button" class="backup-primary-btn" data-admin-update>Update now</button>`
: `<button type="button" class="backup-secondary-btn" data-admin-verify>Verify now</button>`;
const updCard = this.card(
'Updates',
(updAvail || integrityBad) ? 'warn' : 'ok',
updBody,
updActions
);
// Backups
const b = d.backup || {};
const apps = Array.isArray(b.apps) ? b.apps : [];
const locs = Array.isArray(b.locations) ? b.locations : [];
const protectedApps = apps.filter(a => a.latest_snapshot).length;
const totalSize = locs.reduce((a, r) => a + (parseInt(r.total_size_bytes) || 0), 0);
const noBackups = apps.length && protectedApps === 0;
const backupCard = this.card(
'Backups',
!locs.length ? 'none' : (noBackups ? 'warn' : 'ok'),
this.line('Apps protected', `${protectedApps} / ${apps.length}`)
+ this.line('Locations', String(locs.length))
+ this.line('Stored', this.bytes(totalSize)),
`<button type="button" class="backup-secondary-btn" data-admin-go="backup">Manage backups →</button>`
);
// SSH & Security
const s = d.ssh || {};
const keyCount = Array.isArray(s.keys) ? s.keys.length : 0;
const pwOn = s.password_auth === true;
// Warn when password login is on AND no keys (the weakest posture).
const sshKind = (pwOn && keyCount === 0) ? 'warn' : 'ok';
const sshCard = this.card(
'SSH & Security',
sshKind,
this.line('Password login', pwOn ? 'On' : 'Key-only')
+ this.line('Authorized keys', String(keyCount))
+ this.line('Login user', s.user || '—'),
`<button type="button" class="backup-secondary-btn" data-admin-go="ssh">Manage SSH access →</button>`
);
// System health
const disk = d.disk?.root || {};
const mem = d.mem || {};
const info = d.info || {};
const diskPct = parseInt(disk.percent) || 0;
const sysKind = diskPct >= 90 ? 'warn' : 'ok';
const sysCard = this.card(
'System',
sysKind,
this.line('Disk', disk.text || `${diskPct}%`)
+ this.line('Memory', mem.total
? `${this.gb(mem.used)} / ${this.gb(mem.total)} (${Math.round(mem.percent) || 0}%)`
: (mem.text || '—'))
+ this.line('Uptime', this.shortUptime(info.uptime)),
`<button type="button" class="backup-secondary-btn" data-admin-go="system">View system stats →</button>`
);
root.innerHTML = `
<div class="admin-page">
<div class="page-header config-page-header">
<div class="page-header-title">
<div class="admin-breadcrumb">Admin</div>
<h1>Overview</h1>
<p>System health and admin status at a glance. Manage anything from the cards below.</p>
</div>
</div>
<div class="admin-card-grid">
${updCard}
${backupCard}
${sshCard}
${sysCard}
</div>
</div>`;
}
bytes(n) {
n = parseInt(n) || 0;
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return `${n.toFixed(i ? 1 : 0)} ${u[i]}`;
}
// Compact gigabytes, e.g. 2030043136 → "1.9G", matching the Disk row's style.
gb(n) {
return `${((parseInt(n) || 0) / 1073741824).toFixed(1)}G`;
}
// "up 1 hour, 11 minutes" → "1h 11m".
shortUptime(u) {
return (u || '—')
.replace(/^up\s+/, '')
.replace(/\s*weeks?/g, 'w')
.replace(/\s*days?/g, 'd')
.replace(/\s*hours?/g, 'h')
.replace(/\s*minutes?/g, 'm')
.replace(/,/g, '');
}
}
window.AdminOverview = AdminOverview;