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>
This commit is contained in:
librelad 2026-08-20 00:48:08 +01:00
parent 93ec260298
commit a1290b47a3
5 changed files with 62 additions and 5 deletions

View File

@ -1098,7 +1098,33 @@ class AppsManager {
return cfgKey;
}
}
// Last resort: a key carrying a generated-value SLOT suffix. Passwords whose
// value LibrePortal generates are stored as CFG_<APP>_<NAME>_<n>
// (CFG_STOAT_ADMIN_PASSWORD_1), and none of the rules above see through that
// trailing _1 — ADMIN_PASSWORD_1 neither equals ADMIN_PASSWORD nor ends with
// '_ADMIN_PASSWORD'. So a generic mapping matched an app's admin EMAIL and
// silently missed its admin PASSWORD, and the field never rendered: the one
// credential a user most needs to read or change was absent from the page
// unless someone had hand-written a per-app mapping for it.
//
// The backend already resolves the bare name to the slotted key (see
// scripts/app/auth_adapter.sh); this mirrors that. Tried only after every
// exact and whole-word match has failed, so a mapping that already binds
// precisely is never redirected, and the lowest slot wins so the choice is
// deterministic when an app declares several.
const slotRe = new RegExp('(?:^|_)' + fieldKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '_(\\d+)$');
let bestKey = null;
let bestSlot = Infinity;
for (const cfgKey of keys) {
const m = cfgKey.replace('CFG_', '').match(slotRe);
if (m && Number(m[1]) < bestSlot) {
bestSlot = Number(m[1]);
bestKey = cfgKey;
}
}
if (bestKey) return bestKey;
return null;
}

View File

@ -34,6 +34,10 @@ class PortManager {
const isTenCol = parts.length >= 10;
const isNineCol = parts.length >= 9;
// subdomain is column 11, so it exists from ELEVEN fields on. Requiring
// twelve meant the canonical 11-column descriptor every web app ships
// never surfaced its subdomain — the bash parser reads it from nine.
const hasSubdomain = parts.length >= 11;
const isTwelveCol = parts.length >= 12;
// Recommended defaults to the webui flag when not stored on the row —
// matches the panel's "primary list" expectation for apps that haven't
@ -51,7 +55,7 @@ class PortManager {
button_enabled: buttonEnabled,
button_text: isNineCol ? (parts[8] || '') : (parts[7] || ''),
url_path: isTenCol ? (parts[9] || '') : '',
subdomain: isTwelveCol ? (parts[10] || '') : '',
subdomain: hasSubdomain ? (parts[10] || '') : '',
recommended: isTwelveCol ? (parts[11] === 'true') : buttonEnabled
});
}
@ -672,7 +676,15 @@ class PortManager {
this.ports.forEach((port, index) => {
const login = port.login_required ? 'true' : 'false';
const portConfig = `${port.service}|${port.name}|${port.external}:${port.internal}|${port.access}|${port.protocol}|${login}|${port.traefik_managed}|${port.button_enabled}|${port.button_text}|${port.url_path || ''}`;
// All twelve columns, matching buildPortConfig above. This wrote only ten
// and silently dropped subdomain and recommended, so saving ANY port on an
// app quietly discarded that app's Traefik subdomain: the descriptor came
// back a column short and the router fell back to the app-name default.
// Stoat lost its "stoat" subdomain exactly this way — the template had it,
// the live config did not.
const subdomain = port.subdomain || '';
const recommended = port.recommended ? 'true' : 'false';
const portConfig = `${port.service}|${port.name}|${port.external}:${port.internal}|${port.access}|${port.protocol}|${login}|${port.traefik_managed}|${port.button_enabled}|${port.button_text}|${port.url_path || ''}|${subdomain}|${recommended}`;
const fieldName = `CFG_${appName.toUpperCase()}_PORT_${index + 1}`;
// Find and update the individual PORT_X field

View File

@ -45,6 +45,8 @@ class RoutingManager {
const parts = String(raw).split('|');
if (parts.length < 8) continue;
const isNine = parts.length >= 9;
// Column 11 exists from eleven fields on; only `recommended` needs twelve.
const hasSubdomain = parts.length >= 11;
const isTwelve = parts.length >= 12;
const webui = isNine ? parts[7] === 'true' : parts[6] === 'true';
const port = {
@ -59,7 +61,7 @@ class RoutingManager {
protocol: parts[4] || 'tcp',
traefik: isNine ? parts[6] === 'true' : parts[5] === 'true',
webui,
subdomain: isTwelve ? (parts[10] || '') : '',
subdomain: hasSubdomain ? (parts[10] || '') : '',
recommended: isTwelve ? parts[11] === 'true' : webui,
description: isNine ? (parts[8] || '') : (parts[7] || '')
};

View File

@ -70,7 +70,18 @@ initializeAppVariables()
# 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)
local parts=(${port_config_value//|/ })
# 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]##*:}"

View File

@ -474,6 +474,12 @@ PORTEOF
"type": "text",
"tooltip": "Username of the admin account the credentials card shows"
},
"ADMIN_USERNAME": {
"category": "general",
"label": "Admin Username",
"type": "text",
"tooltip": "Username of the admin account the credentials card shows"
},
"ADGUARD_ADMIN_PASSWORD_1": {
"category": "general",
"label": "AdGuard Admin Password",