LibrePortal/scripts/network/variables/variables_init_app.sh
librelad a1290b47a3 fix(ports,config): stop losing columns in the port descriptor
Four faults, all in the same 12-column format, all silent.

The bash parser split with `local parts=(${value//|/ })` — replacing
pipes with spaces and word-splitting. That broke the format two ways at
once: a label containing a space became several fields, and an EMPTY
column collapsed rather than being kept, shifting everything after it.
Stoat's LiveKit row parsed as label "LiveKit", url_path "voice/video",
subdomain "(TCP", recommended "fallback)". Rocket.Chat's subdomain only
landed correctly because the extra label word and the collapsed empty
column happened to cancel out. The column COUNT was wrong too, so the
9/8/7-col compatibility branches were chosen from an inflated number.
Now an IFS read, which keeps empties and never word-splits.

The port editor had two serialisers and they disagreed. buildPortConfig
writes all twelve columns; updateIndividualPortFields wrote ten, dropping
subdomain and recommended — so saving ANY port on an app silently
discarded that app's Traefik subdomain. That is how Stoat's live config
came to differ from its template, which still had "stoat".

Both readers gated the subdomain on twelve columns, but subdomain IS
column eleven — so the canonical 11-column descriptor every web app
ships never surfaced one. The bash side reads it from nine.

Lastly, findMatchingCFGKey could not see a generated-value slot suffix.
Passwords LibrePortal generates are stored as CFG_<APP>_<NAME>_<n>, and
ADMIN_PASSWORD_1 neither equals ADMIN_PASSWORD nor ends with
"_ADMIN_PASSWORD", so a generic mapping matched an app's admin EMAIL and
missed its admin PASSWORD entirely: the field simply never rendered
unless someone had hand-written a per-app mapping. Now resolved as a
last resort, after every exact and whole-word match has failed, lowest
slot first. Plus a generic ADMIN_USERNAME mapping, since ADMIN_USER is a
different field name and correctly does not match it.

Audited all 74 port descriptors across the catalogue: none are
malformed. 39 sit at 9 columns, which is a documented, supported shape
(url_path/subdomain empty, recommended defaulting to the webui flag) and
they are all non-Traefik ports — DNS, SMTP, WireGuard UDP.

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

212 lines
9.3 KiB
Bash
Executable File

