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>
352 lines
19 KiB
Bash
Executable File
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
|
|
}
|