LibrePortal/scripts/app/app_portable.sh
librelad 1381b052ae fix(app): make export/import actually work — found by a real round trip
Ran export -> uninstall -> import on trivy against the live install. It
worked end to end (1.3G app, marker file byte-identical afterwards,
container running, database status correct, tree owned by the container
user) but only after three real bugs, none of which syntax checks or
isolated tests would have caught.

Export wrote the tarball as the CONTAINER user, because tar has to read
app data holding sub-UIDs the manager cannot. That meant the container
user also had to be able to create the destination file, which fails for
any normal destination. Now tar writes to stdout and the caller's shell
creates the file: reading uses the privileges that need it, writing uses
the caller's. Import had the mirror-image bug — tar extracted as the
container user and so could not READ a manager-owned .lpapp; the caller
now opens it and tar reads stdin.

Export also failed at tar time with no hint that the destination was the
problem, so it checks the directory exists and is writable up front.

The third one was quiet and worse. The manifest is pretty-printed, so it
reads `"size_bytes": 1324973614` — with a space that a `"key":[0-9]*`
pattern does not match. Both size_bytes and storage.location came back
empty everywhere they were read, which turned "will it fit" and "does
that location still exist" into checks that always passed. That is the
failure mode preflight exists to prevent, hiding inside preflight itself.
Fixed in app_portable.sh and restore_preflight.sh.

Verified afterwards with crafted manifests: an app claiming 8 TB is now
refused on an 800 GB disk ("Needs 8192G, 806G free"), and one naming a
location this machine lacks warns and names the fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 11:33:52 +01:00

354 lines
14 KiB
Bash