#!/bin/bash
# Default app variable setups
initializeAppVariables()
{
app_name="$1"
if [[ "$app_name" == "" ]]; then
isError "Something went wrong...No app name provided..."
if [[ "$initial_command2" == "terminal" ]]; then
resetToMenu;
fi
fi
# Build variable names based on app_name
compose_setup_var="CFG_${app_name^^}_COMPOSE_FILE"
domain_var="CFG_${app_name^^}_DOMAIN"
whitelist_var="CFG_${app_name^^}_WHITELIST"
healthcheck_var="CFG_${app_name^^}_HEALTHCHECK"
authelia_var="CFG_${app_name^^}_AUTHELIA"
headscale_var="CFG_${app_name^^}_HEADSCALE"
app_category_var="CFG_${app_name^^}_CATEGORY"
app_title_var="CFG_${app_name^^}_TITLE"
# Access the variables using variable indirection
compose_setup="${!compose_setup_var}"
domain="${!domain_var}"
whitelist="${!whitelist_var}"
healthcheck="${!healthcheck_var}"
authelia_setup="${!authelia_var}"
headscale_setup="${!headscale_var}"
app_category="${!app_category_var}"
app_title="${!app_title_var}"
domain_var_name="CFG_DOMAIN_${domain}"
domain_full="${!domain_var_name}"
ssl_key=${domain_full}.key
ssl_crt=${domain_full}.crt
# host_setup (the app's primary/canonical FQDN) is derived from the primary
# Traefik port's subdomain, computed after the port arrays are parsed below.
# Port configuration variables
port_config_vars=()
port_config_data=()
# Arrays to hold parsed port configuration data
port_service_names=()
port_parent_services=()
port_data_tags=()
port_external_ports=()
port_internal_ports=()
port_access_types=()
port_protocols=()
port_traefik_managed=()
port_url_accessibles=()
port_login_requireds=()
port_labels=()
port_url_paths=()
port_subdomains=()
port_recommendeds=()
for i in {1..20}; do
port_config_vars+=("CFG_${app_name^^}_PORT_$i")
# Store actual config data for port allocation
local port_config_var="CFG_${app_name^^}_PORT_$i"
local port_config_value="${!port_config_var}"
if [[ -n "$port_config_value" ]]; then
port_config_data+=("$port_config_value")
# 12-col: parent|name|ext:int|access|proto|login|traefik|webui|label|url_path|subdomain|recommended
# 10-col: parent|name|ext:int|access|proto|login|traefik|webui|label|url_path
# 9-col: parent|name|ext:int|access|proto|login|traefik|webui|label
# Legacy 8-col: parent|name|ext:int|access|proto|traefik|webui|label (login defaults to false)
# Legacy 7-col: name|ext:int|access|proto|traefik|webui|label (no parent, login defaults to false)
# IFS-split, NOT ${v//|/ } with word-splitting. That idiom broke the
# format in two ways at once: a label containing a space became
# several fields ("Web Interface" -> label "Web", url_path
# "Interface"), and an EMPTY column collapsed instead of being kept,
# shifting every field after it. Stoat's LiveKit row parsed as
# label "LiveKit", url_path "voice/video", subdomain "(TCP",
# recommended "fallback)" — and Rocket.Chat's subdomain only landed
# correctly because the extra label word and the collapsed empty
# column happened to cancel out. The column COUNT was wrong too, so
# the 9/8/7-col branch below was chosen from an inflated number.
local parts=()
IFS='|' read -ra parts <<< "$port_config_value"
if [[ ${#parts[@]} -ge 9 ]]; then
local external_port="${parts[2]%%:*}"
local internal_port="${parts[2]##*:}"
port_parent_services+=("${parts[0]}")
port_service_names+=("${parts[1]}")
port_data_tags+=("PORTS_TAG_$i")
port_external_ports+=("$external_port")
port_internal_ports+=("$internal_port")
port_access_types+=("${parts[3]}")
port_protocols+=("${parts[4]}")
port_login_requireds+=("${parts[5]}")
port_traefik_managed+=("${parts[6]}")
port_url_accessibles+=("${parts[7]}")
port_labels+=("${parts[8]}")
port_url_paths+=("${parts[9]:-}")
port_subdomains+=("${parts[10]:-}")
# Recommended defaults to the webui flag when the column isn't present —
# matches the panel's expectation that webui ports are primary by default.
if [[ ${#parts[@]} -ge 12 ]]; then
port_recommendeds+=("${parts[11]}")
else
port_recommendeds+=("${parts[7]}")
fi
elif [[ ${#parts[@]} -ge 8 ]]; then
local external_port="${parts[2]%%:*}"
local internal_port="${parts[2]##*:}"
port_parent_services+=("${parts[0]}")
port_service_names+=("${parts[1]}")
port_data_tags+=("PORTS_TAG_$i")
port_external_ports+=("$external_port")
port_internal_ports+=("$internal_port")
port_access_types+=("${parts[3]}")
port_protocols+=("${parts[4]}")
port_traefik_managed+=("${parts[5]}")
port_url_accessibles+=("${parts[6]}")
port_login_requireds+=("false")
port_labels+=("${parts[7]}")
port_url_paths+=("")
port_subdomains+=("")
port_recommendeds+=("${parts[6]}")
elif [[ ${#parts[@]} -ge 7 ]]; then
local external_port="${parts[1]%%:*}"
local internal_port="${parts[1]##*:}"
port_parent_services+=("")
port_service_names+=("${parts[0]}")
port_data_tags+=("PORTS_TAG_$i")
port_external_ports+=("$external_port")
port_internal_ports+=("$internal_port")
port_access_types+=("${parts[2]}")
port_protocols+=("${parts[3]}")
port_traefik_managed+=("${parts[4]}")
port_url_accessibles+=("${parts[5]}")
port_login_requireds+=("false")
port_labels+=("${parts[6]}")
port_url_paths+=("")
port_subdomains+=("")
port_recommendeds+=("${parts[5]}")
fi
fi
done
# Default Empty config options
if [ "$authelia_setup" == "" ]; then
authelia_setup=false
fi
if [ "$headscale_setup" == "" ]; then
headscale_setup=false
fi
if [ "$whitelist" == "" ]; then
whitelist=false
fi
if [ "$healthcheck" == "" ]; then
healthcheck=true
fi
# No domain configured -> no Traefik, whatever the per-port column says.
# LibrePortal is LAN/VPN-first: a box with CFG_DOMAIN_<n> unset must still
# serve every app on http://<ip>:<port>. Left alone, a traefik=true port with
# an empty $domain_full stamps Host(`<app>.`) — a trailing-dot host that
# matches nothing — and drags APP_URL to https://<app>. with it, breaking any
# app that builds its links from APP_URL (Bookstack, Nextcloud, Mastodon).
# Forcing the column false makes tagsProcessorPortRouterBlocks comment the
# router out entirely and lets tagsProcessorAppUrl fall through to the
# http://<ip>:<port> branch. The published host port is unaffected — access
# type, not the traefik flag, decides whether a port is allocated.
if [[ -z "$domain_full" ]]; then
local _nd
for _nd in "${!port_traefik_managed[@]}"; do
port_traefik_managed[$_nd]="false"
done
fi
# $public is derived from the port config — true iff any of this app's
# PORT_<n> rows have field 7 (traefik) == "true". Replaces the legacy
# CFG_<APP>_PUBLIC field, which was redundant with the per-port flag.
public="false"
local _t
for _t in "${port_traefik_managed[@]}"; do
[[ "$_t" == "true" ]] && { public="true"; break; }
done
# Primary/canonical host for this app: its first recommended Traefik port,
# else its first Traefik port. Feeds the legacy single DOMAINSUBNAME_DATA
# (app env vars), the app URL, and trusted-domains. Mirrors the per-port
# host rule (@/root -> apex, set -> sub.domain, empty -> app-name).
host_setup="${app_name}.${domain_full}"
local _i _primary=-1
for ((_i = 0; _i < ${#port_service_names[@]}; _i++)); do
[[ "${port_traefik_managed[$_i]}" == "true" ]] || continue
if [[ "${port_recommendeds[$_i]}" == "true" ]]; then _primary=$_i; break; fi
[[ $_primary -lt 0 ]] && _primary=$_i
done
if [[ $_primary -ge 0 ]]; then
local _sub="${port_subdomains[$_primary]}"
if [[ "$_sub" == "@" || "$_sub" == "root" ]]; then
host_setup="${domain_full}"
elif [[ -n "$_sub" ]]; then
host_setup="${_sub}.${domain_full}"
fi
fi
# Every branch above suffixes $domain_full, so with no domain configured they
# all yield a bare trailing dot ("bookstack."). That string is not a host, and
# it reaches DOMAINSUBNAME_TAG and the trusted-domains list regardless of the
# Traefik flag — so blank it rather than let a non-resolving name ship. Both
# consumers already treat empty as "no canonical host".
[[ -z "$domain_full" ]] && host_setup=""
}