librelad 2d24a764a8 refactor(storage): route elevation tests and the WebUI tree through paths.sh
Two mechanical sweeps, no behaviour change on a single-root install.

The 14 `[[ "$p" == "$containers_dir"* ]]` prefix tests that decide
manager-vs-container-user elevation become pathIsContainerData, so a file
on a second storage root is no longer misclassified as manager-owned —
which would have written it with the wrong owner and failed later, far
from the cause. The 65 references to the WebUI's own tree become
webuiDir(), which is pinned to the primary root by design.

Two traps found while doing it:

run_privileged.sh is sourced directly by init.sh without paths.sh, so it
needs a fallback. Defining one named pathIsContainerData was wrong:
generate_function_manifest.sh indexes top-level definitions, and the
resulting autoload stub would have shadowed the real multi-root
implementation with the primary-only fallback — silently classifying
every file on a second disk as manager-owned, which is exactly the bug
this sweep exists to prevent. Renamed to _runCfgIsContainerPath, which
delegates when the real one is loaded.

setup_lock.sh built its path in a top-level assignment, so it was
evaluated at source time and needed the file flagged eager. Made it a
function instead: the path resolves on call, and the file drops off
LP_EAGER_FILES entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 04:04:19 +01:00

352 lines
19 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="$(webuiDir)/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}|"
# This app's domain, resolved the same way initializeAppVariables does
# (CFG_<APP>_DOMAIN picks a CFG_DOMAIN_<n> slot). Resolved here rather than
# read from $domain_full — this generator never calls initializeAppVariables,
# so that global is either unset or left over from an unrelated app. Empty
# means no Traefik router exists for any of this app's ports, whatever their
# traefik column says.
local _dom_idx_var="CFG_${app_name^^}_DOMAIN"
local _dom_idx="${!_dom_idx_var:-1}"
local _dom_var="CFG_DOMAIN_${_dom_idx}"
local app_domain_full="${!_dom_var:-}"
# 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)
# Host for the "Open" links. CFG_SERVER_IP stays an explicit
# override, but no config file actually defines it today, so
# this always fell through to "localhost" — a URL that only
# works for someone browsing ON the server. $local_ip_v4 (the
# source IP of the default route) is what LAN/VPN clients dial,
# and matches the host APP_URL is stamped with.
local server_ip=""
if [[ -f "$(webuiDir)/config/generated/configs.json" ]]; then
server_ip=$(grep -o '"CFG_SERVER_IP":[[:space:]]*"[^"]*"' "$(webuiDir)/config/generated/configs.json" | cut -d'"' -f4)
fi
[[ -z "$server_ip" ]] && server_ip="${local_ip_v4:-localhost}"
[[ -z "$server_ip" ]] && server_ip="localhost"
# Whether this port really has a Traefik router. Read from the
# port's own traefik column below (field 7), not inferred from
# the access type — "public" only means the port is published
# on the host, and plenty of public ports carry no router. The
# old assumption reported traefikManaged:true for apps whose
# compose had traefik.enable:false, including every app on a
# box with no domain configured.
local traefik_managed="false"
# 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=""
# Field 7 = the traefik column. Absent on the 8-col
# legacy layout, where field 6 held it instead.
local config_traefik="false"
if [[ "$field_count" -ge 9 ]]; then
config_traefik=$(echo "$var_value" | cut -d'|' -f7)
else
config_traefik=$(echo "$var_value" | cut -d'|' -f6)
fi
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"
# A router also needs a domain to attach to —
# mirrors the guard in initializeAppVariables, so
# the WebUI and the generated compose agree.
if [[ "$config_traefik" == "true" && -n "$app_domain_full" ]]; then
traefik_managed="true"
fi
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
}