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 = '
';
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"
+}