From c7eb09db1d5c89c48d02cc6532da5c6c7c774274 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Wed, 9 Sep 2026 11:20:18 -0400 Subject: [PATCH 1/2] feat(tools): manage the MQTT bridge and Pixlet editor from the Tools tab Both shipped in #538 with no interface at all: the bridge could only be configured by editing integrations/mqtt_bridge/bridge_config.json over SSH, and the Pixlet editor was a script you had to know existed. Neither appeared anywhere in the web UI. They are now two sections in the Tools tab. **MQTT bridge.** Service state (installed / running / stopped), install, start, stop and restart, and a form for every setting: broker host and port, username, TLS, command topic, client id, API base, request timeout, on-demand duration and log level. The password is write-only. GET returns every other field plus a password_set flag, never the value -- this interface has no authentication, so anything it hands back is readable by anyone who can reach the port. Submitting a blank password keeps whatever is stored; there is an explicit Clear for removing one. The file is written through a temp file in the same directory and chmod 0600, so a crash mid-write cannot leave a config the bridge would refuse to load. Values are range-checked server-side (port, timeouts, log level, http(s) API base). **Pixlet editor.** Lists the apps on disk and starts a session per app, with a banner showing which app is open, a link to the editor, a countdown, and Stop. The banner survives a page reload because status is polled while a session runs, and Edit is disabled while one is open. Two changes to the script make that safe to drive from a browser: - It binds 0.0.0.0 by default (PIXLET_EDITOR_HOST overrides). Loopback-only meant the URL shown in a browser was unreachable and needed an SSH tunnel, which is the confusing path. The no-auth argument for loopback does not hold when web_interface already serves 0.0.0.0:5000 unauthenticated and can reconfigure everything. - The session ends by itself after PIXLET_EDITOR_TIMEOUT (default 30 min), enforced by `timeout` inside the script rather than by the caller. The display is stopped while editing, so a forgotten session would otherwise leave the panel dark and read as broken hardware -- the timeout is the whole reason a Start button is defensible. Two bugs found while testing that lifecycle, both of which would have bitten the Stop button: - GNU timeout runs its child in a NEW process group, so signalling the script's group killed only bash and orphaned pixlet with the port still bound. Now --foreground keeps one group, and the EXIT trap kills the recorded child too. - The liveness check treated a zombie as running: the script is a child of the web process and stays unreaped after exiting, and signal 0 succeeds against a zombie, so a finished session would have read as running forever. It now reaps non-blockingly and falls back to /proc state for a process that is not ours. Covered by test/js/dom/test_tools_sections.js (26 assertions, real DOM against the server-rendered partial): form prefill, the blank-means-unchanged password contract, the running-session banner and countdown, and that the editor link uses the host the page was loaded from. Also chmod +x on both scripts, which the docs tell you to run directly. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 --- scripts/install/install_mqtt_bridge.sh | 0 scripts/utils/pixlet_config_editor.sh | 78 ++- test/js/README.md | 1 + test/js/dom/test_tools_sections.js | 116 ++++ test/js/run_all.js | 3 +- web_interface/blueprints/api_v3.py | 508 ++++++++++++++++++ .../templates/v3/partials/tools.html | 374 +++++++++++++ 7 files changed, 1067 insertions(+), 13 deletions(-) mode change 100644 => 100755 scripts/install/install_mqtt_bridge.sh mode change 100644 => 100755 scripts/utils/pixlet_config_editor.sh create mode 100644 test/js/dom/test_tools_sections.js diff --git a/scripts/install/install_mqtt_bridge.sh b/scripts/install/install_mqtt_bridge.sh old mode 100644 new mode 100755 diff --git a/scripts/utils/pixlet_config_editor.sh b/scripts/utils/pixlet_config_editor.sh old mode 100644 new mode 100755 index 8437623b..b0588e16 --- a/scripts/utils/pixlet_config_editor.sh +++ b/scripts/utils/pixlet_config_editor.sh @@ -18,20 +18,33 @@ # ./scripts/utils/pixlet_config_editor.sh # list installed apps # ./scripts/utils/pixlet_config_editor.sh # edit # -# Binds loopback only, and there is deliberately no flag to change that: -# `pixlet serve` has no authentication, and anything that can reach it can -# rewrite the app's config. To edit from another machine, forward the port -- -# which authenticates as SSH and leaves nothing listening on the LAN: +# Binds the LAN by default, matching the web interface, which already serves +# 0.0.0.0:5000 with no authentication -- anything that can reach this can +# already reconfigure the display there. `pixlet serve` has no authentication +# either, so treat both the same way: fine on a home network, not on an open +# one. Override the bind and the session length with: +# +# PIXLET_EDITOR_HOST=127.0.0.1 ./scripts/utils/pixlet_config_editor.sh +# PIXLET_EDITOR_TIMEOUT=600 ./scripts/utils/pixlet_config_editor.sh +# +# For loopback-only editing from another machine, forward the port instead: # # ssh -L 8080:localhost:8080 pi@ledpi.local +# +# The session always ends by itself after PIXLET_EDITOR_TIMEOUT seconds +# (default 30 minutes). The display is stopped while editing, so a session +# left open would otherwise leave the panel dark indefinitely -- the timeout +# is what makes it safe to start one from the web interface. set -eu PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd) APPS_DIR="$PROJECT_ROOT_DIR/starlark-apps" PORT="${PIXLET_EDITOR_PORT:-8080}" -# Loopback only. See the header: pixlet serve is unauthenticated. -BIND_HOST="127.0.0.1" +# LAN by default; see the header for why, and how to force loopback. +BIND_HOST="${PIXLET_EDITOR_HOST:-0.0.0.0}" +# Hard stop, so the display cannot be left off by a forgotten session. +EDITOR_TIMEOUT="${PIXLET_EDITOR_TIMEOUT:-1800}" APP_ID="${1:-}" @@ -109,6 +122,19 @@ fi # failure worth guarding against. cleanup() { echo "" + # Kill the serve child explicitly. `timeout` is started with --foreground so + # it shares this script's process group (without that it makes its own, and + # a group signal aimed at this script would orphan pixlet with the port + # still bound). Belt and braces: signal the recorded pid too, because a + # group signal only reaches it while the group is shared. + if [ -n "${SERVE_PID:-}" ] && kill -0 "$SERVE_PID" 2>/dev/null; then + kill -TERM "$SERVE_PID" 2>/dev/null || true + for _ in 1 2 3 4 5 6 7 8 9 10; do + kill -0 "$SERVE_PID" 2>/dev/null || break + sleep 0.3 + done + kill -KILL "$SERVE_PID" 2>/dev/null || true + fi if [ "$DISPLAY_WAS_RUNNING" = true ]; then echo "Restarting the display service..." sudo systemctl restart ledmatrix || echo "⚠ Could not restart ledmatrix - do it by hand" @@ -122,22 +148,50 @@ if [ "$DISPLAY_WAS_RUNNING" = true ]; then sudo systemctl stop ledmatrix fi +if [ "$BIND_HOST" = "0.0.0.0" ]; then + REACH_HOST="$(hostname).local" +else + REACH_HOST="localhost" +fi + echo "" echo "Editing: $APP_ID" echo "App file: $STAR_FILE" -echo "URL: http://localhost:$PORT/" +echo "URL: http://$REACH_HOST:$PORT/" echo "" -echo "Listening on localhost only -- pixlet serve has no authentication." -echo "From another machine, forward the port:" -echo " ssh -L $PORT:localhost:$PORT $(whoami)@$(hostname)" +if [ "$BIND_HOST" = "0.0.0.0" ]; then + echo "Reachable on the LAN, and pixlet serve has no authentication -- the" + echo "same footing as the web interface on port 5000. Set" + echo "PIXLET_EDITOR_HOST=127.0.0.1 to keep it to this machine." +else + echo "Listening on $BIND_HOST only. From another machine, forward the port:" + echo " ssh -L $PORT:localhost:$PORT $(whoami)@$(hostname)" +fi echo "" echo "Changes save straight to the real config as you make them." echo "Press Ctrl+C when finished - the display restarts automatically." +echo "This session stops on its own after ${EDITOR_TIMEOUT}s regardless." echo "" cd "$APP_DIR" -"$PIXLET_BIN" serve "$(basename "$STAR_FILE")" \ +# `timeout` owns the hard stop rather than the caller: the trap above restarts +# the display however this exits, so a session that outlives the person who +# started it still gives the panel back. Exit 124 is timeout's own code for +# "expired", which is a normal end here, not a failure. +# --foreground: stay in this script's process group so one signal reaches the +# whole session. Backgrounded + `wait` so the EXIT trap can run while the child +# is still alive; a foreground child would leave bash waiting on it instead. +timeout --foreground "$EDITOR_TIMEOUT" "$PIXLET_BIN" serve "$(basename "$STAR_FILE")" \ --host "$BIND_HOST" \ --port "$PORT" \ --no-browser \ - --saveconfig "$CONFIG_FILE" + --saveconfig "$CONFIG_FILE" & +SERVE_PID=$! + +status=0 +wait "$SERVE_PID" || status=$? +if [ "$status" -eq 124 ]; then + echo "Session reached its ${EDITOR_TIMEOUT}s limit." + status=0 +fi +exit "$status" diff --git a/test/js/README.md b/test/js/README.md index dd91132c..023e0d9a 100644 --- a/test/js/README.md +++ b/test/js/README.md @@ -39,6 +39,7 @@ nothing is listening, so it stays useful in a bare checkout. | `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 | | `dom/test_no_double_fetch.js` | yes | Loads the **whole** `plugins_manager.js` and counts requests: typing in the store search must filter the cached list, not refetch `/api/v3/plugins/store/list` | +| `dom/test_tools_sections.js` | yes | The Tools tab's MQTT bridge and Pixlet editor sections: form prefill, the write-only password (blank means unchanged), the running-session banner and countdown, and that the editor link points at the host you loaded the page from | Point the DOM suites at a rig with a full plugin set when it matters — a dev box with two plugins installed will pass while exercising very little. diff --git a/test/js/dom/test_tools_sections.js b/test/js/dom/test_tools_sections.js new file mode 100644 index 00000000..9325550c --- /dev/null +++ b/test/js/dom/test_tools_sections.js @@ -0,0 +1,116 @@ +// Real-DOM (jsdom) test of the two new Tools sections. The HTML is the actual +// server-rendered /partials/tools, and the payloads are the real API's, so a +// renamed field or a changed shape fails this rather than passing quietly. +const http = require('http'); +const { JSDOM, VirtualConsole } = require('jsdom'); + +const BASE = process.env.BASE || 'http://localhost:5000'; +const get = p => new Promise((res, rej) => + http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej)); + +(async () => { + const partial = await get('/partials/tools'); + const bridge = JSON.parse(await get('/api/v3/integrations/mqtt-bridge')); + const apps = JSON.parse(await get('/api/v3/starlark/editor/apps')); + + const errs = []; + const vc = new VirtualConsole(); + vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0])); + + // Controllable fetch: serve the real payloads, and let tests swap in others. + let editorStatus = { status: 'success', data: { running: false } }; + let bridgePayload = bridge; + let onPut = null; + const stubFetch = (url, opts) => { + const u = String(url); + if (onPut && opts && opts.method === 'PUT') onPut(JSON.parse(opts.body)); + let body = { status: 'success', data: {} }; + if (u.includes('/integrations/mqtt-bridge')) body = bridgePayload; + else if (u.includes('/starlark/editor/status')) body = editorStatus; + else if (u.includes('/starlark/editor/apps')) body = apps; + return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body) }); + }; + + // runScripts:'dangerously' so the partial's own From 9254812c4c5769eb36dd78b83e8967a840fc0fd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 02:32:18 +0000 Subject: [PATCH 2/2] fix(tools): don't force PIXLET_EDITOR_HOST, and stop hard-assuming the MQTT bridge is uninstalled in tests CodeRabbit review on #544: - start_pixlet_editor() unconditionally set PIXLET_EDITOR_HOST=0.0.0.0, overriding any operator-configured loopback-only value and always exposing the unauthenticated `pixlet serve` dev process on the LAN (CWE-1188). Use env.setdefault() so it's only a default; also stop hardcoding 'host' in the recorded session state so status reporting matches what was actually used. - test_tools_sections.js asserted an Install button always renders, which assumes the bridge service is never installed on the test/dev host. Branch on the same service.installed flag the template itself renders from. Adds regression coverage in test_starlark_pixlet_routes.py for the setdefault-vs-override behavior (both the "nothing configured" and "operator pinned loopback" cases). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01S3bPMESe2TfrGvbs1ef9c5 --- test/js/dom/test_tools_sections.js | 11 +++- .../test_starlark_pixlet_routes.py | 63 +++++++++++++++++++ web_interface/blueprints/api_v3.py | 9 ++- 3 files changed, 79 insertions(+), 4 deletions(-) diff --git a/test/js/dom/test_tools_sections.js b/test/js/dom/test_tools_sections.js index 9325550c..6668cf80 100644 --- a/test/js/dom/test_tools_sections.js +++ b/test/js/dom/test_tools_sections.js @@ -63,7 +63,16 @@ const get = p => new Promise((res, rej) => !/s3cret|mqtt_password"\s*:\s*"/.test(window.document.body.innerHTML)); ok('state badge rendered', ($('mqtt-bridge-state').textContent || '').trim().length > 0, $('mqtt-bridge-state').textContent); - ok('not-installed shows an Install button', !!$('btn-mqtt-install')); + // The dev/test machine may or may not actually have the bridge service + // installed -- branch on the same `service.installed` flag the template + // itself renders from, rather than hard-assuming "not installed" (which + // fails on any host where the service happens to be present). + if (bridge.data.service.installed) { + ok('installed shows Restart/Start-Stop buttons, not Install', + !$('btn-mqtt-install') && !!$('btn-mqtt-restart') && !!$('btn-mqtt-startstop')); + } else { + ok('not-installed shows an Install button', !!$('btn-mqtt-install')); + } ok('config path shown', $('mqtt-bridge-body').textContent.includes('bridge_config.json')); ok('env override hint shown', $('mqtt-bridge-body').textContent.includes('LEDMATRIX_MQTT_')); diff --git a/test/web_interface/test_starlark_pixlet_routes.py b/test/web_interface/test_starlark_pixlet_routes.py index 24fab51e..10f55314 100644 --- a/test/web_interface/test_starlark_pixlet_routes.py +++ b/test/web_interface/test_starlark_pixlet_routes.py @@ -748,3 +748,66 @@ def test_browse_hands_the_token_to_the_repository(self, client): client.get('/api/v3/starlark/repository/browse') repo.assert_called_once_with(github_token='ghp_configured') + + +class TestPixletEditorHostDefaultsButDoesNotOverride: + """PIXLET_EDITOR_HOST must default to 0.0.0.0, never force it. + + A browser reaching the editor is remote by definition, so a session with + nothing configured has to bind more than loopback to be reachable at + all -- but an operator who has deliberately pinned PIXLET_EDITOR_HOST to + loopback (e.g. in the systemd unit's Environment=, to edit only over an + SSH tunnel) must keep that setting. The previous unconditional + ``env['PIXLET_EDITOR_HOST'] = '0.0.0.0'`` overrode it every time, + always exposing the unauthenticated ``pixlet serve`` dev process on the + LAN regardless (CodeQL CWE-1188). + """ + + @pytest.fixture + def app_dir(self, tmp_path): + d = tmp_path / "demo_app" + d.mkdir() + (d / "demo_app.star").write_text("def main():\n pass\n") + return d + + def _start(self, client, app_dir, tmp_path, operator_host): + from web_interface.blueprints import api_v3 as mod + + script = tmp_path / "pixlet_config_editor.sh" + script.write_text("#!/bin/bash\n") + state_file = tmp_path / "pixlet_editor_state.json" + captured = {} + + class FakeProcess: + pid = 424242 + + def fake_popen(cmd, cwd=None, env=None, stdout=None, stderr=None, + start_new_session=None): + captured['env'] = env + return FakeProcess() + + with patch.object(mod, '_validate_starlark_app_path', + return_value=(app_dir, None)), \ + patch.object(mod, '_PIXLET_EDITOR_SCRIPT', script), \ + patch.object(mod, '_PIXLET_EDITOR_STATE', state_file), \ + patch.object(mod, '_find_pixlet_binary', return_value='/usr/bin/pixlet'), \ + patch.object(mod.subprocess, 'Popen', side_effect=fake_popen), \ + patch.dict(os.environ): + if operator_host is None: + os.environ.pop('PIXLET_EDITOR_HOST', None) + else: + os.environ['PIXLET_EDITOR_HOST'] = operator_host + resp = client.post('/api/v3/starlark/editor/start', + json={'app_id': app_dir.name}) + + assert resp.status_code == 200, resp.get_json() + assert 'env' in captured, "subprocess.Popen was never called" + return captured['env'] + + def test_defaults_to_0_0_0_0_when_operator_set_nothing(self, client, app_dir, tmp_path): + env = self._start(client, app_dir, tmp_path, operator_host=None) + assert env['PIXLET_EDITOR_HOST'] == '0.0.0.0' + + def test_keeps_an_operator_configured_loopback_host(self, client, app_dir, tmp_path): + env = self._start(client, app_dir, tmp_path, operator_host='127.0.0.1') + assert env['PIXLET_EDITOR_HOST'] == '127.0.0.1' diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index fbbc7eb2..15d96144 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -10087,8 +10087,11 @@ def start_pixlet_editor(): env['PIXLET_EDITOR_PORT'] = str(port) env['PIXLET_EDITOR_TIMEOUT'] = str(timeout_s) # A browser reaching this endpoint is remote by definition, so the - # session has to listen on more than loopback to be usable at all. - env['PIXLET_EDITOR_HOST'] = '0.0.0.0' + # session needs to listen on more than loopback to be usable at all -- + # but only as a *default*. An operator who has already set + # PIXLET_EDITOR_HOST (e.g. to keep it loopback-only even from the web + # UI) must not have that overridden here. + env.setdefault('PIXLET_EDITOR_HOST', '0.0.0.0') log_path = Path(tempfile.gettempdir()) / 'ledmatrix_pixlet_editor.log' log_handle = open(log_path, 'w', encoding='utf-8') # noqa: SIM115 - owned by the child @@ -10107,7 +10110,7 @@ def start_pixlet_editor(): now = time.time() state = {'pid': process.pid, 'app_id': app_dir.name, 'port': port, 'timeout': timeout_s, 'started_at': now, 'deadline': now + timeout_s, - 'host': '0.0.0.0', 'log': str(log_path)} + 'host': env['PIXLET_EDITOR_HOST'], 'log': str(log_path)} try: with open(_PIXLET_EDITOR_STATE, 'w', encoding='utf-8') as handle: json.dump(state, handle)