From a1290b47a3ccfa186d71a188cb195f2a07f07af7 Mon Sep 17 00:00:00 2001 From: librelad Date: Thu, 20 Aug 2026 00:48:08 +0100 Subject: [PATCH] fix(ports,config): stop losing columns in the port descriptor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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___, 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 --- .../components/apps/core/js/apps-manager.js | 28 ++++++++++++++++++- .../apps/port-manager/js/port-manager.js | 16 +++++++++-- .../apps/routing/js/routing-manager.js | 4 ++- .../network/variables/variables_init_app.sh | 13 ++++++++- .../webui_create_app_field_mappings.sh | 6 ++++ 5 files changed, 62 insertions(+), 5 deletions(-) diff --git a/containers/libreportal/frontend/components/apps/core/js/apps-manager.js b/containers/libreportal/frontend/components/apps/core/js/apps-manager.js index dc6d268..a897ac7 100755 --- a/containers/libreportal/frontend/components/apps/core/js/apps-manager.js +++ b/containers/libreportal/frontend/components/apps/core/js/apps-manager.js @@ -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___ + // (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; } diff --git a/containers/libreportal/frontend/components/apps/port-manager/js/port-manager.js b/containers/libreportal/frontend/components/apps/port-manager/js/port-manager.js index 0afa5b2..2656f02 100755 --- a/containers/libreportal/frontend/components/apps/port-manager/js/port-manager.js +++ b/containers/libreportal/frontend/components/apps/port-manager/js/port-manager.js @@ -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 diff --git a/containers/libreportal/frontend/components/apps/routing/js/routing-manager.js b/containers/libreportal/frontend/components/apps/routing/js/routing-manager.js index 3087b36..f9c7a6f 100644 --- a/containers/libreportal/frontend/components/apps/routing/js/routing-manager.js +++ b/containers/libreportal/frontend/components/apps/routing/js/routing-manager.js @@ -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] || '') }; diff --git a/scripts/network/variables/variables_init_app.sh b/scripts/network/variables/variables_init_app.sh index 695ef5a..27be595 100755 --- a/scripts/network/variables/variables_init_app.sh +++ b/scripts/network/variables/variables_init_app.sh @@ -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]##*:}" diff --git a/scripts/webui/data/generators/categories/webui_create_app_field_mappings.sh b/scripts/webui/data/generators/categories/webui_create_app_field_mappings.sh index b5a720a..ee9de30 100755 --- a/scripts/webui/data/generators/categories/webui_create_app_field_mappings.sh +++ b/scripts/webui/data/generators/categories/webui_create_app_field_mappings.sh @@ -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",