#!/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..." mkdir -p "$(dirname "$output_file")" # Create temp file first, then atomic move local temp_file="${output_file}.tmp.$$" # 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" mv "$temp_file" "$output_file" createTouch "$output_file" "$docker_install_user" "silent" 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" mv "$temp_file" "$output_file" createTouch "$output_file" "$docker_install_user" "silent" 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__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 | 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" mv "$temp_file" "$output_file" createTouch "$output_file" "$docker_install_user" "silent" echo "No services found to generate" fi echo " ]" >> "$temp_file" echo "}" >> "$temp_file" # Atomic move to final location if [ $? -eq 0 ]; then mv "$temp_file" "$output_file" # Set proper ownership for web UI access createTouch "$output_file" "$docker_install_user" "silent" 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 }