The installer's restore path printed "Settings restored" and had never restored
a setting. backupRestoreSystemConfig only STAGES — right in general, since
overwriting a running control plane's config should not be automatic — but
nothing ever adopted the staged tree. The backup locations, domains and logins
landed in $restore_dir/system-config and stayed there.
So adoption is its own step now (`restore adopt`), allowed only on a machine
with nothing on it yet. backup/locations/ is adopted as a subtree, since the
index is part of the path and that directory is the whole point: it holds every
repository and its credentials, which is what makes "one password unlocks the
rest" true. Deliberately NOT adopted: the container account and its generated
password, port allocations, docker/rootless wiring, and storage/locations —
those describe the old machine, and a registry of drives this box does not have
would make every placement lookup resolve to a phantom.
The guard failed in the shape this project keeps hitting. It globbed the
containers directory, but the manager can traverse that without listing it, so
the glob returned a literal '*', the loop skipped it, and the function returned
"first run" on a machine with three apps. It adopted over a live install in
testing. It now asks the container user for the listing and fails closed: an
unreadable directory means "in use", never "empty".
Two config modes were inverted, found because a restore cannot restore from a
snapshot that was never taken:
- storage location configs were 0640 and hold no secrets. The backup runs as
the container user, could not read them, and restic wrote an INCOMPLETE
snapshot and exited 3 — so EVERY system-config backup failed once a second
storage location existed. Now 0644, with the test asserting they stay
secret-free so that mode remains defensible.
- backup location configs were 0644 and hold the repository password; nobody
could read them. They cannot simply be tightened, because the backup has to
read the credentials it uses — so the directory carries the restriction
(config-secure, manager:container 0750) and the file stays readable to the
two accounts that belong.
Fixing that surfaced a third: config-adopt clamped existing parent directories
to manager:manager 0750, closing configs/backup to the container user and
breaking the credential read the directory fix had just preserved.
restore domains reports which restored domains point here, and the installer
offers to drop the strays. Three verdicts, not two: setupCheckDomainPointsHere
falls back to hostname -I, and comparing a public A record to a private 10.x
address would condemn every correctly-pointed domain on a LAN-only box, which
is the deployment this product targets. Unverifiable is never offered for
deletion.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
210 lines
7.7 KiB
Bash
210 lines
7.7 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
|
|
# 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)
|
|
}
|