librelad ed9697cdc0 fix(rootless): apps/categories/config/system generators write as container owner
The remaining WebUI generators built JSON into a temp file inside the
output dir then placed it with mv/sudo mv + a createTouch that can't re-own,
so in rootless they produced root/libreportal-owned data and 'touch:
Permission denied' spam. Two problems: the temp lived in the (now
dockerinstall-owned) output dir, which the cron updater — running as
libreportal — can't write; and the final file landed wrong-owned.

Move each temp to mktemp (/tmp, writable by whoever runs the updater) and
place the result via runFileWrite (writes as the container owner:
dockerinstall in rootless, manager in rooted), dropping the redundant
createTouch; convert the dir mkdirs to runFileOp. Covers apps
(services/config/tools/app_status/gluetun/config_patch), categories
(app/config-categories/field-mappings), config (configs.json) and system
(info/memory/disk/update). The logs file is handled by the now mode-aware
createFolders + createTouch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-24 14:07:46 +01:00

317 lines
17 KiB
Bash
Executable File

#!/bin/bash
# LibrePortal WebUI Services Config Generator
# Generates apps-services.json from database
webuiGenerateAppsServicesConfig() {
local testing_mode="$1"
local output_file="${containers_dir}libreportal/frontend/data/apps/generated/apps-services.json"
local first_service=true
local service_count=0
isNotice "Generating apps-services.json from database..."
runFileOp mkdir -p "$(dirname "$output_file")"
# Create temp file first, then atomic move
local temp_file="$(mktemp)"
# Create header
cat > "$temp_file" << 'EOF'
{
"services": [
EOF
if [[ ! -f "$docker_dir/$db_file" ]]; then
echo "Warning: Database not found at $docker_dir/$db_file"
# Write empty array and close
echo " ]" >> "$temp_file"
echo "}" >> "$temp_file"
runFileWrite "$output_file" < "$temp_file"; rm -f "$temp_file"
fi
# Get all installed apps
local installed_apps=$(sqlite3 "$docker_dir/$db_file" "SELECT name FROM apps WHERE status = 1 ORDER BY name;" 2>/dev/null)
if [[ -z "$installed_apps" ]]; then
echo "No installed apps found"
# Write empty array and close
echo " ]" >> "$temp_file"
echo "}" >> "$temp_file"
runFileWrite "$output_file" < "$temp_file"; rm -f "$temp_file"
fi
# Process each app
while IFS= read -r app_name; do
# Get app installation date
local install_date=$(sqlite3 "$docker_dir/$db_file" "SELECT install_date, install_time FROM apps WHERE name = '$app_name';" 2>/dev/null)
local installed_date="${install_date}|"
# Get all Docker services for this app
local docker_services=$(sqlite3 "$docker_dir/$db_file" "SELECT service_name, resource_value FROM network_resources WHERE app_name = '$app_name' AND resource_type = 'ip' AND status = 'active' ORDER BY service_name;" 2>/dev/null)
if [[ -n "$docker_services" ]]; then
while IFS='|' read -r docker_service_name docker_service_ip; do
# Get all ports for this service
local service_ports=$(sqlite3 "$docker_dir/$db_file" "SELECT service_name, resource_value FROM network_resources WHERE app_name = '$app_name' AND resource_type = 'port' AND parent_service = '$docker_service_name' AND status = 'active' ORDER BY service_name;" 2>/dev/null)
if [[ -n "$service_ports" ]]; then
while IFS='|' read -r port_service_name port_mapping; do
# Parse port mapping: external:internal:access:protocol
local external_port=$(echo "$port_mapping" | cut -d':' -f1)
local internal_port=$(echo "$port_mapping" | cut -d':' -f2)
local access_type=$(echo "$port_mapping" | cut -d':' -f3)
local protocol=$(echo "$port_mapping" | cut -d':' -f4)
# Get server IP (from system config or default)
local server_ip=""
if [[ -f "${containers_dir}libreportal/config/generated/configs.json" ]]; then
server_ip=$(grep -o '"CFG_SERVER_IP":[[:space:]]*"[^"]*"' "${containers_dir}libreportal/config/generated/configs.json" | cut -d'"' -f4)
fi
[[ -z "$server_ip" ]] && server_ip="localhost"
# Determine if Traefik managed (check if service has Traefik labels)
local traefik_managed="false"
# For now, assume public access means Traefik managed
[[ "$access_type" == "public" ]] && traefik_managed="true"
# Per-port URL path (10th col of CFG_<APP>_PORT_N).
# Set when the matching port row is found below; left
# empty for ports without a web UI (DNS, etc.).
local port_url_path=""
# Get button config from app config file
local button_enabled="false"
local button_text=""
local login_required="false"
local app_config_file="${install_containers_dir}/${app_name}/${app_name}.config"
if [[ -f "$app_config_file" ]]; then
# Look for button config in port configuration
while IFS='=' read -r var_name var_value || [[ -n "$var_name" ]]; do
[[ -z "$var_name" || "$var_name" =~ ^[[:space:]]*# ]] && continue
[[ "$var_name" =~ ^CFG_${app_name^^}_PORT_ ]] || continue
# Remove quotes from value
var_value="${var_value#\"}"
var_value="${var_value%\"}"
# 10-col: service|name|external:internal|access|protocol|login|traefik|button_enabled|button_text|url_path
# 9-col legacy: service|name|external:internal|access|protocol|login|traefik|button_enabled|button_text
# 8-col legacy (no login):
# service|name|external:internal|access|protocol|traefik|button_enabled|button_text
local field_count=$(awk -F'|' '{print NF}' <<< "$var_value")
local config_service=$(echo "$var_value" | cut -d'|' -f1)
local config_name=$(echo "$var_value" | cut -d'|' -f2)
local config_login_required="false"
local config_button_enabled
local config_button_text
local config_url_path=""
if [[ "$field_count" -ge 10 ]]; then
config_login_required=$(echo "$var_value" | cut -d'|' -f6)
config_button_enabled=$(echo "$var_value" | cut -d'|' -f8)
config_button_text=$(echo "$var_value" | cut -d'|' -f9)
config_url_path=$(echo "$var_value" | cut -d'|' -f10)
elif [[ "$field_count" -ge 9 ]]; then
config_login_required=$(echo "$var_value" | cut -d'|' -f6)
config_button_enabled=$(echo "$var_value" | cut -d'|' -f8)
config_button_text=$(echo "$var_value" | cut -d'|' -f9)
else
config_button_enabled=$(echo "$var_value" | cut -d'|' -f7)
config_button_text=$(echo "$var_value" | cut -d'|' -f8)
fi
# Construct full service name from config (appname_servicename format used in database)
local config_full_service_name="${app_name}_${config_name}"
# Case-insensitive matching for service names
if [[ "${config_service,,}" == "${docker_service_name,,}" && "${config_full_service_name,,}" == "${port_service_name,,}" ]]; then
button_enabled="$config_button_enabled"
button_text="$config_button_text"
login_required="$config_login_required"
port_url_path="$config_url_path"
fi
done < "$app_config_file"
fi
# Default button text if not set
[[ -z "$button_text" ]] && button_text="${port_service_name^} Interface"
# Multi-button support: button_text + port_url_path may
# carry comma-separated parallel arrays. The first pair
# remains the "primary" (kept in externalURL/buttonText
# for back-compat); the full list goes into a `links[]`
# array the frontend can render as multiple Open buttons.
local IFS_BAK="$IFS"
IFS=',' read -ra _btn_labels <<< "$button_text"
IFS=',' read -ra _btn_paths <<< "$port_url_path"
IFS="$IFS_BAK"
# Normalize each path: empty stays empty; non-empty
# gets a leading slash if missing so authors can write
# either "admin/" or "/admin/" interchangeably.
local _link_count=${#_btn_labels[@]}
local _i
for ((_i=0; _i<_link_count; _i++)); do
local _p="${_btn_paths[$_i]:-}"
if [[ -n "$_p" && "$_p" != /* ]]; then
_btn_paths[$_i]="/$_p"
fi
done
# Primary URL = first pair (kept for back-compat).
local _primary_path="${_btn_paths[0]:-}"
local _primary_label="${_btn_labels[0]:-$button_text}"
local external_url="http://${server_ip}:${external_port}${_primary_path}"
local internal_url="http://${docker_service_ip}:${internal_port}${_primary_path}"
# Build links[] JSON array — one entry per button.
local _links_json=""
for ((_i=0; _i<_link_count; _i++)); do
local _l="${_btn_labels[$_i]}"
local _p="${_btn_paths[$_i]:-}"
[[ $_i -gt 0 ]] && _links_json+=","
_links_json+=$'\n {'
_links_json+=$'\n '"\"label\": \"$_l\","
_links_json+=$'\n '"\"externalURL\": \"http://${server_ip}:${external_port}${_p}\","
_links_json+=$'\n '"\"internalURL\": \"http://${docker_service_ip}:${internal_port}${_p}\""
_links_json+=$'\n }'
done
# Add separator comma for non-first services
if [[ "$first_service" == false ]]; then
echo " }," >> "$temp_file"
fi
first_service=false
service_count=$((service_count + 1))
# Write JSON entry
{
echo " {"
echo " \"app\": \"$app_name\","
echo " \"installed\": \"$installed_date\","
echo " \"serviceName\": \"$docker_service_name\","
echo " \"serviceIP\": \"$docker_service_ip\","
echo " \"name\": \"$port_service_name\","
echo " \"externalPort\": $external_port,"
echo " \"internalPort\": $internal_port,"
echo " \"access\": \"$access_type\","
echo " \"protocol\": \"$protocol\","
echo " \"traefikManaged\": $traefik_managed,"
echo " \"serverIP\": \"$server_ip\","
echo " \"externalURL\": \"$external_url\","
echo " \"internalURL\": \"$internal_url\","
echo " \"links\": [${_links_json}"
echo " ],"
echo " \"buttonEnabled\": $button_enabled,"
echo " \"buttonText\": \"$_primary_label\","
echo " \"loginRequired\": $login_required"
} >> "$temp_file"
done <<< "$service_ports"
fi
done <<< "$docker_services"
fi
done <<< "$installed_apps"
# Host-installed apps live outside the Docker network_resources DB.
# Walk container configs for HOST_INSTALL=true + HOST_SERVICES, confirm
# the package is present via dpkg, then emit one synthetic service entry
# per systemd unit. Transport=systemd tells the frontend the logs come
# from a host log file (tail -F over the bind-mounted /host/var/log).
if [[ -d "$install_containers_dir" ]]; then
for app_dir in "$install_containers_dir"/*; do
[[ -d "$app_dir" ]] || continue
local host_app_name=$(basename "$app_dir")
[[ "$host_app_name" == "template" ]] && continue
local cfg_file="$app_dir/$host_app_name.config"
[[ -f "$cfg_file" ]] || continue
local app_upper="${host_app_name^^}"; app_upper="${app_upper//-/_}"
local host_install host_package host_services host_log_map
host_install=$(grep -E "^CFG_${app_upper}_HOST_INSTALL=" "$cfg_file" | cut -d'=' -f2 | tr -d '\r"')
[[ "$host_install" == "true" ]] || continue
host_package=$(grep -E "^CFG_${app_upper}_HOST_PACKAGE=" "$cfg_file" | cut -d'=' -f2 | tr -d '\r"')
host_services=$(grep -E "^CFG_${app_upper}_HOST_SERVICES=" "$cfg_file" | cut -d'=' -f2 | tr -d '\r"')
host_log_map=$(grep -E "^CFG_${app_upper}_HOST_LOG_FILES=" "$cfg_file" | cut -d'=' -f2 | tr -d '\r"')
[[ -n "$host_package" ]] || continue
dpkg-query -W -f='${Status}' "$host_package" 2>/dev/null | grep -q "install ok installed" || continue
[[ -n "$host_services" ]] || continue
IFS=',' read -ra _units <<< "$host_services"
for _unit in "${_units[@]}"; do
_unit="${_unit//[[:space:]]/}"
[[ -z "$_unit" ]] && continue
local _status="inactive"
systemctl is-active --quiet "$_unit" 2>/dev/null && _status="active"
local _display_name="${_unit%.service}"
# Pluck the matching log file for this unit from the
# comma-list of <unit>|<path> pairs. Empty = no log file
# known; backend will surface that to the client.
local _log_host="" _log_container=""
if [[ -n "$host_log_map" ]]; then
IFS=',' read -ra _pairs <<< "$host_log_map"
for _p in "${_pairs[@]}"; do
local _u="${_p%%|*}"
local _f="${_p#*|}"
if [[ "$_u" == "$_unit" ]]; then
_log_host="$_f"
# /var/log/X -> /host/var/log/X (matches the
# bind-mount in libreportal's compose).
_log_container="/host${_f}"
break
fi
done
fi
if [[ "$first_service" == false ]]; then
echo " }," >> "$temp_file"
fi
first_service=false
service_count=$((service_count + 1))
{
echo " {"
echo " \"app\": \"$host_app_name\","
echo " \"installed\": \"\","
echo " \"serviceName\": \"$_display_name\","
echo " \"name\": \"$_display_name\","
echo " \"transport\": \"systemd\","
echo " \"unit\": \"$_unit\","
echo " \"status\": \"$_status\","
[[ -n "$_log_container" ]] && echo " \"logFile\": \"$_log_container\","
echo " \"buttonEnabled\": false,"
echo " \"buttonText\": \"\","
echo " \"loginRequired\": false"
} >> "$temp_file"
done
done
fi
# Close last object and array
if [[ "$first_service" == false ]]; then
echo " }" >> "$temp_file"
else
# No services found, write empty entry
echo " ]" >> "$temp_file"
echo "}" >> "$temp_file"
runFileWrite "$output_file" < "$temp_file"; rm -f "$temp_file"
echo "No services found to generate"
fi
echo " ]" >> "$temp_file"
echo "}" >> "$temp_file"
# Atomic move to final location
if [ $? -eq 0 ]; then
runFileWrite "$output_file" < "$temp_file"; rm -f "$temp_file"
# Set proper ownership for web UI access
else
rm -f "$temp_file" 2>/dev/null
fi
# Output summary
if [[ "$testing_mode" == "test" ]]; then
echo "apps-services.json test generation completed!"
echo "Output file: $output_file"
echo "Total services generated: $service_count"
else
isSuccessful "Generation of apps-services.json completed!"
fi
}