librelad 94c9e83c42 feat(backup): container-side capture of private app files
Reads files the backup user can't see from the host (container-owned, e.g.
Nextcloud's www-data data dir) by streaming them out THROUGH the container
(docker exec tar) — no host root, no host read perms, works rooted + rootless.
Extracts to staging as plain files so restic keeps full dedup + per-file
restore (not a piped tar blob); the live path is excluded from the snapshot.
Restore streams the staging copy back through a throwaway in-namespace
container that recreates the tree with the app's uid:gid.

Declared via a libreportal.backup.files compose label; Nextcloud (html, 33:33)
is the first to use it. Live capture failure falls back to stop-snapshot-start.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 18:15:53 +01:00

254 lines
10 KiB
Bash

#!/bin/bash
# LibrePortal WebUI Config Generator
# Generates apps.json from container configuration files
# Single awk pass extracting SERVICE_TAG_1..SERVICE_TAG_9 from a compose
# file. Was 9 separate tagsManagerGetTagContent forks per app — across
# 31 apps that's 279 awk forks; now 1 per app.
# Output: tab-separated values for slots 1..9 (empty for missing).
_webuiReadServiceTags() {
local file_path="$1"
awk '
BEGIN { for (i = 1; i <= 9; i++) v[i] = "" }
{
for (i = 1; i <= 9; i++) {
if (v[i] != "") continue
marker = "#LIBREPORTAL|SERVICE_TAG_" i "|"
p = index($0, marker)
if (p == 0) continue
rest = substr($0, p + length(marker))
n = match(rest, /[| \t\r]/)
if (n > 0) rest = substr(rest, 1, n - 1)
v[i] = rest
}
}
END {
for (i = 1; i <= 9; i++) printf "%s\t", v[i]
print ""
}
' "$file_path"
}
webuiGenerateLibrePortalConfig() {
local testing_mode="$1"
local specific_app="$2"
local output_file="${containers_dir}libreportal/frontend/data/apps/generated/apps.json"
local first_app=true
local app_count=0
isNotice "Generating LibrePortal apps.json from config files..."
[[ -n "$specific_app" ]] && isNotice "Filtering to specific app: $specific_app"
mkdir -p "$(dirname "$output_file")"
local temp_file="${output_file}.tmp.$$"
cat > "$temp_file" << 'EOF'
{
"apps": [
EOF
# Determine which apps to process
local apps_to_process=()
if [[ -n "$specific_app" ]]; then
if [[ -d "$install_containers_dir/$specific_app" && "$specific_app" != "template" ]]; then
apps_to_process=("$specific_app")
else
isError "App '$specific_app' not found or is template"
fi
else
while IFS= read -r -d '' dir; do
app_name="${dir##*/}"
[[ "$app_name" != "template" ]] && apps_to_process+=("$app_name")
done < <(find "$install_containers_dir" -maxdepth 1 -type d ! -path "$install_containers_dir" -print0 | sort -z)
fi
local app_name dir deployed_config_file config_file
local title description long_description category url config_vars
local host_install host_package
local var_name var_value
local APP_UPPER
for app_name in "${apps_to_process[@]}"; do
dir="$install_containers_dir/$app_name"
deployed_config_file="$containers_dir/$app_name/$app_name.config"
config_file="$dir/$app_name.config"
[[ -f "$deployed_config_file" ]] && config_file="$deployed_config_file"
[[ ! -f "$config_file" ]] && { isNotice "No config file for $app_name"; continue; }
title=""; description=""; long_description=""; category=""; url=""; config_vars=""
host_install=""; host_package=""
APP_UPPER="${app_name^^}"
# Single pass through the config file.
while IFS='=' read -r var_name var_value || [[ -n "$var_name" ]]; do
[[ -z "$var_name" || "$var_name" =~ ^[[:space:]]*# ]] && continue
[[ "$var_name" == CFG_${APP_UPPER}_* ]] || continue
# Strip surrounding quotes + \r, then trim whitespace.
var_value="${var_value#\"}"
var_value="${var_value%\"}"
var_value="${var_value//$'\r'/}"
var_value="${var_value#"${var_value%%[![:space:]]*}"}"
var_value="${var_value%"${var_value##*[![:space:]]}"}"
case "$var_name" in
"CFG_${APP_UPPER}_TITLE") title="$var_value" ;;
"CFG_${APP_UPPER}_DESCRIPTION") description="$var_value" ;;
"CFG_${APP_UPPER}_LONG_DESCRIPTION") long_description="$var_value" ;;
"CFG_${APP_UPPER}_CATEGORY") category="$var_value" ;;
"CFG_${APP_UPPER}_URL")
url="$var_value"
config_vars+="\"$var_name\": \"$var_value\","
;;
"CFG_${APP_UPPER}_HOST_INSTALL")
host_install="$var_value"
config_vars+="\"$var_name\": \"$var_value\","
;;
"CFG_${APP_UPPER}_HOST_PACKAGE")
host_package="$var_value"
config_vars+="\"$var_name\": \"$var_value\","
;;
*)
config_vars+="\"$var_name\": \"$var_value\","
;;
esac
done < "$config_file"
[[ -z "$title" || -z "$category" ]] && { isNotice "Skipping $app_name - missing essential data"; continue; }
local is_installed="false"
if [[ "$host_install" == "true" ]]; then
if [[ -n "$host_package" ]] \
&& dpkg-query -W -f='${Status}' "$host_package" 2>/dev/null | grep -q "install ok installed"; then
is_installed="true"
fi
else
[[ -d "$containers_dir/$app_name" ]] && is_installed="true"
fi
# Categories: comma-list, lowercase + trimmed via bash param expansion
# (was echo|tr|sed — 3 forks per category per app).
local mapped_category="" mapped_categories_json=""
if [[ -n "$category" ]]; then
local _first_cat="" _cats_acc=""
local _cat_arr _c
IFS=',' read -ra _cat_arr <<< "$category"
for _c in "${_cat_arr[@]}"; do
_c="${_c,,}"
_c="${_c#"${_c%%[![:space:]]*}"}"
_c="${_c%"${_c##*[![:space:]]}"}"
[[ -z "$_c" ]] && continue
[[ -z "$_first_cat" ]] && _first_cat="$_c"
[[ -n "$_cats_acc" ]] && _cats_acc+=","
_cats_acc+="\"$_c\""
done
mapped_category="$_first_cat"
mapped_categories_json="[$_cats_acc]"
fi
local full_name="$title"
[[ -n "$description" ]] && full_name="$title - $description"
full_name="${full_name//\\/\\\\}"
full_name="${full_name//\"/\\\"}"
long_description="${long_description//\\/\\\\}"
long_description="${long_description//\"/\\\"}"
local icon_file=""
if [[ -f "$dir/$app_name.svg" ]]; then
icon_file="$app_name.svg"
elif [[ -f "$dir/$app_name.png" ]]; then
icon_file="$app_name.png"
fi
if [[ -n "$icon_file" ]]; then
local icons_apps_dir="${containers_dir}libreportal/frontend/icons/apps"
mkdir -p "$icons_apps_dir"
cp "$dir/$icon_file" "$icons_apps_dir/$icon_file" 2>/dev/null
fi
# Service names — one awk pass instead of 9 grep|awk|head pipelines.
local services_json=""
local compose_file="$dir/docker-compose.yml"
if [[ -f "$compose_file" ]]; then
local _tags_line
_tags_line=$(_webuiReadServiceTags "$compose_file")
local _svc_arr _s
IFS=$'\t' read -ra _svc_arr <<< "$_tags_line"
for _s in "${_svc_arr[@]}"; do
[[ -n "$_s" ]] && services_json+="\"$_s\","
done
services_json="${services_json%,}"
fi
# Live-backup capability: the app can be snapshotted live without
# downtime only if it declares a dumpable database, or is explicitly
# marked live-safe. The app config UI uses this to hide the "live"
# strategy option where it would be unsafe.
local backup_live_capable="false"
if [[ -f "$compose_file" ]]; then
if grep -qE '^[[:space:]]*libreportal\.backup\.(db|files)[[:space:]]*:' "$compose_file" 2>/dev/null \
|| grep -qE '^[[:space:]]*libreportal\.backup\.live[[:space:]]*:[[:space:]]*["'\'']?true' "$compose_file" 2>/dev/null; then
backup_live_capable="true"
fi
fi
if [[ "$first_app" == false ]]; then
echo " }," >> "$temp_file"
fi
first_app=false
app_count=$((app_count + 1))
# Build the app's field list, then emit with correct commas in one
# pass. Eliminates the per-app `sed -i '$ s/,$//'` (31 forks across
# the catalog) that was needed to undo a speculative trailing
# comma when longDescription was absent.
local fields=()
fields+=(" \"name\": \"$full_name\"")
fields+=(" \"command\": \"libreportal app install $app_name\"")
fields+=(" \"category\": \"$mapped_category\"")
[[ -n "$mapped_categories_json" ]] && fields+=(" \"categories\": $mapped_categories_json")
fields+=(" \"installed\": $is_installed")
fields+=(" \"backup_live_capable\": $backup_live_capable")
[[ -n "$url" ]] && fields+=(" \"url\": \"$url\"")
[[ -n "$description" ]] && fields+=(" \"description\": \"$description\"")
[[ -n "$icon_file" ]] && fields+=(" \"icon\": \"icons/apps/$icon_file\"")
[[ -n "$services_json" ]] && fields+=(" \"services\": [$services_json]")
if [[ -n "$config_vars" ]]; then
fields+=(" \"config\": {"$'\n'"${config_vars%,}"$'\n'" }")
fi
[[ -n "$long_description" ]] && fields+=(" \"longDescription\": \"$long_description\"")
{
echo " {"
local _n=${#fields[@]}
local _i
for _i in "${!fields[@]}"; do
if [[ $_i -lt $((_n - 1)) ]]; then
echo "${fields[$_i]},"
else
echo "${fields[$_i]}"
fi
done
} >> "$temp_file"
done
echo " }" >> "$temp_file"
echo " ]" >> "$temp_file"
echo "}" >> "$temp_file"
# Already root via start.sh — drop redundant sudo.
if [ $? -eq 0 ]; then
mv "$temp_file" "$output_file"
createTouch "$output_file" "$docker_install_user" "silent"
else
rm -f "$temp_file" 2>/dev/null
fi
if [[ "$testing_mode" == "test" ]]; then
isSuccessful "Generated apps.json (test mode, $app_count apps)"
else
isSuccessful "Generated apps.json ($app_count apps)"
fi
}