diff --git a/containers/libreportal/frontend/css/admin.css b/containers/libreportal/frontend/css/admin.css index c843841..e4438a5 100644 --- a/containers/libreportal/frontend/css/admin.css +++ b/containers/libreportal/frontend/css/admin.css @@ -53,6 +53,14 @@ .admin-status-dot.warn { background: #fbbd23; } .admin-status-dot.none { background: rgba(var(--text-rgb), 0.25); } +/* Inline integrity readout (dot + label) inside a card line. */ +.admin-integrity { + display: inline-flex; + align-items: center; + gap: 6px; +} +.admin-integrity .admin-status-dot { width: 8px; height: 8px; } + .admin-card-body { display: flex; flex-direction: column; diff --git a/containers/libreportal/frontend/js/components/admin/admin-overview.js b/containers/libreportal/frontend/js/components/admin/admin-overview.js index 478218b..5cdeeb1 100644 --- a/containers/libreportal/frontend/js/components/admin/admin-overview.js +++ b/containers/libreportal/frontend/js/components/admin/admin-overview.js @@ -15,15 +15,16 @@ class AdminOverview { const r = this.root(); if (r) r.innerHTML = '
Loading…
'; this.bindEvents(); - const [upd, backup, ssh, disk, mem, info] = await Promise.all([ + 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, backup, ssh, disk, mem, info }; + this.d = { upd, verify, backup, ssh, disk, mem, info }; this.render(); } @@ -50,7 +51,18 @@ class AdminOverview { 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) { @@ -71,6 +83,44 @@ class AdminOverview { 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: 'Can’t 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 `
` + + `Integrity` + + `${this.escape(disp.label)}` + + `
`; + } + /* A status card: kind sets the dot colour (ok/warn/none). actionsHtml is the footer (Manage link / button). */ card(title, kind, lines, actionsHtml) { @@ -94,18 +144,27 @@ class AdminOverview { if (!root) return; const d = this.d || {}; - // Updates + // 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) + ? `` + : ``; + const updCard = this.card( 'Updates', - updAvail ? 'warn' : 'ok', - 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 || '—'), - updAvail && upd.can_update - ? `` - : `Nothing to do` + (updAvail || integrityBad) ? 'warn' : 'ok', + updBody, + updActions ); // Backups diff --git a/containers/libreportal/frontend/js/components/update-notifier.js b/containers/libreportal/frontend/js/components/update-notifier.js index f0d6e65..08dbdf5 100644 --- a/containers/libreportal/frontend/js/components/update-notifier.js +++ b/containers/libreportal/frontend/js/components/update-notifier.js @@ -29,9 +29,12 @@ class UpdateNotifier { if (this.fetching) return this.fetching; this.fetching = (async () => { try { - const res = await fetch('/data/system/update_status.json', { cache: 'no-store' }); - if (!res.ok) return null; - this.status = await res.json(); + 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; @@ -221,6 +224,8 @@ class UpdateNotifier { ['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.' @@ -319,6 +324,18 @@ class UpdateNotifier { 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 'Can’t verify'; + case 'development': return 'Development build'; + default: return ''; + } + } + _versionLabel() { const s = this.status; if (!s) return ''; diff --git a/scripts/cli/commands/verify/cli_verify_commands.sh b/scripts/cli/commands/verify/cli_verify_commands.sh new file mode 100644 index 0000000..d500f21 --- /dev/null +++ b/scripts/cli/commands/verify/cli_verify_commands.sh @@ -0,0 +1,65 @@ +#!/bin/bash + +# Verify Commands Handler +# Re-checks the installed code against the signed release manifest and refreshes +# the integrity status the WebUI "Updates" card reads (verify_status.json). + +cliHandleVerifyCommands() +{ + local verify_type="$initial_command2" + + case "$verify_type" in + ""|"now"|"check") + # Run the integrity check, print a human summary, and (re)write + # verify_status.json so the WebUI badge reflects the result. + cliRunVerify + ;; + "json"|"status") + # Non-interactive: just (re)write verify_status.json. This is what + # the WebUI "Verify now" button runs through the task pipeline. + webuiSystemVerify "force" + ;; + *) + isNotice "Invalid verify command: ${RED}$verify_type${NC}" + cliShowVerifyHelp + ;; + esac +} + +# Run the check (via webuiSystemVerify, which also rewrites verify_status.json +# and leaves the LP_VERIFY_* globals set) and print a readable summary. +cliRunVerify() +{ + isHeader "LibrePortal Integrity Check" + + if ! declare -f webuiSystemVerify >/dev/null 2>&1; then + isError "Verification is unavailable on this install." + return 1 + fi + webuiSystemVerify "force" + + case "$LP_VERIFY_STATE" in + verified) + isSuccessful "Verified — all ${LP_VERIFY_TOTAL} files match the signed release." + ;; + modified) + isError "Modified — ${LP_VERIFY_MODIFIED} changed, ${LP_VERIFY_MISSING} missing of ${LP_VERIFY_TOTAL} files." + if [[ -n "$LP_VERIFY_SAMPLE" ]]; then + isNotice "Affected files (sample):" + while IFS= read -r _p; do [[ -n "$_p" ]] && echo " - $_p"; done <<< "$LP_VERIFY_SAMPLE" + fi + ;; + tampered) + isError "Manifest signature invalid — ${LP_VERIFY_ERROR:-the release manifest cannot be trusted}." + ;; + unsigned) + isNotice "Files match the manifest, but it isn't signed yet (no production key) — can't fully vouch for it." + ;; + unverifiable) + isNotice "${LP_VERIFY_ERROR:-The signed manifest could not be checked.}" + ;; + *) + isNotice "Development build (${CFG_INSTALL_MODE:-git} install) — no signed manifest to verify against." + ;; + esac +} diff --git a/scripts/cli/commands/verify/cli_verify_header.sh b/scripts/cli/commands/verify/cli_verify_header.sh new file mode 100644 index 0000000..b9dc5fe --- /dev/null +++ b/scripts/cli/commands/verify/cli_verify_header.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +# Verify Commands Header +# Shows available verify commands and help information + +cliShowVerifyHelp() +{ + echo "" + echo "Available Verify Commands:" + echo "" + echo " libreportal verify - Check installed files against the signed release manifest" + echo " libreportal verify json - Refresh the integrity status file only (no console output)" + echo "" + echo "Confirms your installed LibrePortal code still matches the release we signed." + echo "This is a self-check (it runs from the same install), so for an independent" + echo "guarantee verify the release tarball yourself with 'minisign -Vm'." + echo "" +} diff --git a/scripts/release/make_release.sh b/scripts/release/make_release.sh index ceca668..f7ad265 100644 --- a/scripts/release/make_release.sh +++ b/scripts/release/make_release.sh @@ -50,7 +50,41 @@ OUT="$REPO_ROOT/dist/$CHANNEL" mkdir -p "$OUT" echo "Building $TARBALL (channel=$CHANNEL, ref=$REF) ..." -git archive --format=tar.gz --prefix="$PREFIX" -o "$OUT/$TARBALL" "$REF" + +# Build into a staging tree (not a streamed tarball) so we can drop a per-file +# integrity manifest INSIDE the release. `git archive --format=tar` still honours +# .gitattributes export-ignore, so dev-only trees stay out of the staging tree. +STAGE="$(mktemp -d)" +trap 'rm -rf "$STAGE"' EXIT +git archive --format=tar --prefix="$PREFIX" "$REF" | tar -x -C "$STAGE" + +# SHA256SUMS: one `sha256sum`-format line per shipped file (relative ./paths, +# stable sort), excluding the manifest + its signature themselves. The running +# install re-hashes against this to prove its files match the signed release +# (see lpVerifyInstall in scripts/source/verify.sh). The manifest's own trust +# comes from SHA256SUMS.minisig below, not from being in the list. +( + cd "$STAGE/$PREFIX" + find . -type f ! -name SHA256SUMS ! -name SHA256SUMS.minisig -print0 \ + | LC_ALL=C sort -z | xargs -0 sha256sum > SHA256SUMS +) +MANIFEST_FILES="$(wc -l < "$STAGE/$PREFIX/SHA256SUMS" | tr -d ' ')" + +# Sign the manifest with the same offline minisign key used for the tarball, so +# the install can verify it against the root-owned public key. Unsigned builds +# still ship SHA256SUMS (drift detection works; the badge just reports it as +# unsigned). Keep LP_MINISIGN_SECKEY on the release machine only. +if [[ -n "${LP_MINISIGN_SECKEY:-}" ]]; then + command -v minisign >/dev/null 2>&1 || { echo "make_release: LP_MINISIGN_SECKEY set but 'minisign' isn't installed" >&2; exit 1; } + minisign -Sm "$STAGE/$PREFIX/SHA256SUMS" -s "$LP_MINISIGN_SECKEY" -t "libreportal $VERSION manifest ($CHANNEL)" >/dev/null + MANIFEST_SIGNED=" ✓ SHA256SUMS.minisig (manifest signed, inside tarball)" +else + MANIFEST_SIGNED=" ℹ︎ SHA256SUMS unsigned (set LP_MINISIGN_SECKEY to sign)" +fi + +# Pack the staging tree (manifest + signature included). --sort/--owner/--group +# keep the archive deterministic for a given commit. +tar --sort=name --owner=0 --group=0 --numeric-owner -czf "$OUT/$TARBALL" -C "$STAGE" "$PREFIX" ( cd "$OUT" && sha256sum "$TARBALL" > "$TARBALL.sha256" ) SHA="$(cut -d' ' -f1 < "$OUT/$TARBALL.sha256")" @@ -82,3 +116,5 @@ echo "✓ $OUT/$TARBALL" echo "✓ $OUT/$TARBALL.sha256 ($SHA)" echo "✓ $OUT/latest.json" echo "$SIGNED" +echo " ✓ SHA256SUMS ($MANIFEST_FILES files, inside tarball)" +echo "$MANIFEST_SIGNED" diff --git a/scripts/source/files/arrays/files_cli.sh b/scripts/source/files/arrays/files_cli.sh index 9a5ecd5..ada4a0b 100755 --- a/scripts/source/files/arrays/files_cli.sh +++ b/scripts/source/files/arrays/files_cli.sh @@ -44,6 +44,8 @@ cli_scripts=( "cli/commands/update/cli_update_header.sh" "cli/commands/validation/cli_validation_commands.sh" "cli/commands/validation/cli_validation_header.sh" + "cli/commands/verify/cli_verify_commands.sh" + "cli/commands/verify/cli_verify_header.sh" "cli/commands/webui/cli_webui_commands.sh" "cli/commands/webui/cli_webui_header.sh" "cli/task/cli_task_run.sh" diff --git a/scripts/source/files/arrays/files_source.sh b/scripts/source/files/arrays/files_source.sh index 060ea5f..bc82ae9 100755 --- a/scripts/source/files/arrays/files_source.sh +++ b/scripts/source/files/arrays/files_source.sh @@ -30,5 +30,6 @@ source_scripts=( "source/files/arrays/files_webui.sh" "source/files/arrays/function_manifest.sh" "source/files/generate_function_manifest.sh" + "source/verify.sh" ) diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index dccb02c..877c11d 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.sh @@ -266,8 +266,10 @@ declare -gA LP_FN_MAP=( [cliHandleSystemCommands]="cli/commands/system/cli_system_commands.sh" [cliHandleUpdateCommands]="cli/commands/update/cli_update_commands.sh" [cliHandleValidationCommands]="cli/commands/validation/cli_validation_commands.sh" + [cliHandleVerifyCommands]="cli/commands/verify/cli_verify_commands.sh" [cliHandleWebuiCommands]="cli/commands/webui/cli_webui_commands.sh" [cliInitialize]="cli/cli_initialize.sh" + [cliRunVerify]="cli/commands/verify/cli_verify_commands.sh" [cliShowAppHelp]="cli/commands/app/cli_app_header.sh" [cliShowBackupHelp]="cli/commands/backup/cli_backup_header.sh" [cliShowConfigHelp]="cli/commands/config/cli_config_header.sh" @@ -285,6 +287,7 @@ declare -gA LP_FN_MAP=( [cliShowSystemHelp]="cli/commands/system/cli_system_header.sh" [cliShowUpdateHelp]="cli/commands/update/cli_update_header.sh" [cliShowValidationHelp]="cli/commands/validation/cli_validation_header.sh" + [cliShowVerifyHelp]="cli/commands/verify/cli_verify_header.sh" [cliShowWebuiHelp]="cli/commands/webui/cli_webui_header.sh" [cliTaskFollow]="cli/task/cli_task_run.sh" [cliTaskRun]="cli/task/cli_task_run.sh" @@ -564,6 +567,8 @@ declare -gA LP_FN_MAP=( [lpReleaseLatestFootprint]="source/fetch.sh" [lpReleaseLatestVersion]="source/fetch.sh" [_lpSha256]="source/fetch.sh" + [lpVerifyInstall]="source/verify.sh" + [lpVerifyPubKeyPath]="source/verify.sh" [lpVersionGt]="source/fetch.sh" [mainLoop]="crontab/task/crontab_task_processor.sh" [mainMenu]="menu/menu_main.sh" @@ -664,6 +669,7 @@ declare -gA LP_FN_MAP=( [prometheus_install_post_compose]="prometheus/scripts/prometheus_install_hooks.sh" [prometheus_install_post_start]="prometheus/scripts/prometheus_install_hooks.sh" [readTaskField]="crontab/task/crontab_task_processor.sh" + [reclaimDockerSpace]="cli/commands/system/cli_system_commands.sh" [reconcileConfigFile]="config/core/variables/config_scan_variables.sh" [reconcileContainersTopOwnership]="function/permission/libreportal_folders.sh" [reconcileDockerOwnership]="function/permission/libreportal_folders.sh" @@ -884,6 +890,7 @@ declare -gA LP_FN_MAP=( [webuiSystemMetrics]="webui/data/generators/system/webui_system_metrics.sh" [webuiSystemUpdate]="webui/data/generators/system/webui_system_update.sh" [webuiSystemUpdateCheck]="webui/data/generators/system/webui_system_update.sh" + [webuiSystemVerify]="webui/data/generators/system/webui_system_update.sh" [webuiUpdateAppLog]="webui/data/utils/webui_app_log.sh" [webuiUpdateAppStatus]="webui/data/generators/apps/webui_app_status.sh" [webuiUpdateSystemConfig]="webui/data/generators/config/webui_update_config.sh" @@ -1155,8 +1162,10 @@ declare -gA LP_FN_ROOT=( [cliHandleSystemCommands]="scripts" [cliHandleUpdateCommands]="scripts" [cliHandleValidationCommands]="scripts" + [cliHandleVerifyCommands]="scripts" [cliHandleWebuiCommands]="scripts" [cliInitialize]="scripts" + [cliRunVerify]="scripts" [cliShowAppHelp]="scripts" [cliShowBackupHelp]="scripts" [cliShowConfigHelp]="scripts" @@ -1174,6 +1183,7 @@ declare -gA LP_FN_ROOT=( [cliShowSystemHelp]="scripts" [cliShowUpdateHelp]="scripts" [cliShowValidationHelp]="scripts" + [cliShowVerifyHelp]="scripts" [cliShowWebuiHelp]="scripts" [cliTaskFollow]="scripts" [cliTaskRun]="scripts" @@ -1453,6 +1463,8 @@ declare -gA LP_FN_ROOT=( [lpReleaseLatestFootprint]="scripts" [lpReleaseLatestVersion]="scripts" [_lpSha256]="scripts" + [lpVerifyInstall]="scripts" + [lpVerifyPubKeyPath]="scripts" [lpVersionGt]="scripts" [mainLoop]="scripts" [mainMenu]="scripts" @@ -1553,6 +1565,7 @@ declare -gA LP_FN_ROOT=( [prometheus_install_post_compose]="containers" [prometheus_install_post_start]="containers" [readTaskField]="scripts" + [reclaimDockerSpace]="scripts" [reconcileConfigFile]="scripts" [reconcileContainersTopOwnership]="scripts" [reconcileDockerOwnership]="scripts" @@ -1773,6 +1786,7 @@ declare -gA LP_FN_ROOT=( [webuiSystemMetrics]="scripts" [webuiSystemUpdate]="scripts" [webuiSystemUpdateCheck]="scripts" + [webuiSystemVerify]="scripts" [webuiUpdateAppLog]="scripts" [webuiUpdateAppStatus]="scripts" [webuiUpdateSystemConfig]="scripts" @@ -2064,8 +2078,10 @@ cliHandleSshCommands() { source "${install_scripts_dir}cli/commands/ssh/cli_ssh_ cliHandleSystemCommands() { source "${install_scripts_dir}cli/commands/system/cli_system_commands.sh"; cliHandleSystemCommands "$@"; } cliHandleUpdateCommands() { source "${install_scripts_dir}cli/commands/update/cli_update_commands.sh"; cliHandleUpdateCommands "$@"; } cliHandleValidationCommands() { source "${install_scripts_dir}cli/commands/validation/cli_validation_commands.sh"; cliHandleValidationCommands "$@"; } +cliHandleVerifyCommands() { source "${install_scripts_dir}cli/commands/verify/cli_verify_commands.sh"; cliHandleVerifyCommands "$@"; } cliHandleWebuiCommands() { source "${install_scripts_dir}cli/commands/webui/cli_webui_commands.sh"; cliHandleWebuiCommands "$@"; } cliInitialize() { source "${install_scripts_dir}cli/cli_initialize.sh"; cliInitialize "$@"; } +cliRunVerify() { source "${install_scripts_dir}cli/commands/verify/cli_verify_commands.sh"; cliRunVerify "$@"; } cliShowAppHelp() { source "${install_scripts_dir}cli/commands/app/cli_app_header.sh"; cliShowAppHelp "$@"; } cliShowBackupHelp() { source "${install_scripts_dir}cli/commands/backup/cli_backup_header.sh"; cliShowBackupHelp "$@"; } cliShowConfigHelp() { source "${install_scripts_dir}cli/commands/config/cli_config_header.sh"; cliShowConfigHelp "$@"; } @@ -2083,6 +2099,7 @@ cliShowSshHelp() { source "${install_scripts_dir}cli/commands/ssh/cli_ssh_header cliShowSystemHelp() { source "${install_scripts_dir}cli/commands/system/cli_system_header.sh"; cliShowSystemHelp "$@"; } cliShowUpdateHelp() { source "${install_scripts_dir}cli/commands/update/cli_update_header.sh"; cliShowUpdateHelp "$@"; } cliShowValidationHelp() { source "${install_scripts_dir}cli/commands/validation/cli_validation_header.sh"; cliShowValidationHelp "$@"; } +cliShowVerifyHelp() { source "${install_scripts_dir}cli/commands/verify/cli_verify_header.sh"; cliShowVerifyHelp "$@"; } cliShowWebuiHelp() { source "${install_scripts_dir}cli/commands/webui/cli_webui_header.sh"; cliShowWebuiHelp "$@"; } cliTaskFollow() { source "${install_scripts_dir}cli/task/cli_task_run.sh"; cliTaskFollow "$@"; } cliTaskRun() { source "${install_scripts_dir}cli/task/cli_task_run.sh"; cliTaskRun "$@"; } @@ -2362,6 +2379,8 @@ lpReleaseChannel() { source "${install_scripts_dir}source/fetch.sh"; lpReleaseCh lpReleaseLatestFootprint() { source "${install_scripts_dir}source/fetch.sh"; lpReleaseLatestFootprint "$@"; } lpReleaseLatestVersion() { source "${install_scripts_dir}source/fetch.sh"; lpReleaseLatestVersion "$@"; } _lpSha256() { source "${install_scripts_dir}source/fetch.sh"; _lpSha256 "$@"; } +lpVerifyInstall() { source "${install_scripts_dir}source/verify.sh"; lpVerifyInstall "$@"; } +lpVerifyPubKeyPath() { source "${install_scripts_dir}source/verify.sh"; lpVerifyPubKeyPath "$@"; } lpVersionGt() { source "${install_scripts_dir}source/fetch.sh"; lpVersionGt "$@"; } mainLoop() { source "${install_scripts_dir}crontab/task/crontab_task_processor.sh"; mainLoop "$@"; } mainMenu() { source "${install_scripts_dir}menu/menu_main.sh"; mainMenu "$@"; } @@ -2462,6 +2481,7 @@ processBcryptPassword() { source "${install_scripts_dir}config/password/bcrypt/p prometheus_install_post_compose() { source "${install_containers_dir}prometheus/scripts/prometheus_install_hooks.sh"; prometheus_install_post_compose "$@"; } prometheus_install_post_start() { source "${install_containers_dir}prometheus/scripts/prometheus_install_hooks.sh"; prometheus_install_post_start "$@"; } readTaskField() { source "${install_scripts_dir}crontab/task/crontab_task_processor.sh"; readTaskField "$@"; } +reclaimDockerSpace() { source "${install_scripts_dir}cli/commands/system/cli_system_commands.sh"; reclaimDockerSpace "$@"; } reconcileConfigFile() { source "${install_scripts_dir}config/core/variables/config_scan_variables.sh"; reconcileConfigFile "$@"; } reconcileContainersTopOwnership() { source "${install_scripts_dir}function/permission/libreportal_folders.sh"; reconcileContainersTopOwnership "$@"; } reconcileDockerOwnership() { source "${install_scripts_dir}function/permission/libreportal_folders.sh"; reconcileDockerOwnership "$@"; } @@ -2682,6 +2702,7 @@ webuiSystemMemory() { source "${install_scripts_dir}webui/data/generators/system webuiSystemMetrics() { source "${install_scripts_dir}webui/data/generators/system/webui_system_metrics.sh"; webuiSystemMetrics "$@"; } webuiSystemUpdate() { source "${install_scripts_dir}webui/data/generators/system/webui_system_update.sh"; webuiSystemUpdate "$@"; } webuiSystemUpdateCheck() { source "${install_scripts_dir}webui/data/generators/system/webui_system_update.sh"; webuiSystemUpdateCheck "$@"; } +webuiSystemVerify() { source "${install_scripts_dir}webui/data/generators/system/webui_system_update.sh"; webuiSystemVerify "$@"; } webuiUpdateAppLog() { source "${install_scripts_dir}webui/data/utils/webui_app_log.sh"; webuiUpdateAppLog "$@"; } webuiUpdateAppStatus() { source "${install_scripts_dir}webui/data/generators/apps/webui_app_status.sh"; webuiUpdateAppStatus "$@"; } webuiUpdateSystemConfig() { source "${install_scripts_dir}webui/data/generators/config/webui_update_config.sh"; webuiUpdateSystemConfig "$@"; } diff --git a/scripts/source/verify.sh b/scripts/source/verify.sh new file mode 100644 index 0000000..df50a10 --- /dev/null +++ b/scripts/source/verify.sh @@ -0,0 +1,112 @@ +#!/bin/bash +# +# Integrity verification of the installed LibrePortal code tree. +# +# Re-hashes $script_dir against the signed SHA256SUMS manifest that ships INSIDE +# every release tarball (built by scripts/release/make_release.sh) and checks +# that manifest's minisign signature against the ROOT-OWNED public key in the +# footprint (/usr/local/lib/libreportal/libreportal.pub). A pass proves the +# installed files still match the release we published and signed. +# +# Honest scope: this runs BY the software it verifies, so it catches accidental +# drift, partial tampering, and "is this a real signed release" — but it is NOT +# tamper-proof on its own (a wholesale code swap could also fake the result). +# The hard guarantee is out-of-band: the user running `minisign -Vm `. +# +# Results land in LP_VERIFY_* globals so the generator + CLI can format them: +# LP_VERIFY_STATE verified|modified|tampered|unsigned|unverifiable|development +# LP_VERIFY_SIGNED true|false (a real, non-placeholder key signed it) +# LP_VERIFY_SIG_VALID true|false|null (null = not applicable / couldn't check) +# LP_VERIFY_TOTAL files listed in the manifest +# LP_VERIFY_OK / _MODIFIED / _MISSING +# LP_VERIFY_SAMPLE up to 5 offending relative paths, newline-separated +# LP_VERIFY_ERROR human message, or empty + +lpVerifyPubKeyPath() { echo "/usr/local/lib/libreportal/libreportal.pub"; } + +lpVerifyInstall() { + LP_VERIFY_STATE="development" + LP_VERIFY_SIGNED="false" + LP_VERIFY_SIG_VALID="null" + LP_VERIFY_TOTAL=0; LP_VERIFY_OK=0; LP_VERIFY_MODIFIED=0; LP_VERIFY_MISSING=0 + LP_VERIFY_SAMPLE="" + LP_VERIFY_ERROR="" + + local mode="${CFG_INSTALL_MODE:-release}" + local root="${script_dir%/}" + local manifest="$root/SHA256SUMS" + local sig="$root/SHA256SUMS.minisig" + local pub; pub="$(lpVerifyPubKeyPath)" + + # No manifest — a git/local dev install, or a build without one. There's + # nothing to verify against, so report a neutral "development build" rather + # than implying something is wrong. + if [[ "$mode" != "release" || ! -f "$manifest" ]]; then + LP_VERIFY_STATE="development" + return 0 + fi + + LP_VERIFY_TOTAL=$(wc -l < "$manifest" 2>/dev/null | tr -d ' ') + [[ -n "$LP_VERIFY_TOTAL" ]] || LP_VERIFY_TOTAL=0 + + # Manifest signature. The pubkey is in the root-owned footprint so the + # manager can't swap it to bless a forged manifest. A REPLACE_ME placeholder + # means signing isn't activated for this build → treat the manifest as + # unsigned (drift detection still works, we just can't vouch for it). + if [[ -f "$pub" ]] && ! grep -q REPLACE_ME "$pub" 2>/dev/null; then + LP_VERIFY_SIGNED="true" + if ! command -v minisign >/dev/null 2>&1; then + LP_VERIFY_STATE="unverifiable" + LP_VERIFY_ERROR="minisign is not installed, so the signed manifest can't be checked." + return 0 + fi + if [[ ! -f "$sig" ]]; then + LP_VERIFY_STATE="tampered" + LP_VERIFY_SIG_VALID="false" + LP_VERIFY_ERROR="The release manifest signature (SHA256SUMS.minisig) is missing." + return 0 + fi + if ! minisign -Vm "$manifest" -p "$pub" -x "$sig" >/dev/null 2>&1; then + LP_VERIFY_STATE="tampered" + LP_VERIFY_SIG_VALID="false" + LP_VERIFY_ERROR="The release manifest signature is invalid." + return 0 + fi + LP_VERIFY_SIG_VALID="true" + fi + + # Re-hash every listed file. `--check --quiet` prints only failures; each is + # either ": FAILED" (content differs) or ": FAILED open or read" + # (missing/unreadable). Lines prefixed "sha256sum:" are diagnostics/summary + # — skip them so a missing file isn't double-counted. Run from $root so the + # manifest's ./relative paths resolve. + local check_out line path sample_count=0 + check_out="$(cd "$root" && sha256sum --check --quiet "$manifest" 2>&1)" + while IFS= read -r line; do + [[ -z "$line" || "$line" == sha256sum:* ]] && continue + if [[ "$line" == *": FAILED open or read" ]]; then + LP_VERIFY_MISSING=$((LP_VERIFY_MISSING + 1)); path="${line%: FAILED open or read}" + elif [[ "$line" == *": FAILED" ]]; then + LP_VERIFY_MODIFIED=$((LP_VERIFY_MODIFIED + 1)); path="${line%: FAILED}" + else + continue + fi + if (( sample_count < 5 )); then + LP_VERIFY_SAMPLE+="${LP_VERIFY_SAMPLE:+$'\n'}${path#./}" + sample_count=$((sample_count + 1)) + fi + done <<< "$check_out" + + local bad=$((LP_VERIFY_MODIFIED + LP_VERIFY_MISSING)) + LP_VERIFY_OK=$((LP_VERIFY_TOTAL - bad)) + (( LP_VERIFY_OK < 0 )) && LP_VERIFY_OK=0 + + if (( bad > 0 )); then + LP_VERIFY_STATE="modified" + elif [[ "$LP_VERIFY_SIGNED" == "true" ]]; then + LP_VERIFY_STATE="verified" + else + LP_VERIFY_STATE="unsigned" + fi + return 0 +} diff --git a/scripts/update/check_update.sh b/scripts/update/check_update.sh index f34865c..994fb66 100755 --- a/scripts/update/check_update.sh +++ b/scripts/update/check_update.sh @@ -65,6 +65,7 @@ webuiRunUpdate() dockerInstallApp "libreportal" WEBUI_UPDATER_FORCE=1 webuiLibrePortalUpdate webuiSystemUpdateCheck "force" + webuiSystemVerify "force" isSuccessful "LibrePortal has been updated to v${lat}." return 0 fi @@ -117,6 +118,7 @@ webuiRunUpdate() # out-of-date flag. WEBUI_UPDATER_FORCE=1 webuiLibrePortalUpdate webuiSystemUpdateCheck "force" + webuiSystemVerify "force" isSuccessful "LibrePortal has been updated." } diff --git a/scripts/webui/data/generators/system/webui_system_update.sh b/scripts/webui/data/generators/system/webui_system_update.sh index 9b912a4..2bbb221 100755 --- a/scripts/webui/data/generators/system/webui_system_update.sh +++ b/scripts/webui/data/generators/system/webui_system_update.sh @@ -9,6 +9,7 @@ webuiSystemUpdate() { webuiSystemMemory webuiSystemMetrics webuiSystemUpdateCheck + webuiSystemVerify isSuccessful "System information updated!" } @@ -222,3 +223,79 @@ EOF "$current_commit" "$latest_commit" \ "$behind" "$ahead" "$branch" "git" "$fetch_error" } + +# --------------------------------------------------------------------------- +# WebUI integrity verification +# --------------------------------------------------------------------------- +# Writes frontend/data/system/verify_status.json so the Admin → Overview +# "Updates" card can show whether the installed code still matches the signed +# release manifest. The actual check is lpVerifyInstall (scripts/source/verify.sh, +# sets LP_VERIFY_* globals); this just throttles it and serialises the result. +# +# Re-hashing the whole install tree is cheap (~1s for ~1100 files) but pointless +# to redo every minute, so it's throttled like the update check. Pass "force" +# (the manual "Verify now" action and the post-update hook) to bypass the +# throttle. Non-release installs have no manifest, so lpVerifyInstall reports a +# neutral "development" state and no hashing happens. +webuiSystemVerify() { + local force_flag="$1" + + local system_dir="$containers_dir/libreportal/frontend/data/system" + local final_file="${system_dir}/verify_status.json" + local stamp_file="${system_dir}/.verify_check_stamp" + local interval="${CFG_VERIFY_CHECK_INTERVAL:-86400}" + + createFolders "quiet" "$sudo_user_name" "$system_dir" + + local install_mode="${CFG_INSTALL_MODE:-git}" + + local do_run="false" + if [[ "$force_flag" == "force" || ! -f "$final_file" || ! -f "$stamp_file" ]]; then + do_run="true" + else + local _now _last; _now=$(date +%s); _last=$(stat -c '%Y' "$stamp_file" 2>/dev/null || echo 0) + (( _now - _last >= interval )) && do_run="true" + fi + [[ "$do_run" == "true" ]] || return 0 + + declare -f lpVerifyInstall >/dev/null 2>&1 || return 0 + lpVerifyInstall + runAsManager touch "$stamp_file" 2>/dev/null || touch "$stamp_file" 2>/dev/null + + # Encode the sample-paths array (each path JSON-escaped). + local sample_json="[]" + if [[ -n "$LP_VERIFY_SAMPLE" ]]; then + local _items="" _p + while IFS= read -r _p; do + [[ -z "$_p" ]] && continue + _p=${_p//\\/\\\\}; _p=${_p//\"/\\\"} + _items+="${_items:+, }\"${_p}\"" + done <<< "$LP_VERIFY_SAMPLE" + sample_json="[${_items}]" + fi + + local version="" + [[ -f "$script_dir/VERSION" ]] && version=$(tr -d ' \t\n\r' < "$script_dir/VERSION") + + local error_json="null" + [[ -n "$LP_VERIFY_ERROR" ]] && error_json="\"${LP_VERIFY_ERROR//\"/\\\"}\"" + + local temp_file; temp_file="$(mktemp)" + cat << EOF > "$temp_file" +{ + "state": "${LP_VERIFY_STATE}", + "signed": ${LP_VERIFY_SIGNED:-false}, + "signature_valid": ${LP_VERIFY_SIG_VALID:-null}, + "files_total": ${LP_VERIFY_TOTAL:-0}, + "files_ok": ${LP_VERIFY_OK:-0}, + "files_modified": ${LP_VERIFY_MODIFIED:-0}, + "files_missing": ${LP_VERIFY_MISSING:-0}, + "modified_sample": ${sample_json}, + "version": "${version}", + "install_mode": "${install_mode}", + "error": ${error_json}, + "checked_at": "$(date -Iseconds)" +} +EOF + runFileWrite "$final_file" < "$temp_file"; rm -f "$temp_file" +}