#!/bin/bash # Portable per-app export/import — the "one file you can hand around". # # libreportal app export [file] -> -.lpapp # libreportal app import [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 [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 " 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 [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 }