Follow-up to 928e244, which stopped configs/ subdirectories being sourced without a .category marker. That closed the hole; this removes the thing that fell into it. storageIndexFile pointed at configs/storage/app_locations. The file's requirements are only "manager-owned" and "not on a removable disk" — configs/ satisfies both, which is why I put it there, and it was still wrong: that tree carries a third property the file violates. sourceScanFiles SOURCES what it finds under configs/, and sourcing means executing. The index is a TSV of "<slug><TAB><root>", which bash reads as a command and its argument. Harmless while no slug matched a real executable. The row for the app named `libreportal` armed it, because that IS the CLI on PATH: sourcing ran `libreportal /libreportal-containers`, which re-entered the scan, which sourced the file again — one process pair per level until the host OOMed and took the desktop session with it. It now lives at $system_dir/storage/app_locations, with a one-shot migration so an install that already has an index keeps knowing where its apps live rather than silently forgetting. libreportal-ownership reconciles the new directory, and scan_files.sh gained a note that configs/storage/ carries no .category on purpose. scripts/dev/lp-configs-guard-test covers both ends: the index never lands in configs/, a legacy one migrates, and a file of the exact detonating shape placed in an unmarked configs/ subdirectory is not executed while a marked category still loads. Also wires sourceStorageLocations into the config scan beside sourceBackupLocations — per-location configs sit at depth 3, below the generic scan, and need their own walker like the backup ones do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
204 lines
7.3 KiB
Bash
204 lines
7.3 KiB
Bash
#!/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/<idx>/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_<id>_* 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_<APP>_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
|
|
runInstallOp chmod 0640 "$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)
|
|
}
|