Exhaustive audit (workflow: 19 finders + adversarial per-file verify; 85 raw -> 66 unique -> 39 confirmed) found 36 direct writes into the container-owned tree that bypass runFileOp/runFileWrite/runCfgOp (manager => EACCES in rootless) plus 3 $?-masking sites. Fixes by area: - apps: grafana + prometheus install hooks (sudo chmod -> runFileOp chmod); gluetun provider etag (tee -> runFileWrite). - webui generators: task-create (10 sites: mkdir/chown/tee/jq|tee/sed|tee -> runFileOp/runFileWrite); app-icons (mkdir/cp/mv); config icon cp; system metrics + update throttle stamps (runAsManager touch -> runFileOp touch); setup-lock rm; updater history seed + cp. - task health checker: 4 log writes (tee -a -> runFileWrite -a) + 3 find -delete (-> runFileOp find). - config reconcile: backup cp -> runCfgOp; live cp -> runFileWrite < tmp for container-owned configs (the container user can't read a manager 0600 tmp). - peer pull: tar extract into the container tree -> runFileOp tar. - masking: ip_find_available + folder_group(x2) — split 'local VAR=$(cmd)' so $? reaches the following [[ $? ]] check. 15 files, all pass bash -n; fixed idioms confirmed gone. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
253 lines
10 KiB
Bash
253 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"
|
|
|
|
runFileOp mkdir -p "$(dirname "$output_file")"
|
|
local temp_file="$(mktemp)"
|
|
|
|
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/core/icons/apps"
|
|
runFileOp mkdir -p "$icons_apps_dir"
|
|
runFileWrite "$icons_apps_dir/$icon_file" < "$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\": \"/core/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
|
|
runFileWrite "$output_file" < "$temp_file"; rm -f "$temp_file"
|
|
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
|
|
}
|