LibrePortal/scripts/network/variables/variables_init_app.sh
librelad 45ee27a0d6 refactor(ports): nine columns is the floor; refuse the legacy layouts
The parser accepted five shapes. Three of them (9, 10, 11/12) differ only
by trailing columns that have sane defaults, and those are worth keeping:
39 of the catalogue's descriptors stop at nine because they are
non-Traefik ports — DNS, SMTP, WireGuard UDP — with no subdomain to
state. A short row there is a complete row.

The other two were different animals. The 8-column legacy layout has no
login column and the 7-column one has no parent either, so they SHIFT
every position rather than omitting a tail: whenever the length was
misread, each field after the shift silently took its neighbour's value —
a port's access type reading from its protocol, and so on. That is the
same class of fault the word-splitting bug in this file just caused, and
it is invisible when it happens.

Nothing needs them. All 74 descriptors in the catalogue carry nine or
more, as does every one on this install. So they are refused now, with a
notice naming the offending key: a skipped port is visible, a mis-parsed
one is not.

Checked that skipping a row cannot misalign the parallel arrays —
port_config_data and port_config_vars are appended before the branch, but
neither is ever indexed alongside the others; the former is only tested
for emptiness.

Verified across every shape: 9, 10, 11 and 12 parse with the right
defaults, a label containing spaces survives intact next to an empty
trailing column, and both legacy layouts are refused rather than guessed.

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

198 lines
9.0 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
# 11-col: ... |subdomain (recommended defaults to the webui flag)
# 10-col: ... |url_path (subdomain empty -> app-name default)
# 9-col: parent|name|ext:int|access|proto|login|traefik|webui|label
#
# Nine is the floor. The trailing three are genuinely optional and
# have sane defaults, so a 9/10/11-column row is a complete row --
# 39 of the catalogue's descriptors stop at nine because they are
# non-Traefik ports (DNS, SMTP, WireGuard UDP) with no subdomain to
# state. Anything shorter is not a shorter row, it is a DIFFERENT
# layout, and is refused below.
# 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
else
# The 8- and 7-column legacy shapes used to be accepted here.
# They do not merely omit trailing fields -- they SHIFT every
# position (8-col has no login column, 7-col has no parent
# either), so whenever the length was misread every field after
# the shift silently took its neighbour's value. That is exactly
# the class of fault this parser was just fixed for, and nothing
# ships in those shapes any more: all 74 descriptors in the
# catalogue, and every one on a live install, carry nine or more.
#
# So refuse and say which row, rather than guess a layout and
# hand back a port whose access or protocol came from the wrong
# column. A skipped row is visible; a mis-parsed one is not.
isNotice "Port config $port_config_var has ${#parts[@]} column(s); at least 9 are required (parent|name|ext:int|access|proto|login|traefik|webui|label). Skipping this port."
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=""
}