From 4aa4c39e8d61a87051e923de1158090563b9404e Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:50:35 -0400 Subject: [PATCH 1/6] fix(web-ui): let the MQTT bridge form save a password without TLS PUT /api/v3/integrations/mqtt-bridge/config refuses a stored password while mqtt_tls is off unless allow_insecure_mqtt is set (the CWE-319 guard in api_v3/misc.py). The Tools tab form neither rendered a control for that flag nor sent it, so a password-protected broker on a LAN without TLS could never be saved from the UI, and once such a password was in bridge_config.json every later save from the form was refused. The form now shows "Allow without TLS (trusted network)" while "Use TLS" is unchecked, prefilled from the GET's config.allow_insecure_mqtt, and mqttBody() sends its state as allow_insecure_mqtt. The box is off until the user ticks it, so the server's guard still refuses a cleartext password by default. Tests: the Tools DOM suite checks the control, its show/hide with the TLS box, the prefill and the value saved; a Flask test pins that the GET reports the opt-in (false until saved on). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 ++++ test/js/dom/test_tools_sections.js | 42 +++++++++++++++++++ test/test_mqtt_bridge_config_endpoint.py | 19 +++++++++ .../templates/v3/partials/tools.html | 18 ++++++++ 4 files changed, 87 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fa3a3862..3a14e4b0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -556,6 +556,14 @@ policies are unchanged. the panel went off at 23:00 or at 23:01 depending on when in the minute that check ran. Windows that cross midnight and per-day schedules follow the same rule, and so does the dim schedule. +- The MQTT bridge settings on the Tools tab can save a broker password with + TLS off. The server refuses that unless `allow_insecure_mqtt` is set, and + the form had no way to set it, so a password-protected broker on a home + network without TLS could not be saved from the web UI, and once such a + password was stored every later save failed too. While "Use TLS" is + unchecked the form now shows "Allow without TLS (trusted network)", + prefilled from the saved settings. It is off until ticked, so the server + still refuses a cleartext password by default. - An on-demand session that ends during scheduled-off hours, by expiring or being stopped, blanks the panel within about a second. It used to stay on until the next minute, because the once-a-minute schedule check had diff --git a/test/js/dom/test_tools_sections.js b/test/js/dom/test_tools_sections.js index ea8ffd051..91fb617e0 100644 --- a/test/js/dom/test_tools_sections.js +++ b/test/js/dom/test_tools_sections.js @@ -98,8 +98,50 @@ const get = p => new Promise((res, rej) => window.saveMqttBridge(); await tick(150); ok('save includes password once typed', sent && sent.mqtt_password === 'typed-secret'); + + // A password with TLS off is refused unless allow_insecure_mqtt is set + // (CWE-319, api_v3/misc.py). The form has to be able to send it, or a + // plain-LAN broker with a password can never be saved from here. + const allowRow = () => $('mqtt-allow-insecure-row'); + const shown = el => !!el && !el.classList.contains('hidden'); + ok('allow-without-TLS control rendered', !!$('mqtt-allow-insecure')); + ok('allow-without-TLS starts as saved', + !!$('mqtt-allow-insecure') && $('mqtt-allow-insecure').checked === !!bridge.data.config.allow_insecure_mqtt); + ok('allow-without-TLS shown only while TLS is off', + shown(allowRow()) === !$('mqtt-tls').checked); + $('mqtt-tls').checked = true; + $('mqtt-tls').dispatchEvent(new window.Event('change', { bubbles: true })); + ok('ticking TLS hides it', !shown(allowRow())); + $('mqtt-tls').checked = false; + $('mqtt-tls').dispatchEvent(new window.Event('change', { bubbles: true })); + ok('unticking TLS shows it again', shown(allowRow())); + + const setAllow = v => { if ($('mqtt-allow-insecure')) $('mqtt-allow-insecure').checked = v; }; + setAllow(false); + window.saveMqttBridge(); + await tick(150); + ok('save sends allow_insecure_mqtt false when unticked', !!sent && sent.allow_insecure_mqtt === false, sent); + setAllow(true); + window.saveMqttBridge(); + await tick(150); + ok('save sends allow_insecure_mqtt true when ticked', !!sent && sent.allow_insecure_mqtt === true, sent); onPut = null; + // Prefilled from the saved settings, and hidden while TLS is saved on. + bridgePayload = JSON.parse(JSON.stringify(bridge)); + bridgePayload.data.config.allow_insecure_mqtt = true; + bridgePayload.data.config.mqtt_tls = false; + window.loadMqttBridge(); + await tick(150); + ok('a saved opt-in is prefilled', !!$('mqtt-allow-insecure') && $('mqtt-allow-insecure').checked === true); + bridgePayload.data.config.mqtt_tls = true; + window.loadMqttBridge(); + await tick(150); + ok('hidden on load when TLS is saved on', !shown(allowRow())); + bridgePayload = bridge; + window.loadMqttBridge(); + await tick(150); + // ── Pixlet editor, idle ──────────────────────────────────────────────── const appIds = (apps.data.apps || []).map(a => a.id); ok('editor lists the apps on disk', diff --git a/test/test_mqtt_bridge_config_endpoint.py b/test/test_mqtt_bridge_config_endpoint.py index 595e1c742..3fd2f8497 100644 --- a/test/test_mqtt_bridge_config_endpoint.py +++ b/test/test_mqtt_bridge_config_endpoint.py @@ -136,3 +136,22 @@ def test_the_opt_in_is_a_real_boolean(self, client): "allow_insecure_mqtt": "false"}) assert r.status_code == 400 + def test_the_settings_read_reports_the_opt_in(self, client, monkeypatch): + """The Tools form prefills its "Allow without TLS" box from the GET. + + Off until someone saves it on, so an untouched form sends false and + the guard above still refuses a cleartext password. + """ + c, _ = client + monkeypatch.setattr(misc, "_mqtt_bridge_service_state", + lambda: {"installed": False, "active": False, "enabled": False}) + + def read(): + return c.get("/api/v3/integrations/mqtt-bridge").get_json()["data"]["config"] + + assert read()["allow_insecure_mqtt"] is False + r = c.put(URL, json={"mqtt_password": "hunter2", "mqtt_tls": False, + "allow_insecure_mqtt": True}) + assert r.status_code == 200, r.get_json() + assert read()["allow_insecure_mqtt"] is True + diff --git a/web_interface/templates/v3/partials/tools.html b/web_interface/templates/v3/partials/tools.html index c8dc8b2e9..0875ac404 100644 --- a/web_interface/templates/v3/partials/tools.html +++ b/web_interface/templates/v3/partials/tools.html @@ -1093,6 +1093,12 @@

Plugin Health

Use TLS (a password without TLS crosses the network in the clear) +

@@ -1132,6 +1138,15 @@

Plugin Health

if (clearBtn) clearBtn.addEventListener('click', () => clearMqttPassword()); const clearTokenBtn = document.getElementById('mqtt-clear-api-token'); if (clearTokenBtn) clearTokenBtn.addEventListener('click', () => clearMqttApiToken()); + // The cleartext opt-in only means something while TLS is off. + const tlsBox = document.getElementById('mqtt-tls'); + const allowRow = document.getElementById('mqtt-allow-insecure-row'); + if (tlsBox && allowRow) { + tlsBox.addEventListener('change', () => { + allowRow.classList.toggle('hidden', tlsBox.checked); + allowRow.classList.toggle('flex', !tlsBox.checked); + }); + } } window.loadMqttBridge = function() { @@ -1160,6 +1175,9 @@

Plugin Health

on_demand_duration: val('mqtt-duration') === '' ? null : val('mqtt-duration'), log_level: val('mqtt-log-level'), mqtt_tls: !!(document.getElementById('mqtt-tls') || {}).checked, + // The server refuses a password with TLS off unless this is set + // (CWE-319); it is off until the user ticks it. + allow_insecure_mqtt: !!(document.getElementById('mqtt-allow-insecure') || {}).checked, }; // Only send a password when one was typed; blank means "leave it alone". const pw = val('mqtt-password'); From 2978f5907e498d246a4eebdb22e1aed7ad13822c Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:52:21 -0400 Subject: [PATCH 2/6] fix(web-ui): stop the Overview reconciliation poll from running forever The reconciliation banner script in partials/overview.html re-asked /api/v3/plugins/reconciliation-status every 2 s until the answer said done, with no limit. The route answers done: false whenever ledmatrix_reconciliation.json is missing or unreadable, which happens when _run_startup_reconciliation raises before writing it or when /tmp is cleaned under a long-running web service (reconciliation runs once per process). The browser then sent that request every 2 s for as long as the page stayed open, on every tab, since the poll was never tied to the Overview being visible. The poll now gives up after 30 tries (a minute) and runs only while the Overview is the active, visible tab, registered with LEDVisibility under its own key like the other partials' pollers. Dismissing the banner ends it too. Test: test/js/unit/test_overview_reconciliation_poll.js runs the shipped script in a vm with fake timers and fetch. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 + test/js/README.md | 1 + test/js/run_all.js | 3 +- .../unit/test_overview_reconciliation_poll.js | 137 ++++++++++++++++++ .../templates/v3/partials/overview.html | 51 ++++++- 5 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 test/js/unit/test_overview_reconciliation_poll.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a14e4b0b..9065e5f29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -564,6 +564,14 @@ policies are unchanged. unchecked the form now shows "Allow without TLS (trusted network)", prefilled from the saved settings. It is off until ticked, so the server still refuses a cleartext password by default. +- The Overview's plugin-config warning check stops polling. It asked + `/api/v3/plugins/reconciliation-status` every 2 s until startup + reconciliation reported done, and the route reports not done whenever its + status file is missing: reconciliation raised before writing it, or /tmp + was cleaned under a long-running web service. The page then sent that + request every 2 s for as long as it stayed open, whichever tab was showing. + It now gives up after a minute and only polls while the Overview is on + screen. - An on-demand session that ends during scheduled-off hours, by expiring or being stopped, blanks the panel within about a second. It used to stay on until the next minute, because the once-a-minute schedule check had diff --git a/test/js/README.md b/test/js/README.md index cc0c1b4f8..4a020fc6c 100644 --- a/test/js/README.md +++ b/test/js/README.md @@ -53,6 +53,7 @@ server has none. | `unit/test_store_registry_fields.js` | no | The store card's registry fields from `plugins_manager.js`: the commit that introduced the listed version (a hex SHA only, linked to that tree), the "Needs LEDMatrix X+" warning, a card from an older registry without either, and `isStorePluginInstalled` answering to `aliases` | | `unit/test_page_registry.js` | no | The page lifecycle in `js/core/registry.js` (a minimal DOM shim): one `init` per `data-page` root, `destroy` and an aborted `ctx.signal` when htmx swaps it away, a vetoed swap keeps it, lazy page modules, a root removed without htmx swept on the next swap | | `unit/test_core_modules.js` | no | `js/core/api.js` (JSON envelope, HTTP/`status: error`/network errors, abort passthrough, the #683 login redirect, same-server paths only) and `js/core/facade.js` (`window.LEDMatrix`, deprecated aliases) | +| `unit/test_overview_reconciliation_poll.js` | no | The Overview's reconciliation-banner poll from `partials/overview.html`, run in a vm: it gives up after a bounded number of requests when the status never says done, runs only while the Overview is on screen (`LEDVisibility`, its own key), and stops once the banner is shown | | `unit/test_plugin_action_delegation.js` | no | The document-level card-action delegation and `handlePluginAction` from `plugins_manager.js`, run with the handler inside an IIFE as in the real file: each action is handled once, a Starlark app uninstall goes to `DELETE /starlark/apps/`, and an uninstall is confirmed once | | `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup | | `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry | diff --git a/test/js/run_all.js b/test/js/run_all.js index 411225015..47bf77b35 100755 --- a/test/js/run_all.js +++ b/test/js/run_all.js @@ -23,7 +23,8 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js', 'unit/test_update_all.js', 'unit/test_inline_handler_escaping.js', 'unit/test_plugin_action_delegation.js', 'unit/test_file_upload_widget.js', 'unit/test_store_registry_fields.js', 'unit/test_restart_banner.js', - 'unit/test_page_registry.js', 'unit/test_core_modules.js']; + 'unit/test_page_registry.js', 'unit/test_core_modules.js', + 'unit/test_overview_reconciliation_poll.js']; const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js', 'dom/test_tools_sections.js', 'dom/test_cache_page.js', 'dom/test_durations_page.js', 'dom/test_operation_history_page.js', diff --git a/test/js/unit/test_overview_reconciliation_poll.js b/test/js/unit/test_overview_reconciliation_poll.js new file mode 100644 index 000000000..9b75da11a --- /dev/null +++ b/test/js/unit/test_overview_reconciliation_poll.js @@ -0,0 +1,137 @@ +// The Overview's "Plugin Config Warning" poll must end. +// +// The banner script in partials/overview.html asks +// /api/v3/plugins/reconciliation-status every 2 s until startup reconciliation +// says it is done. The route answers done: false whenever its status file is +// missing -- reconciliation raised before writing it, or /tmp was cleaned +// under a long-running web service -- so the poll used to run every 2 s for +// as long as the page stayed open, on every tab. It now gives up after a +// bounded number of tries and runs only while the Overview is on screen +// (LEDVisibility, like the other partials' pollers). +// +// Runs the shipped inline script in a vm with fake timers, fetch and DOM -- +// no jsdom and no server needed. + +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const PARTIAL = path.resolve(__dirname, '../../../web_interface/templates/v3/partials/overview.html'); + +let pass = 0, fail = 0; +const ok = (label, cond, extra) => cond + ? (pass++, console.log(' ok ' + label)) + : (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : ''))); + +function bannerScript() { + const html = fs.readFileSync(PARTIAL, 'utf8'); + const scripts = [...html.matchAll(/