#!/bin/bash
# Portable per-app export/import — the "one file you can hand around".
#
# libreportal app export <app> [file] -> <app>-<date>.lpapp
# libreportal app import <file> [newname]
#
# WHAT THIS IS NOT
#
# Not a backup. A restic/borg/kopia repository gives you deduplication, history,
# retention and encryption; this gives you one tarball of one app at one moment.
# It exists because "email me that app" and "keep a copy before I break this" are
# real jobs that a repository answers badly — and because a single file is what
# people picture when they say "the backup file".
#
# The format is deliberately boring: gzipped tar of the app directory, with its
# .libreportal-manifest.json at the root. That manifest already records the
# compose hash, images, volumes, size, databases and storage location, so import
# gets the same reconciliation the restore preflight does, for free.
#
# The app is stopped for the duration of an export. A tar of a running Postgres
# is a corrupt Postgres, and a file that looks fine until you restore it is
# worse than a refusal.
lpAppExtension="lpapp"
appExport()
{
local app="$1"
local out="$2"
if [[ -z "$app" ]]; then
isError "Usage: app export <app_name> [file]"
return 1
fi
local dir
if ! dir=$(appDir "$app"); then
# appDir only refuses for an unmounted location; anything else is the
# app simply not being here, and saying "not mounted" for a typo sends
# people looking at their disks.
if [[ -d "$(primaryRoot)/$app" ]]; then
isError "$app is on a storage location that is not mounted."
else
isError "$app is not installed."
fi
return 1
fi
if [[ ! -d "$dir" ]]; then
isError "$app is not installed."
return 1
fi
[[ -z "$out" ]] && out="$(pwd)/${app}-$(date +%Y%m%d-%H%M%S).${lpAppExtension}"
case "$out" in */) out="${out}${app}-$(date +%Y%m%d-%H%M%S).${lpAppExtension}" ;; esac
if [[ -e "$out" ]]; then
isError "'$out' already exists — refusing to overwrite it."
return 1
fi
# Check up front rather than failing at tar time: the error there is a bare
# non-zero exit with no hint that the destination was the problem.
local out_dir="${out%/*}"; [[ -z "$out_dir" ]] && out_dir="."
if [[ ! -d "$out_dir" ]]; then
isError "'$out_dir' does not exist."
return 1
fi
if [[ ! -w "$out_dir" ]]; then
isError "Cannot write to '$out_dir' — pick somewhere $(id -un) can write."
return 1
fi
isHeader "Export $app"
# Refresh the manifest first: it is what makes the file self-describing, and
# a stale one would describe a different app than the tar contains.
declare -f manifestWrite >/dev/null 2>&1 && manifestWrite "$app" >/dev/null 2>&1
local was_running=0
if declare -f dockerComposeDown >/dev/null 2>&1; then
if runFileOp docker compose -f "$dir/docker-compose.yml" ps -q 2>/dev/null | grep -q .; then
was_running=1
fi
isNotice "Stopping $app — a copy taken while it runs can be corrupt."
dockerComposeDown "$app" >/dev/null 2>&1 || true
fi
isNotice "Writing $out"
# Split the privileges deliberately: tar READS as the container user, because
# app data holds sub-UIDs the manager cannot read — but it writes to STDOUT,
# and the destination file is created by this shell, as whoever ran the
# command. Writing directly from tar meant the container user had to be able
# to create the file too, which fails for any normal destination.
#
# --numeric-owner so container uids survive the round trip rather than being
# remapped through this machine's /etc/passwd.
if ! runFileOp tar --numeric-owner -C "${dir%/*}" -czf - "$app" > "$out" 2>/dev/null; then
isError "Export failed."
rm -f "$out"
(( was_running )) && dockerComposeUp "$app" >/dev/null 2>&1
return 1
fi
chmod 0640 "$out" 2>/dev/null
if (( was_running )); then
isNotice "Starting $app again."
dockerComposeUp "$app" >/dev/null 2>&1 || true
fi
local size; size=$(du -h "$out" 2>/dev/null | awk '{print $1}')
isSuccessful "Exported $app to $out (${size:-?})"
isNotice "This is a copy, not a backup — no history, no retention, and not encrypted."
echo "$out"
}
# Read the manifest out of an export without unpacking the whole thing.
appImportManifest()
{
local file="$1"
tar -xzOf "$file" --wildcards '*/.libreportal-manifest.json' 2>/dev/null | head -c 65536
}
# The app name an export contains, taken from the tar's top-level directory
# rather than the filename — the filename is whatever someone renamed it to.
appImportName()
{
local file="$1"
tar -tzf "$file" 2>/dev/null | head -1 | cut -d/ -f1
}
# Inspect one or more exports WITHOUT importing anything, and report what would
# happen. This is what lets the WebUI ask for acceptance before it acts: the
# same checks appImport makes, run early and rendered as a list.
#
# A .lpapp is a plain tarball — not encrypted — so unlike a backup repository
# there is no password to collect, and nothing secret has to cross from the
# browser to the host. That is the whole reason this can live in the WebUI when
# the repository restore cannot.
#
# Emits one JSON object per line: {"file","app","size_bytes","verdict","detail"}
# verdict is ok | warn | refuse.
appImportCheck()
{
local target="$1"
if [[ -z "$target" ]]; then
isError "Usage: app import-check <file-or-directory>"
return 1
fi
local -a files=()
if [[ -d "$target" ]]; then
while IFS= read -r f; do [[ -n "$f" ]] && files+=("$f"); done \
< <(find "$target" -maxdepth 1 -type f -name "*.${lpAppExtension}" 2>/dev/null | sort)
elif [[ -f "$target" ]]; then
files=("$target")
else
printf '{"file":"%s","app":"","size_bytes":0,"verdict":"refuse","detail":"No such file or directory"}\n' "$(_lpJsonStr "$target")"
return 1
fi
if (( ${#files[@]} == 0 )); then
printf '{"file":"%s","app":"","size_bytes":0,"verdict":"refuse","detail":"No .%s files found there"}\n' \
"$(_lpJsonStr "$target")" "$lpAppExtension"
return 1
fi
local f app manifest size_bytes loc dir verdict detail
for f in "${files[@]}"; do
verdict="ok"; detail="ready to import"
app=$(appImportName "$f")
if [[ -z "$app" || ! "$app" =~ ^[A-Za-z0-9._-]+$ ]]; then
_lpImportRow "$f" "" 0 refuse "Not a LibrePortal export"
continue
fi
# Squeeze whitespace first. The manifest is PRETTY-PRINTED, so the real
# text is `"size_bytes": 1324973614` — with a space that a
# `"key":[0-9]*` pattern does not match. Both the size and the location
# silently came back empty, which turned the "will it fit" and "does
# that location exist" checks into no-ops that always passed.
manifest=$(appImportManifest "$f" | tr -d ' \n\t')
size_bytes=$(printf '%s' "$manifest" | grep -o '"size_bytes":[0-9]*' | head -1 | cut -d: -f2)
loc=$(printf '%s' "$manifest" | grep -o '"location":"[^"]*"' | head -1 | cut -d'"' -f4)
[[ "$size_bytes" =~ ^[0-9]+$ ]] || size_bytes=0
if [[ ! -f "${install_containers_dir%/}/$app/$app.config" ]]; then
_lpImportRow "$f" "$app" "$size_bytes" refuse "This version no longer ships $app"
continue
fi
if ! dir=$(appDir "$app" 2>/dev/null); then
_lpImportRow "$f" "$app" "$size_bytes" refuse "Its storage location is not mounted"
continue
fi
if [[ -d "$dir" ]]; then
_lpImportRow "$f" "$app" "$size_bytes" refuse "$app is already installed — uninstall it first"
continue
fi
if (( size_bytes > 0 )); then
local need_kb=$(( size_bytes / 1024 )) avail_kb
avail_kb=$(df -Pk "${dir%/*}" 2>/dev/null | awk 'NR==2 {print $4}')
if [[ -n "$avail_kb" ]] && (( avail_kb < need_kb )); then
_lpImportRow "$f" "$app" "$size_bytes" refuse \
"Needs $(( need_kb / 1048576 ))G, $(( avail_kb / 1048576 ))G free"
continue
fi
fi
if [[ -n "$loc" && "$loc" != "default" && "$loc" != "primary" ]] \
&& ! storageLocationPath "$loc" >/dev/null 2>&1; then
verdict="warn"
detail="came from location \"$loc\", which this machine does not have — will use $(storageLocationName "${dir%/*}")"
fi
_lpImportRow "$f" "$app" "$size_bytes" "$verdict" "$detail"
done
return 0
}
# Same check, published where the WebUI can read it. The CLI prints one JSON
# object per line (easy to pipe); the WebUI wants one document, so this wraps
# them and writes it beside the other generated data.
appImportCheckPublish()
{
local target="$1"
local out_dir="$(webuiDir)/frontend/data/system"
local out_file="$out_dir/import_check.json"
createFolders "quiet" "$sudo_user_name" "$out_dir"
local tmp; tmp=$(mktemp) || return 1
{
printf '{\n "path": "%s",\n "checked": "%s",\n "results": [\n' \
"$(_lpJsonStr "$target")" "$(date -Iseconds)"
local first=1 line
while IFS= read -r line; do
[[ -z "$line" ]] && continue
(( first )) || printf ',\n'
first=0
printf ' %s' "$line"
done < <(appImportCheck "$target" 2>/dev/null)
printf '\n ]\n}\n'
} > "$tmp"
runFileWrite "$out_file" < "$tmp"
rm -f "$tmp"
return 0
}
_lpJsonStr()
{
local s="$1"
s="${s//\\/\\\\}"; s="${s//\"/\\\"}"
s="${s//$'\t'/ }"; s="${s//$'\n'/ }"; s="${s//$'\r'/}"
printf '%s' "$s"
}
_lpImportRow()
{
printf '{"file":"%s","app":"%s","size_bytes":%s,"verdict":"%s","detail":"%s"}\n' \
"$(_lpJsonStr "$1")" "$(_lpJsonStr "$2")" "${3:-0}" "$4" "$(_lpJsonStr "$5")"
}
appImport()
{
local file="$1"
local as_name="$2"
if [[ -z "$file" || ! -f "$file" ]]; then
isError "Usage: app import <file.${lpAppExtension}> [new_name]"
return 1
fi
isHeader "Import $file"
local app
app=$(appImportName "$file")
if [[ -z "$app" || ! "$app" =~ ^[A-Za-z0-9._-]+$ ]]; then
isError "'$file' does not look like a LibrePortal export."
return 1
fi
[[ -n "$as_name" ]] && {
isError "Importing under a different name is not supported yet — the app's config namespace (CFG_${app^^}_*) and compose identities would all need rewriting. Use 'libreportal instance create' for a second copy."
return 1
}
# --- the same checks the restore preflight makes -------------------------
local manifest size_bytes loc
# Whitespace-squeezed: the manifest is pretty-printed (see appImportCheck).
manifest=$(appImportManifest "$file" | tr -d ' \n\t')
size_bytes=$(printf '%s' "$manifest" | grep -o '"size_bytes":[0-9]*' | head -1 | cut -d: -f2)
loc=$(printf '%s' "$manifest" | grep -o '"location":"[^"]*"' | head -1 | cut -d'"' -f4)
if [[ ! -f "${install_containers_dir%/}/$app/$app.config" ]]; then
isError "This version of LibrePortal does not ship '$app' — it would restore into something that cannot start."
return 1
fi
local dir
if ! dir=$(appDir "$app"); then
isError "The storage location for '$app' is not mounted."
return 1
fi
if [[ -d "$dir" ]]; then
isError "'$app' is already installed at $dir — uninstall it first, or export it before overwriting."
return 1
fi
if [[ -n "$loc" && "$loc" != "default" && "$loc" != "primary" ]] \
&& ! storageLocationPath "$loc" >/dev/null 2>&1; then
isNotice "Came from storage location '$loc', which this machine does not have — using $(storageLocationName "${dir%/*}")."
fi
if [[ -n "$size_bytes" && "$size_bytes" =~ ^[0-9]+$ ]]; then
local need_kb=$(( size_bytes / 1024 )) avail_kb
avail_kb=$(df -Pk "${dir%/*}" 2>/dev/null | awk 'NR==2 {print $4}')
if [[ -n "$avail_kb" ]] && (( avail_kb < need_kb )); then
isError "Needs $(( need_kb / 1048576 ))G, only $(( avail_kb / 1048576 ))G free at ${dir%/*}."
return 1
fi
fi
# --- unpack ---------------------------------------------------------------
isNotice "Unpacking into $dir"
runFileOp mkdir -p "${dir%/*}"
# Mirror of the export split: the caller's shell opens the .lpapp (it may sit
# anywhere the user can read), and tar EXTRACTS as the container user so the
# unpacked tree lands with the right ownership. Passing the path to tar
# instead required the container user to be able to read the file, which
# fails for any file the user created themselves.
if ! runFileOp tar --numeric-owner -xzf - -C "${dir%/*}" < "$file" 2>/dev/null; then
isError "Unpack failed — removing the partial directory."
runFileOp rm -rf "$dir"
return 1
fi
# Ownership, then the normal install pipeline: the compose still carries the
# SOURCE machine's ports, IPs and domains, and re-running the pipeline is
# what re-allocates them for this one.
declare -f runOwnership >/dev/null 2>&1 && runOwnership app-perms >/dev/null 2>&1
isNotice "Wiring $app into this machine (ports, IPs, domains)."
dockerConfigSetupToContainer "silent" "$app"
initializeAppVariables "$app"
declare -f migrateUrlRewrite >/dev/null 2>&1 && migrateUrlRewrite "$app" >/dev/null 2>&1
dockerComposeUpdateAndStartApp "$app" install
dockerComposeUp "$app"
declare -f databaseInstallApp >/dev/null 2>&1 && databaseInstallApp "$app"
isSuccessful "$app imported and started"
return 0
}