diff --git a/containers/libreportal/frontend/components/apps/services/js/services-manager.js b/containers/libreportal/frontend/components/apps/services/js/services-manager.js
index 7194a97..eb96380 100644
--- a/containers/libreportal/frontend/components/apps/services/js/services-manager.js
+++ b/containers/libreportal/frontend/components/apps/services/js/services-manager.js
@@ -86,7 +86,13 @@ class ServicesManager {
}
_titleBlock(appName) {
- const display = (appName || '').replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
+ // Prefer the app's real title over a title-cased slug: getAppDisplayName
+ // reads it from window.apps, so "rocketchat" reads "Rocket.Chat" rather
+ // than "Rocketchat", and "ipinfo"/"speedtest" get "IPinfo"/"LibreSpeed".
+ // The slug casing stays as the fallback for when window.apps has not
+ // loaded yet.
+ const display = (window.getAppDisplayName ? window.getAppDisplayName(appName) : '')
+ || (appName || '').replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
const adv = window.LpUi?.advanced?.get() ? 'checked' : '';
// The toggle is the visible surface for the global "Advanced UI" mode
// ([[window.LpUi.advanced]]). Flipping it here unhides the rich
diff --git a/containers/libreportal/frontend/components/apps/tools/js/tools-manager.js b/containers/libreportal/frontend/components/apps/tools/js/tools-manager.js
index 975d9c7..2cd1120 100644
--- a/containers/libreportal/frontend/components/apps/tools/js/tools-manager.js
+++ b/containers/libreportal/frontend/components/apps/tools/js/tools-manager.js
@@ -357,7 +357,13 @@ class ToolsManager {
unload() { /* no timers/streams */ }
_titleBlock(appName) {
- const display = (appName || '').replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
+ // Prefer the app's real title over a title-cased slug: getAppDisplayName
+ // reads it from window.apps, so "rocketchat" reads "Rocket.Chat" rather
+ // than "Rocketchat", and "ipinfo"/"speedtest" get "IPinfo"/"LibreSpeed".
+ // The slug casing stays as the fallback for when window.apps has not
+ // loaded yet.
+ const display = (window.getAppDisplayName ? window.getAppDisplayName(appName) : '')
+ || (appName || '').replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
return `
🧰 Tools
diff --git a/scripts/backup/engine/restic_restore.sh b/scripts/backup/engine/restic_restore.sh
index 8dc44cf..ebed588 100644
--- a/scripts/backup/engine/restic_restore.sh
+++ b/scripts/backup/engine/restic_restore.sh
@@ -1,5 +1,65 @@
#!/bin/bash
+# Build the `unshare` prefix that lets a NON-ROOT restic recreate the container
+# uids a snapshot recorded.
+#
+# Why this is needed: backups run as the docker install user (runBackupOp — the
+# backup engine never gets root). A non-root restic cannot chown a restored file
+# to anyone else, so every file came back owned by that user. For LibrePortal's
+# own files that is correct; for the ones a CONTAINER owns it is fatal. Under
+# rootless, a container process running as uid N appears on the host as
+# subuid_start + N - 1 (prometheus' nobody -> 296605, postgres -> 231141), and an
+# app whose data dir is no longer owned by its own uid does not start:
+# prometheus dies on "open data/queries.active: permission denied", and postgres
+# refuses outright unless its data dir is 0700 and its own. Restores therefore
+# handed back apps that could not boot.
+#
+# The fix needs no new privilege. The docker install user already owns a subuid
+# range (that is what makes rootless work), so it may enter a user namespace in
+# which it is root and those subuids are mappable. Mapping them to THEMSELVES
+# means an id recorded in the snapshot is written back as the same host id.
+#
+# Files recorded as the docker install user's own uid are the one gap: that uid
+# is outside the subuid range and is already consumed by the inner-root mapping,
+# so restic's lchown for them fails with EINVAL. It is harmless — restic runs as
+# inner root, which IS that user on the host, so those files already land with
+# exactly the right owner. resticRestoreErrorsAreBenign below is what keeps that
+# from being reported as a failed restore.
+_resticUsernsPrefix()
+{
+ local usr="${docker_install_user:-dockerinstall}"
+ command -v unshare >/dev/null 2>&1 || return 0
+
+ local uline gline ustart ucount gstart gcount
+ uline=$(grep "^${usr}:" /etc/subuid 2>/dev/null | head -1)
+ gline=$(grep "^${usr}:" /etc/subgid 2>/dev/null | head -1)
+ # No subuid range (rooted mode, or a hand-rolled account) — nothing to map,
+ # so leave the call exactly as it was rather than guess.
+ [[ -n "$uline" && -n "$gline" ]] || return 0
+
+ ustart="${uline#*:}"; ustart="${ustart%%:*}"; ucount="${uline##*:}"
+ gstart="${gline#*:}"; gstart="${gstart%%:*}"; gcount="${gline##*:}"
+ [[ "$ustart" =~ ^[0-9]+$ && "$ucount" =~ ^[0-9]+$ ]] || return 0
+ [[ "$gstart" =~ ^[0-9]+$ && "$gcount" =~ ^[0-9]+$ ]] || return 0
+
+ printf '%s\n' unshare --map-root-user \
+ "--map-users=${ustart}:${ustart}:${ucount}" \
+ "--map-groups=${gstart}:${gstart}:${gcount}"
+}
+
+# True when every error restic reported is the expected "cannot map the backup
+# user's own uid" one described above. Anything else — a missing pack, a full
+# disk, a permission problem on the target — must still fail the restore.
+resticRestoreErrorsAreBenign()
+{
+ local out="$1"
+ local bad
+ # Every line restic prints for a failed ownership set, minus the benign form.
+ bad=$(printf '%s\n' "$out" | grep -E "^ignoring error for " \
+ | grep -vE "lchown .*: (invalid argument|operation not permitted)$")
+ [[ -z "$bad" ]]
+}
+
resticRestoreSnapshot()
{
local idx="$1"
@@ -20,8 +80,25 @@ resticRestoreSnapshot()
[[ -n "$include_path" ]] && args+=(--include "$include_path")
isNotice "Restoring ${snapshot_id:0:8} from $(resticLocationName "$idx") → $target_dir"
- runBackupOp restic "${args[@]}"
- local rc=$?
+
+ local ns_prefix=()
+ mapfile -t ns_prefix < <(_resticUsernsPrefix)
+
+ # Output is captured (not streamed) so the benign-error check below can read
+ # it; it is echoed straight back afterwards, so the operator sees the same
+ # restic report as before.
+ local out rc
+ out=$(runBackupOp "${ns_prefix[@]}" restic "${args[@]}" 2>&1)
+ rc=$?
+ printf '%s\n' "$out"
+
+ # restic exits non-zero for the un-mappable-uid lchowns even though the files
+ # themselves landed correctly. Only forgive that exact case.
+ if [[ $rc -ne 0 && ${#ns_prefix[@]} -gt 0 ]] && resticRestoreErrorsAreBenign "$out"; then
+ isNotice "Restore reported ownership warnings for LibrePortal's own files — expected, they are already owned correctly."
+ rc=0
+ fi
+
resticEnvUnset
return $rc
}
diff --git a/scripts/restore/restore_app_start.sh b/scripts/restore/restore_app_start.sh
index 5e3dcf0..28c01fe 100644
--- a/scripts/restore/restore_app_start.sh
+++ b/scripts/restore/restore_app_start.sh
@@ -78,7 +78,13 @@ restoreAppStart()
isError "Restore failed — leaving app in stopped state"
return 1
fi
- runFileOp chown -R "$docker_install_user":"$docker_install_user" "$containers_dir$stored_app_name"
+ # NO blanket chown here. The snapshot's ownership is the thing being restored:
+ # container-owned data (postgres, mongo, prometheus' store) must come back
+ # under the container's uid or the app will not boot. resticRestoreSnapshot
+ # now recreates those uids faithfully, and this line could only ever undo
+ # that — it used to run as the docker install user, so against a correctly
+ # restored tree it just fails file by file, and where it did "work" it was
+ # cementing the ownership that broke the app.
((menu_number++))
echo ""
diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh
index 2a5e080..4187e54 100644
--- a/scripts/source/files/arrays/function_manifest.sh
+++ b/scripts/source/files/arrays/function_manifest.sh
@@ -853,6 +853,7 @@ declare -gA LP_FN_MAP=(
[resticLocationUri]="backup/engine/restic_env.sh"
[resticNextFreeIndex]="backup/engine/restic_env.sh"
[resticRestoreAppLatest]="backup/engine/restic_restore.sh"
+ [resticRestoreErrorsAreBenign]="backup/engine/restic_restore.sh"
[resticRestoreSnapshot]="backup/engine/restic_restore.sh"
[resticRestoreSystemLatest]="backup/engine/restic_restore.sh"
[resticRetentionFor]="backup/engine/restic_forget.sh"
@@ -860,6 +861,7 @@ declare -gA LP_FN_MAP=(
[resticSnapshotListFiles]="backup/engine/restic_snapshots.sh"
[resticSnapshotsJson]="backup/engine/restic_snapshots.sh"
[resticSystemSnapshotsJson]="backup/engine/restic_snapshots.sh"
+ [_resticUsernsPrefix]="backup/engine/restic_restore.sh"
[restoreAppRunHook]="restore/restore_app_hooks.sh"
[restoreAppStart]="restore/restore_app_start.sh"
[restoreDbRehydratePreStart]="backup/db/backup_db.sh"
@@ -1976,6 +1978,7 @@ declare -gA LP_FN_ROOT=(
[resticLocationUri]="scripts"
[resticNextFreeIndex]="scripts"
[resticRestoreAppLatest]="scripts"
+ [resticRestoreErrorsAreBenign]="scripts"
[resticRestoreSnapshot]="scripts"
[resticRestoreSystemLatest]="scripts"
[resticRetentionFor]="scripts"
@@ -1983,6 +1986,7 @@ declare -gA LP_FN_ROOT=(
[resticSnapshotListFiles]="scripts"
[resticSnapshotsJson]="scripts"
[resticSystemSnapshotsJson]="scripts"
+ [_resticUsernsPrefix]="scripts"
[restoreAppRunHook]="scripts"
[restoreAppStart]="scripts"
[restoreDbRehydratePreStart]="scripts"
@@ -3134,6 +3138,7 @@ resticLocationType() { unset -f resticLocationType; __lpAutoload "${install_scri
resticLocationUri() { unset -f resticLocationUri; __lpAutoload "${install_scripts_dir}backup/engine/restic_env.sh"; resticLocationUri "$@"; }
resticNextFreeIndex() { unset -f resticNextFreeIndex; __lpAutoload "${install_scripts_dir}backup/engine/restic_env.sh"; resticNextFreeIndex "$@"; }
resticRestoreAppLatest() { unset -f resticRestoreAppLatest; __lpAutoload "${install_scripts_dir}backup/engine/restic_restore.sh"; resticRestoreAppLatest "$@"; }
+resticRestoreErrorsAreBenign() { unset -f resticRestoreErrorsAreBenign; __lpAutoload "${install_scripts_dir}backup/engine/restic_restore.sh"; resticRestoreErrorsAreBenign "$@"; }
resticRestoreSnapshot() { unset -f resticRestoreSnapshot; __lpAutoload "${install_scripts_dir}backup/engine/restic_restore.sh"; resticRestoreSnapshot "$@"; }
resticRestoreSystemLatest() { unset -f resticRestoreSystemLatest; __lpAutoload "${install_scripts_dir}backup/engine/restic_restore.sh"; resticRestoreSystemLatest "$@"; }
resticRetentionFor() { unset -f resticRetentionFor; __lpAutoload "${install_scripts_dir}backup/engine/restic_forget.sh"; resticRetentionFor "$@"; }
@@ -3141,6 +3146,7 @@ resticSnapshotLatestId() { unset -f resticSnapshotLatestId; __lpAutoload "${inst
resticSnapshotListFiles() { unset -f resticSnapshotListFiles; __lpAutoload "${install_scripts_dir}backup/engine/restic_snapshots.sh"; resticSnapshotListFiles "$@"; }
resticSnapshotsJson() { unset -f resticSnapshotsJson; __lpAutoload "${install_scripts_dir}backup/engine/restic_snapshots.sh"; resticSnapshotsJson "$@"; }
resticSystemSnapshotsJson() { unset -f resticSystemSnapshotsJson; __lpAutoload "${install_scripts_dir}backup/engine/restic_snapshots.sh"; resticSystemSnapshotsJson "$@"; }
+_resticUsernsPrefix() { unset -f _resticUsernsPrefix; __lpAutoload "${install_scripts_dir}backup/engine/restic_restore.sh"; _resticUsernsPrefix "$@"; }
restoreAppRunHook() { unset -f restoreAppRunHook; __lpAutoload "${install_scripts_dir}restore/restore_app_hooks.sh"; restoreAppRunHook "$@"; }
restoreAppStart() { unset -f restoreAppStart; __lpAutoload "${install_scripts_dir}restore/restore_app_start.sh"; restoreAppStart "$@"; }
restoreDbRehydratePreStart() { unset -f restoreDbRehydratePreStart; __lpAutoload "${install_scripts_dir}backup/db/backup_db.sh"; restoreDbRehydratePreStart "$@"; }