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>
212 lines
9.3 KiB
Bash
Executable File
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=""
|
|
}
|