#!/bin/bash # Manager-side storage-location management. # # Division of labour, which is the whole security story: # # root helper (libreportal-storage) owns the REGISTRY — which paths exist and # may hold app data. Manager cannot write it. # here owns the per-location CONFIG — friendly # name, enabled flag, notes. Manager-owned, # and none of it can redirect a root chown. # # The per-location config mirrors the backup-location layout exactly # (configs/backup/locations//location.config), so the WebUI config renderer, # the CLI and the sourcing path all work with no new machinery. Note the PATH is # deliberately NOT a field here: it is registry data, and changing it is # `storage add`/`remove`, not a config edit. storageLocationsDir() { printf '%s' "${configs_dir%/}/storage/locations" } storageLocationDir() { printf '%s' "$(storageLocationsDir)/$1" } storageLocationConfig() { printf '%s' "$(storageLocationDir "$1")/location.config" } # Source every per-location config so CFG_STORAGE_LOC__* are in the # environment. Called from the libreportal_configs scan path, alongside # sourceBackupLocations. sourceStorageLocations() { local dir cfg dir=$(storageLocationsDir) [[ -d "$dir" ]] || return 0 local find_cmd=(find) if [[ ! -r "$dir" || ! -x "$dir" ]] && declare -f runFileOp >/dev/null 2>&1; then find_cmd=(runFileOp find) fi while IFS= read -r -d '' cfg; do [[ -f "$cfg" ]] && source "$cfg" done < <("${find_cmd[@]}" "$dir" -mindepth 2 -maxdepth 2 -name location.config -type f -print0 2>/dev/null) } _storageWriteLocationConfig() { local id="$1" name="$2" path="$3" local dir cfg dir=$(storageLocationDir "$id") cfg=$(storageLocationConfig "$id") runInstallOp mkdir -p "$dir" { echo "# Storage location $id — added $(date -Iseconds)." echo "# The PATH is not editable here: it lives in the root-owned registry" echo "# (/usr/local/lib/libreportal/storage.roots) so that editing config can" echo "# never redirect a privileged chown. Use 'libreportal storage add/remove'." echo "CFG_STORAGE_LOC_${id}_NAME=\"${name}\" # Name - Friendly label used by CFG__STORAGE and shown in the UI" echo "CFG_STORAGE_LOC_${id}_ENABLED=true # Enabled - Offer this location when choosing where an app's data lives [true:Yes|false:No]" echo "CFG_STORAGE_LOC_${id}_PATH=\"${path}\" # Path - Where this location lives on disk (registry-owned; shown for reference) **READONLY**" echo "CFG_STORAGE_LOC_${id}_NOTES=\"\" # Notes - Free text, e.g. which physical disk this is" } | runInstallWrite "$cfg" >/dev/null # 0644, not 0640: this file holds a name, a path and free-text notes — # nothing secret — and the backup runs as its own user, which could not # read it at 0640. That made restic report "permission denied", write an # INCOMPLETE system snapshot and exit non-zero, so *every* system-config # backup failed the moment a second storage location existed — and a # first-run restore has nothing to restore without one. runInstallOp chmod 0644 "$cfg" } # Add a storage location: fitness first (so the user gets every reason at once), # then the root helper's admission checks, then the manager-side config. storageAdd() { local path="$1" name="$2" force="${3:-}" if [[ -z "$path" ]]; then isError "storageAdd requires a path" return 1 fi path="${path%/}" isHeader "Adding storage location $path" # --- fitness (§6) -------------------------------------------------------- local sev check msg refused=0 while IFS=$'\t' read -r sev check msg; do [[ -z "$sev" ]] && continue case "$sev" in refuse) isError "$check: $msg"; refused=1 ;; warn) isNotice "$check: $msg" ;; info) isNotice "$check: $msg" ;; esac done < <(storageCheckPath "$path") if (( refused )) && [[ "$force" != "--force" ]]; then isError "Refusing to add '$path' — the failures above mean app data cannot work there." return 1 fi # --- admission (root helper, §3) ---------------------------------------- local id if ! id=$(runStorage add "$path" ${name:+--name="$name"} 2>&1); then isError "$id" return 1 fi id="${id##*$'\n'}" if [[ ! "$id" =~ ^[0-9]+$ ]]; then isError "Unexpected response from the storage helper: $id" return 1 fi _storageWriteLocationConfig "$id" "${name:-location-$id}" "$path" source "$(storageLocationConfig "$id")" 2>/dev/null # Long-lived processes (the task processor) hold a memoised root list. storageCacheReset isSuccessful "Storage location $id '${name:-location-$id}' added at $path" _storageRefreshWebui echo "$id" } # Regenerate the WebUI's storage data when that generator exists. # # `declare -f X >/dev/null && X` as a function's LAST statement makes the whole # function return 1 whenever X is absent — the caller then reads a successful # operation as a failure. Same trap _appCallHook documents in app_install.sh. _storageRefreshWebui() { if declare -f webuiGenerateStorageLocations >/dev/null 2>&1; then webuiGenerateStorageLocations || true fi return 0 } # Remove a location. The helper refuses while app directories remain, and also # when the drive is absent (we would be dropping the only record of where those # apps live). storageRemove() { local want="$1" [[ -n "$want" ]] || { isError "storageRemove requires an id or path"; return 1; } local id if ! id=$(runStorage remove "$want" 2>&1); then isError "$id" return 1 fi id="${id##*$'\n'}" local dir; dir=$(storageLocationDir "$id") [[ -d "$dir" ]] && runInstallOp rm -rf "$dir" storageCacheReset isSuccessful "Storage location $id removed" _storageRefreshWebui return 0 } # Human-readable listing: id, name, path, state, apps, free space. storageList() { local id path state name_var name apps free isHeader "Storage locations" printf '%-4s %-16s %-34s %-12s %-5s %s\n' "ID" "NAME" "PATH" "STATE" "APPS" "FREE" printf '%-4s %-16s %-34s %-12s %-5s %s\n' "0" "default" "$(primaryRoot)" "ok" \ "$(storageAppsOnRoot "$(primaryRoot)" | wc -l | tr -d ' ')" \ "$(_storageFreeHuman "$(primaryRoot)")" while IFS=$'\t' read -r id state path; do [[ -z "$id" ]] && continue name_var="CFG_STORAGE_LOC_${id}_NAME" name="${!name_var:-location-$id}" if [[ "$state" == "ok" ]]; then apps=$(storageAppsOnRoot "$path" | wc -l | tr -d ' ') free=$(_storageFreeHuman "$path") else apps="?"; free="-" fi printf '%-4s %-16s %-34s %-12s %-5s %s\n' "$id" "$name" "$path" "$state" "$apps" "$free" done < <(runStorage verify 2>/dev/null) } _storageFreeHuman() { local p="$1" kb kb=$(df -Pk "$p" 2>/dev/null | awk 'NR==2 {print $4}') [[ -z "$kb" ]] && { printf '-'; return; } if (( kb >= 1048576 )); then printf '%s GiB' "$((kb / 1048576))" else printf '%s MiB' "$((kb / 1024))"; fi } # Apps living on one specific root. storageAppsOnRoot() { local root="${1%/}" d storageRootAvailable "$root" || return 0 local scan_op="" declare -F runFileOp >/dev/null 2>&1 && scan_op="runFileOp" while IFS= read -r d; do [[ -n "$d" ]] && printf '%s\n' "${d##*/}" done < <($scan_op find "$root" -mindepth 1 -maxdepth 1 -type d 2>/dev/null) }