From 1e80cf8bf5dc99185731faedcc67306c971832d1 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:21:38 +0200 Subject: [PATCH 1/2] fix: UDP-transport hardening, bridge tracker coverage, CI repairs; release 0.7.2 --- .github/workflows/python.yml | 6 +- ACCEPTANCE.md | 98 +++++++++++- README.md | 6 +- app/ios/Flutter/AppFrameworkInfo.plist | 4 +- app/pubspec.yaml | 2 +- data/__init__.py | 2 +- data/mesh_ingestor/config.py | 25 ++- .../protocols/meshtastic_udp_socket.py | 14 +- data/tools/capture_udp_fixtures.py | 4 +- matrix/Cargo.lock | 2 +- matrix/Cargo.toml | 2 +- matrix/src/main.rs | 146 ++++++++++++++++++ tests/test_capture_udp_fixtures_unit.py | 114 ++++++++++++++ tests/test_config_unit.py | 53 +++++++ tests/test_meshtastic_udp_socket_unit.py | 15 +- web/lib/potato_mesh/config.rb | 6 +- web/package-lock.json | 4 +- web/package.json | 2 +- web/views/layouts/app.erb | 12 +- 19 files changed, 480 insertions(+), 37 deletions(-) create mode 100644 tests/test_capture_udp_fixtures_unit.py diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 3db3aab8..ae2637c1 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -37,9 +37,13 @@ jobs: with: python-version: "3.13" - name: Install dependencies + # Install from the manifest, not a hand-picked list: PR #838 added + # `cryptography` to data/requirements.txt and the hand-list here missed + # it, breaking test collection on main. requirements.txt already carries + # the dev deps (black/pytest/pytest-cov) too. run: | python -m pip install --upgrade pip - pip install black pytest pytest-cov meshtastic meshcore + pip install -r data/requirements.txt - name: Test with pytest and coverage run: | mkdir -p reports diff --git a/ACCEPTANCE.md b/ACCEPTANCE.md index a1265ea6..ec287e3b 100644 --- a/ACCEPTANCE.md +++ b/ACCEPTANCE.md @@ -226,7 +226,6 @@ grep -A14 '^coverage:' .codecov.yml `target: 100%` and `threshold: 10%`. Per-language coverage is produced by the suites in B1 (SimpleCov for Ruby, `pytest-cov`, `cargo llvm-cov`, `flutter --coverage`, V8 for JS) and enforced server-side by Codecov. -> See [§ Known gaps](#known-gaps): the `patch` block is currently missing. ### B3 — 100% API documentation (language standard) ```bash @@ -432,9 +431,6 @@ These deviate from the bar above and are surfaced by the Phase 2 environment audit. They are **FAIL** until fixed, but a reviewer should attribute them to the existing codebase, not to the change under review. -- **B2 — `.codecov.yml` has no `patch` block.** It defines only - `coverage.status.project.default` (target 100% / threshold 10%); `CLAUDE.md` - requires the same on **patch**. Fix tracked in the Phase 2 audit. - **B4 — header-check exemptions are conventional, not codified.** Formats without comment syntax (JSON fixtures under `tests/`, `*.lock` files, binary assets) cannot carry the notice; there is no committed allow-list or CI check @@ -513,7 +509,7 @@ git grep -nA2 'def version_fallback' -- web/lib/potato_mesh/config.rb `{ nodes, messages, telemetry }`, each metric carrying integer `{ hour, day, week, month }`, with `sampled` still present and `false`. The old flat keys (`active_nodes`, integer-valued `meshcore`/`meshtastic`) are **gone** — -this is the intended, versioned break. `version_fallback` returns `"0.7.1"`, and +this is the intended, versioned break. `version_fallback` returns `"0.7.2"`, and `test_version_sync.py` **passes** — the bump is applied in lockstep across all five language manifests (`data.VERSION`, `Config.version_fallback`, `web/package.json`, `app/pubspec.yaml`, `matrix/Cargo.toml`; `matrix/Cargo.lock` @@ -2430,3 +2426,95 @@ capture touches only already-built DOM), **CB-A1** (every bulk collection still backfills; the accumulators it merges into are raw, which is the shape the model already expects), and **B1** (all suites). Frontend-only: no POST/GET/event contract change, so `CONTRACTS.md` and the Python suite are unaffected. + +--- + +## Bugfix: UDP-transport hardening & bridge failure-tracker coverage + +Four small defects fixed as a batch. The first two live on the passive +UDP-transport surface (PR #838), which shipped with **no SPEC/ACCEPTANCE +feature section** — the contract was silent there, so these are its first +command-backed checks. The third closes a test-coverage gap (D9/B1) in the +Matrix bridge's poison-message tracker (PR #839). The fourth repairs the +Python CI dependency drift that turned `main` red after #838. Formatting +drift found alongside (rufo on `web/views/layouts/app.erb`, the cause of the +red Ruby workflow on `main`) is covered by the existing **B5**, no new check +needed. + +### UH-A1 — malformed `PRIMARY_CHANNEL_KEY` fails at import, not in the retry loop +```bash +( . .venv/bin/activate && pytest -q tests/test_config_unit.py -k PrimaryChannelKey ) +PRIMARY_CHANNEL_KEY='not-base64!!' python -c 'import data.mesh_ingestor.config' # exits non-zero, names the var +``` +**Expected:** the unit tests pass; the one-liner fails with +`ValueError: PRIMARY_CHANNEL_KEY is not valid base64: 'not-base64!!'. …`. +`config.py` validates the key as base64 **at import time** (decoding exactly as +`meshtastic_udp_decode.expand_default_key` later would), matching the existing +`TRANSPORT`/`PROTOCOL` import-time validation. Previously the raw value was +stored unchecked and only decoded lazily inside `channel_hash` / +`decrypt_meshpacket`, so with `PRIMARY_CHANNEL_NAME` set a malformed key raised +`binascii.Error` out of `connect()` — caught by `daemon._connect_interface`'s +generic `except Exception`, which logged only "Failed to create mesh interface" +and retried forever: the service never ingested and never surfaced the cause. +Valid keys of any decodable length (1-byte default `AQ==`, 16/32-byte PSKs) are +accepted unchanged; blank still falls back to `AQ==`. + +### UH-A2 — UDP multicast sockets bind the group address, never all interfaces +```bash +( . .venv/bin/activate && pytest -q tests/test_meshtastic_udp_socket_unit.py tests/test_capture_udp_fixtures_unit.py ) +git grep -n 'bind(("", ' -- data/ +``` +**Expected:** tests pass; the grep prints nothing. Both +`data/mesh_ingestor/protocols/meshtastic_udp_socket.py` and its documented +mirror `data/tools/capture_udp_fixtures.py` bind `(group, port)` instead of the +wildcard `("", port)` (CodeQL *py/bind-socket-all-network-interfaces*): the +kernel then delivers only datagrams addressed to the multicast group, so +unicast traffic sent to the port on any local interface never reaches the +socket. Receive behavior for "Mesh via UDP" traffic is unchanged (the transport +is multicast-only); binding a group address is POSIX behavior (Linux/macOS, the +platforms the transport targets). The capture tool, previously untested, gains +unit coverage of its socket plumbing. + +### UH-A3 — bridge failure-tracker success-path reset is covered — D9/B1 +```bash +( cd matrix && cargo test poll_once_clears_failure_tracker_when_failed_message_recovers ) +``` +**Expected:** pass. The most common real-world sequence — a message fails a +poll transiently, then succeeds on the next — executes the success-path reset +in `poll_once` (`matrix/src/main.rs`: clear `failing_msg_id` / +`failing_msg_attempts` after a successful `handle_message`), which **no prior +test reached**: the watermark test stops at the first failure and the poison +test's tracker is already cleared by the skip before the next success. The test +arms the tracker with a 500 node lookup, swaps the mock to 200, re-polls, and +asserts the tracker is cleared, the watermark advances through the recovered +message to the batch tail, and the message is not reprocessed. Verified by +mutation: with the reset disabled (`if false && …`) only this test fails — +every other test stays green, which is the coverage gap this closes. + +### UH-A4 — Python CI installs the ingestor deps from the manifest +```bash +grep -n 'pip install -r data/requirements.txt' .github/workflows/python.yml +``` +**Expected:** one match in the workflow's install step. The workflow previously +hand-listed packages (`black pytest pytest-cov meshtastic meshcore`), which +silently drifted from `data/requirements.txt` when PR #838 added +`cryptography>=42.0.0` — every Python CI run on `main` since then failed test +collection with `ModuleNotFoundError: No module named 'cryptography'`. +Installing from the manifest (which also carries the dev deps) keeps CI in +lockstep with the documented [Setup](#setup-one-time) command and removes the +drift channel. + +### UH-R1 — Regression: prior acceptance still holds +```bash +( . .venv/bin/activate && pytest -q tests/ ) && ( . .venv/bin/activate && black --check ./ ) +( cd matrix && cargo test --all --all-features && cargo fmt --all -- --check \ + && cargo clippy --all-targets --all-features -- -D warnings ) +( cd web && bundle exec rspec ) && ( cd web && npm test ) && ( cd web && bundle exec rufo --check . ) +``` +**Expected:** all green, including **B5** (rufo/black — `views/layouts/app.erb` +re-formatted). At risk and explicitly required to stay green: the UDP provider +suite (`test_meshtastic_udp_unit.py` — the provider consumes the validated key +and group-bound socket unchanged), `test_config_unit.py`'s UDP-var defaults +(blank-fallback semantics unchanged), and the bridge watermark/poison tests +(the new test only adds coverage; `poll_once` is untouched). The web app, +federation wire, and Flutter app are not touched by this batch. diff --git a/README.md b/README.md index 97403adc..7811121a 100644 --- a/README.md +++ b/README.md @@ -366,9 +366,9 @@ docker pull ghcr.io/l5yth/potato-mesh-matrix-bridge-linux-arm64:latest docker pull ghcr.io/l5yth/potato-mesh-matrix-bridge-linux-armv7:latest # version-pinned examples -docker pull ghcr.io/l5yth/potato-mesh-web-linux-amd64:v0.7.1 -docker pull ghcr.io/l5yth/potato-mesh-ingestor-linux-amd64:v0.7.1 -docker pull ghcr.io/l5yth/potato-mesh-matrix-bridge-linux-amd64:v0.7.1 +docker pull ghcr.io/l5yth/potato-mesh-web-linux-amd64:v0.7.2 +docker pull ghcr.io/l5yth/potato-mesh-ingestor-linux-amd64:v0.7.2 +docker pull ghcr.io/l5yth/potato-mesh-matrix-bridge-linux-amd64:v0.7.2 ``` Note: `latest` is only published for non-prerelease versions. Pre-release tags diff --git a/app/ios/Flutter/AppFrameworkInfo.plist b/app/ios/Flutter/AppFrameworkInfo.plist index 43f2802f..834ae9d7 100644 --- a/app/ios/Flutter/AppFrameworkInfo.plist +++ b/app/ios/Flutter/AppFrameworkInfo.plist @@ -15,11 +15,11 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 0.7.1 + 0.7.2 CFBundleSignature ???? CFBundleVersion - 0.7.1 + 0.7.2 MinimumOSVersion 14.0 diff --git a/app/pubspec.yaml b/app/pubspec.yaml index b9a0b88c..1b69da51 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -3,7 +3,7 @@ name: potato_mesh_reader description: Meshtastic Reader — read-only view for PotatoMesh messages. publish_to: "none" -version: 0.7.1 +version: 0.7.2 environment: sdk: ">=3.4.0 <4.0.0" diff --git a/data/__init__.py b/data/__init__.py index be498664..511e823d 100644 --- a/data/__init__.py +++ b/data/__init__.py @@ -18,7 +18,7 @@ message information before forwarding it to the accompanying web application. """ -VERSION = "0.7.1" +VERSION = "0.7.2" """Semantic version identifier shared with the dashboard and front-end.""" __version__ = VERSION diff --git a/data/mesh_ingestor/config.py b/data/mesh_ingestor/config.py index 175b9ee3..d837fdfb 100644 --- a/data/mesh_ingestor/config.py +++ b/data/mesh_ingestor/config.py @@ -16,6 +16,7 @@ from __future__ import annotations +import base64 import math import os from datetime import datetime, timezone @@ -90,8 +91,28 @@ PRIMARY_CHANNEL_ONLY = os.environ.get("PRIMARY_CHANNEL_ONLY") == "1" """When ``True``, only channel index 0 (PRIMARY) is ingested; all else is dropped.""" -PRIMARY_CHANNEL_KEY = os.environ.get("PRIMARY_CHANNEL_KEY", "AQ==").strip() or "AQ==" -"""Base64 PSK used to decrypt the primary channel; defaults to the Meshtastic default key.""" +_raw_primary_key = os.environ.get("PRIMARY_CHANNEL_KEY", "AQ==").strip() or "AQ==" +try: + # Decode exactly the way meshtastic_udp_decode.expand_default_key later + # will, so a malformed key fails HERE with a clear startup error (parity + # with the TRANSPORT/PROTOCOL validation above) instead of surfacing as a + # lazy binascii.Error out of connect() that the daemon's generic + # reconnect handler would swallow and retry forever. + # binascii.Error and UnicodeEncodeError both subclass ValueError. + base64.b64decode(_raw_primary_key.encode("ascii"), validate=True) +except ValueError as exc: + raise ValueError( + f"PRIMARY_CHANNEL_KEY is not valid base64: {_raw_primary_key!r}. " + "Provide the channel PSK exactly as printed by `meshtastic --info` " + '(e.g. "AQ==" for the default key).' + ) from exc + +PRIMARY_CHANNEL_KEY = _raw_primary_key +"""Base64 PSK used to decrypt the primary channel; defaults to the Meshtastic default key. + +Validated as base64 at import time: a malformed value raises :class:`ValueError` +immediately (like an unknown :data:`TRANSPORT`), rather than failing lazily +inside ``channel_hash``/``decrypt_meshpacket`` during ``connect()``.""" PRIMARY_CHANNEL_NAME = os.environ.get("PRIMARY_CHANNEL_NAME", "").strip() """Name of the primary channel (e.g. ``"MediumFast"``), used to compute the diff --git a/data/mesh_ingestor/protocols/meshtastic_udp_socket.py b/data/mesh_ingestor/protocols/meshtastic_udp_socket.py index dda8321b..06389754 100644 --- a/data/mesh_ingestor/protocols/meshtastic_udp_socket.py +++ b/data/mesh_ingestor/protocols/meshtastic_udp_socket.py @@ -35,8 +35,11 @@ def open_multicast_socket(group: str, port: int) -> socket.socket: Creates an IPv4 UDP socket suitable for receive-only Meshtastic "Mesh via UDP" multicast traffic: address reuse is enabled (so multiple local listeners, or quick restarts, don't collide on the port), the socket is - bound to the wildcard address, and it joins *group* via - ``IP_ADD_MEMBERSHIP`` on the default interface (``0.0.0.0``). + bound to the *group* address itself — never the wildcard, so unicast + datagrams sent to the port on any local interface are not delivered to + it — and it joins *group* via ``IP_ADD_MEMBERSHIP`` on the default + interface (``0.0.0.0``). Binding a multicast group address is POSIX + behaviour (Linux/macOS, the platforms this transport is exercised on). Args: group: IPv4 multicast group address to join, e.g. ``"224.0.0.69"``. @@ -57,7 +60,12 @@ def open_multicast_socket(group: str, port: int) -> socket.socket: sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) except OSError: pass - sock.bind(("", port)) + # Bind the multicast group address, not the wildcard: the kernel then + # only delivers datagrams addressed to group:port, so unicast traffic to + # the port never reaches this socket (CodeQL: binding a socket to all + # network interfaces). POSIX-only behaviour, which matches the platforms + # this transport supports. + sock.bind((group, port)) # Joining with the wildcard interface (0.0.0.0) lets the kernel pick # the receiving interface, matching how the capture tool joins the group. mreq = socket.inet_aton(group) + socket.inet_aton("0.0.0.0") diff --git a/data/tools/capture_udp_fixtures.py b/data/tools/capture_udp_fixtures.py index 0771f556..046d2212 100755 --- a/data/tools/capture_udp_fixtures.py +++ b/data/tools/capture_udp_fixtures.py @@ -84,7 +84,9 @@ def open_multicast_socket(group: str, port: int) -> socket.socket: sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) except OSError: pass - sock.bind(("", port)) + # Bind the group address, not the wildcard, so unicast datagrams to the + # port are never delivered (mirrors meshtastic_udp_socket.py; POSIX-only). + sock.bind((group, port)) mreq = socket.inet_aton(group) + socket.inet_aton("0.0.0.0") sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) sock.settimeout(1.0) diff --git a/matrix/Cargo.lock b/matrix/Cargo.lock index 7fb8aa42..2040a3c7 100644 --- a/matrix/Cargo.lock +++ b/matrix/Cargo.lock @@ -968,7 +968,7 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "potatomesh-matrix-bridge" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "axum", diff --git a/matrix/Cargo.toml b/matrix/Cargo.toml index c81522f4..9658ff3c 100644 --- a/matrix/Cargo.toml +++ b/matrix/Cargo.toml @@ -14,7 +14,7 @@ [package] name = "potatomesh-matrix-bridge" -version = "0.7.1" +version = "0.7.2" edition = "2021" [dependencies] diff --git a/matrix/src/main.rs b/matrix/src/main.rs index e36d6437..6bc097d7 100644 --- a/matrix/src/main.rs +++ b/matrix/src/main.rs @@ -1055,6 +1055,152 @@ mod tests { ); } + /// Companion to the two failure-path tests above, covering the + /// success-path tracker reset (the `if state.failing_msg_id == Some(msg.id)` + /// clear after a successful `handle_message`): a message that failed + /// transiently and then succeeds on a later poll must clear the tracker + /// and let the watermark advance. Neither prior test executes that reset — + /// the watermark test stops at the first failure and never re-polls, and + /// in the poison test the tracker is already cleared by the skip before + /// the next message succeeds. Without this test a regression in the reset + /// (not clearing, or clearing the wrong id) would pass CI, and the stale + /// attempt count would let a single later transient failure of the same + /// message hit `MAX_FORWARD_ATTEMPTS` and drop it. + #[tokio::test] + async fn poll_once_clears_failure_tracker_when_failed_message_recovers() { + let tmp_dir = tempfile::tempdir().unwrap(); + let state_path = tmp_dir.path().join("state.json"); + let state_str = state_path.to_str().unwrap(); + + let mut server = mockito::Server::new_async().await; + + // Batch of two TEXT messages, refetched on every poll while the + // watermark is stuck (same shape as the watermark/poison tests). + let _mock_msgs = server + .mock("GET", "/api/messages") + .match_query(mockito::Matcher::Any) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"[ + {"id":1,"rx_time":10,"rx_iso":"2025-11-27T00:00:00Z","from_id":"!aaaaaaaa","to_id":"^all","channel":1,"portnum":"TEXT_MESSAGE_APP","text":"Ping","lora_freq":868,"modem_preset":"MediumFast","channel_name":"TEST","node_id":"!aaaaaaaa"}, + {"id":2,"rx_time":20,"rx_iso":"2025-11-27T00:00:00Z","from_id":"!bbbbbbbb","to_id":"^all","channel":1,"portnum":"TEXT_MESSAGE_APP","text":"Ping","lora_freq":868,"modem_preset":"MediumFast","channel_name":"TEST","node_id":"!bbbbbbbb"} + ]"#, + ) + .create(); + + // Poll 1: A's node lookup fails transiently (500), arming the tracker. + let mock_node_a_fail = server + .mock("GET", "/api/nodes/aaaaaaaa") + .match_query(mockito::Matcher::Any) + .with_status(500) + .create(); + + // B's node lookup and the Matrix forward chain (shared by A once its + // node lookup recovers) always succeed. + let _mock_node_b = server + .mock("GET", "/api/nodes/bbbbbbbb") + .match_query(mockito::Matcher::Any) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"node_id":"!bbbbbbbb","long_name":"Node B","short_name":"NB"}"#) + .create(); + let _mock_register = server + .mock("POST", "/_matrix/client/v3/register") + .match_query(mockito::Matcher::Any) + .with_status(200) + .create(); + let _mock_join = server + .mock( + "POST", + mockito::Matcher::Regex(r"/_matrix/client/v3/rooms/.+/join".to_string()), + ) + .match_query(mockito::Matcher::Any) + .with_status(200) + .create(); + let _mock_display = server + .mock( + "PUT", + mockito::Matcher::Regex(r"/_matrix/client/v3/profile/.+/displayname".to_string()), + ) + .match_query(mockito::Matcher::Any) + .with_status(200) + .create(); + let _mock_send = server + .mock( + "PUT", + mockito::Matcher::Regex(r"/_matrix/client/v3/rooms/.+/send/.+".to_string()), + ) + .match_query(mockito::Matcher::Any) + .with_status(200) + .create(); + + let http_client = reqwest::Client::new(); + let potato = PotatoClient::new( + http_client.clone(), + PotatomeshConfig { + base_url: server.url(), + poll_interval_secs: 1, + }, + ); + let matrix = MatrixAppserviceClient::new( + http_client, + MatrixConfig { + homeserver: server.url(), + as_token: "AS_TOKEN".to_string(), + hs_token: "HS_TOKEN".to_string(), + server_name: "example.org".to_string(), + room_id: "!roomid:example.org".to_string(), + }, + ); + let mut state = BridgeState::default(); + + poll_once(&potato, &matrix, &mut state, state_str).await; + + // The transient failure armed the tracker and stalled the watermark. + assert_eq!(state.failing_msg_id, Some(1)); + assert_eq!(state.failing_msg_attempts, 1); + assert_eq!(state.last_rx_time, None); + + // Poll 2: A's node lookup now succeeds, like B's. + mock_node_a_fail.remove_async().await; + let _mock_node_a_ok = server + .mock("GET", "/api/nodes/aaaaaaaa") + .match_query(mockito::Matcher::Any) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"node_id":"!aaaaaaaa","long_name":"Node A","short_name":"NA"}"#) + .create(); + + poll_once(&potato, &matrix, &mut state, state_str).await; + + // The success-path reset cleared the tracker for the recovered id... + assert_eq!( + state.failing_msg_id, None, + "tracker id must be cleared when the failing message finally forwards" + ); + assert_eq!( + state.failing_msg_attempts, 0, + "attempt count must be reset when the failing message finally forwards" + ); + // ...and the watermark advanced through A to B. + assert_eq!( + state.last_rx_time, + Some(20), + "watermark should advance once the transient failure recovers" + ); + let msg_a = PotatoMessage { + id: 1, + rx_time: 10, + node_id: "!aaaaaaaa".to_string(), + ..sample_msg(1) + }; + assert!( + !state.should_forward(&msg_a), + "recovered message must not be reprocessed" + ); + } + /// Drive `handle_message` end-to-end against a mocked Matrix homeserver /// and PotatoMesh API, asserting that the bridged message body carries /// the expected protocol tag and preset abbreviation. Shared by the diff --git a/tests/test_capture_udp_fixtures_unit.py b/tests/test_capture_udp_fixtures_unit.py new file mode 100644 index 00000000..2bd21195 --- /dev/null +++ b/tests/test_capture_udp_fixtures_unit.py @@ -0,0 +1,114 @@ +# Copyright © 2025-26 l5yth & contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Unit tests for ``data/tools/capture_udp_fixtures.py``'s socket plumbing. + +The capture tool deliberately mirrors +:mod:`data.mesh_ingestor.protocols.meshtastic_udp_socket` (its module docstring +requires the two to stay in sync), so its ``open_multicast_socket`` gets the +same fake-socket bind/join assertions — most importantly that the socket is +bound to the multicast *group* address and never to the wildcard ``""`` +(CodeQL: binding a socket to all network interfaces). + +The tool is not a package module, so it is loaded here by file path. +""" + +from __future__ import annotations + +import importlib.util +import socket as real_socket +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +_TOOL_PATH = REPO_ROOT / "data" / "tools" / "capture_udp_fixtures.py" + + +def _load_tool(): + """Load the capture tool as a module from its file path. + + Returns: + The loaded ``capture_udp_fixtures`` module object. + """ + spec = importlib.util.spec_from_file_location("capture_udp_fixtures", _TOOL_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +capture_tool = _load_tool() + + +class FakeSock: + """Records socket calls instead of touching a real network socket.""" + + def __init__(self) -> None: + """Initialize the fake with an empty call log.""" + self.calls: list[tuple] = [] + + def setsockopt(self, *args): + """Record a ``setsockopt`` call.""" + self.calls.append(("setsockopt", args)) + + def bind(self, addr): + """Record a ``bind`` call.""" + self.calls.append(("bind", addr)) + + def settimeout(self, timeout): + """Record a ``settimeout`` call.""" + self.calls.append(("settimeout", timeout)) + + +class TestCaptureToolOpenMulticastSocket: + """Tests for the capture tool's :func:`open_multicast_socket`.""" + + def test_binds_group_address_not_wildcard(self, monkeypatch): + """The socket is bound to ``(group, port)``, never the wildcard ``""``. + + Regression guard for the CodeQL finding "binding a socket to all + network interfaces": a wildcard bind would deliver unicast datagrams + sent to the port on any local interface, while the tool only ever + needs the multicast group's traffic. + """ + fake = FakeSock() + monkeypatch.setattr(capture_tool.socket, "socket", lambda *a, **k: fake) + + sock = capture_tool.open_multicast_socket("224.0.0.69", 4403) + + assert sock is fake + assert ("bind", ("224.0.0.69", 4403)) in fake.calls + assert ("bind", ("", 4403)) not in fake.calls + + def test_sets_reuse_join_and_timeout(self, monkeypatch): + """Reuse options, IP_ADD_MEMBERSHIP on the group, and a 1s timeout.""" + fake = FakeSock() + monkeypatch.setattr(capture_tool.socket, "socket", lambda *a, **k: fake) + + capture_tool.open_multicast_socket("239.1.2.3", 5000) + + assert ( + "setsockopt", + (real_socket.SOL_SOCKET, real_socket.SO_REUSEADDR, 1), + ) in fake.calls + assert ("bind", ("239.1.2.3", 5000)) in fake.calls + expected_mreq = real_socket.inet_aton("239.1.2.3") + real_socket.inet_aton( + "0.0.0.0" + ) + assert ( + "setsockopt", + (real_socket.IPPROTO_IP, real_socket.IP_ADD_MEMBERSHIP, expected_mreq), + ) in fake.calls + assert ("settimeout", 1.0) in fake.calls diff --git a/tests/test_config_unit.py b/tests/test_config_unit.py index 5047fa3f..6b6eb313 100644 --- a/tests/test_config_unit.py +++ b/tests/test_config_unit.py @@ -15,6 +15,7 @@ from __future__ import annotations +import base64 import sys from pathlib import Path @@ -610,6 +611,58 @@ def test_ingestor_node_id_blank_is_none(self, monkeypatch): assert config.INGESTOR_NODE_ID is None +class TestPrimaryChannelKeyValidation: + """Tests for import-time base64 validation of :data:`config.PRIMARY_CHANNEL_KEY`.""" + + @pytest.fixture(autouse=True) + def _isolate(self, monkeypatch): + """Clear UDP-transport env vars and reload config to defaults after each test.""" + import importlib + + _clear_udp_env(monkeypatch) + yield + _clear_udp_env(monkeypatch) + importlib.reload(config) + + def test_invalid_base64_raises_value_error_at_import(self, monkeypatch): + """A non-base64 PRIMARY_CHANNEL_KEY raises ValueError at import time. + + Regression guard: the key used to be stored raw and only decoded + lazily inside ``channel_hash``/``decrypt_meshpacket``, so a malformed + key first surfaced as ``binascii.Error`` out of ``connect()`` — which + the daemon's generic handler swallowed, logging only "Failed to create + mesh interface" and retrying forever instead of stopping the service + with a clear startup error (parity with ``TRANSPORT``/``PROTOCOL``). + """ + import importlib + + monkeypatch.setenv("PRIMARY_CHANNEL_KEY", "not-base64!!") + with pytest.raises(ValueError, match="PRIMARY_CHANNEL_KEY"): + importlib.reload(config) + + def test_non_ascii_key_raises_value_error_at_import(self, monkeypatch): + """A non-ASCII PRIMARY_CHANNEL_KEY is rejected at import time as well. + + ``expand_default_key`` encodes the key as ASCII before decoding, so a + non-ASCII value raises ``UnicodeEncodeError`` (a ``ValueError``) on the + same lazy path; import-time validation must catch this shape too. + """ + import importlib + + monkeypatch.setenv("PRIMARY_CHANNEL_KEY", "ÄQ==") + with pytest.raises(ValueError, match="PRIMARY_CHANNEL_KEY"): + importlib.reload(config) + + def test_valid_padded_base64_key_is_accepted(self, monkeypatch): + """A well-formed padded base64 PSK (32-byte key) imports cleanly.""" + import importlib + + key = base64.b64encode(bytes(range(32))).decode("ascii") + monkeypatch.setenv("PRIMARY_CHANNEL_KEY", key) + importlib.reload(config) + assert config.PRIMARY_CHANNEL_KEY == key + + class TestUdpTransportAllExports: """Tests that the new config names are exported via ``__all__``.""" diff --git a/tests/test_meshtastic_udp_socket_unit.py b/tests/test_meshtastic_udp_socket_unit.py index 86144ab3..36414500 100644 --- a/tests/test_meshtastic_udp_socket_unit.py +++ b/tests/test_meshtastic_udp_socket_unit.py @@ -69,14 +69,21 @@ class TestOpenMulticastSocket: """Tests for :func:`open_multicast_socket`.""" def test_sets_options_binds_joins_and_times_out(self, monkeypatch): - """Happy path: reuseaddr, bind, IP_ADD_MEMBERSHIP, and a 1s timeout.""" + """Happy path: reuseaddr, group-bound bind, IP_ADD_MEMBERSHIP, 1s timeout. + + The bind must target the multicast *group* address, never the wildcard + ``""``/``0.0.0.0`` — a wildcard bind would deliver unicast datagrams + sent to the port on any local interface (CodeQL: binding a socket to + all network interfaces). + """ fake = FakeSock() monkeypatch.setattr(udp_socket.socket, "socket", lambda *a, **k: fake) sock = udp_socket.open_multicast_socket("224.0.0.69", 4403) assert sock is fake - assert ("bind", ("", 4403)) in fake.calls + assert ("bind", ("224.0.0.69", 4403)) in fake.calls + assert ("bind", ("", 4403)) not in fake.calls assert ( "setsockopt", (real_socket.SOL_SOCKET, real_socket.SO_REUSEADDR, 1), @@ -139,7 +146,7 @@ def test_reuseport_oserror_is_tolerated(self, monkeypatch): (real_socket.SOL_SOCKET, real_socket.SO_REUSEPORT, 1), ) in fake.calls # Despite the OSError, bind/join/timeout still happened. - assert ("bind", ("", 4403)) in fake.calls + assert ("bind", ("224.0.0.69", 4403)) in fake.calls assert ("settimeout", 1.0) in fake.calls def test_join_uses_requested_group_and_port(self, monkeypatch): @@ -149,7 +156,7 @@ def test_join_uses_requested_group_and_port(self, monkeypatch): udp_socket.open_multicast_socket("239.1.2.3", 5000) - assert ("bind", ("", 5000)) in fake.calls + assert ("bind", ("239.1.2.3", 5000)) in fake.calls expected_mreq = real_socket.inet_aton("239.1.2.3") + real_socket.inet_aton( "0.0.0.0" ) diff --git a/web/lib/potato_mesh/config.rb b/web/lib/potato_mesh/config.rb index 23f9ed4d..15c9737e 100644 --- a/web/lib/potato_mesh/config.rb +++ b/web/lib/potato_mesh/config.rb @@ -315,13 +315,13 @@ def max_json_body_bytes # Provide the fallback version string when git metadata is unavailable. # # 0.7.0 introduced the breaking /api/stats response-shape change - # (SPEC S1); 0.7.1 is the current patch release, kept in lockstep with - # the other language manifests. The matching +git tag v0.7.1+ is the + # (SPEC S1); 0.7.2 is the current patch release, kept in lockstep with + # the other language manifests. The matching +git tag v0.7.2+ is the # maintainer release step. # # @return [String] semantic version identifier. def version_fallback - "0.7.1" + "0.7.2" end # Default refresh interval for frontend polling routines. diff --git a/web/package-lock.json b/web/package-lock.json index 72db08f9..131fe768 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "potato-mesh", - "version": "0.7.1", + "version": "0.7.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "potato-mesh", - "version": "0.7.1", + "version": "0.7.2", "devDependencies": { "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", diff --git a/web/package.json b/web/package.json index c5ad9626..1462879c 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "potato-mesh", - "version": "0.7.1", + "version": "0.7.2", "type": "module", "private": true, "scripts": { diff --git a/web/views/layouts/app.erb b/web/views/layouts/app.erb index 0c1c64c7..1b62e8f0 100644 --- a/web/views/layouts/app.erb +++ b/web/views/layouts/app.erb @@ -67,12 +67,12 @@ # is not enough here since a literal "" inside a JSON string # value still terminates the element in HTML parsing. %> <% instance_ld_json = JSON.generate({ - "@context" => "https://schema.org", - "@type" => "WebSite", - "name" => meta_name, - "url" => canonical_base, - "description" => meta_description, - }).gsub("<", "\\u003c").gsub(">", "\\u003e").gsub("&", "\\u0026") %> + "@context" => "https://schema.org", + "@type" => "WebSite", + "name" => meta_name, + "url" => canonical_base, + "description" => meta_description, + }).gsub("<", "\\u003c").gsub(">", "\\u003e").gsub("&", "\\u0026") %> From 37bca227e6d7fa7c00dfa011dda047466804cb1b Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:24:53 +0200 Subject: [PATCH 2/2] fix: UDP-transport hardening, bridge tracker coverage, CI repairs; release 0.7.2 --- ACCEPTANCE.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ACCEPTANCE.md b/ACCEPTANCE.md index ec287e3b..50ac85af 100644 --- a/ACCEPTANCE.md +++ b/ACCEPTANCE.md @@ -2453,7 +2453,7 @@ PRIMARY_CHANNEL_KEY='not-base64!!' python -c 'import data.mesh_ingestor.config' `TRANSPORT`/`PROTOCOL` import-time validation. Previously the raw value was stored unchecked and only decoded lazily inside `channel_hash` / `decrypt_meshpacket`, so with `PRIMARY_CHANNEL_NAME` set a malformed key raised -`binascii.Error` out of `connect()` — caught by `daemon._connect_interface`'s +`binascii.Error` out of `connect()` — caught by `daemon._try_connect`'s generic `except Exception`, which logged only "Failed to create mesh interface" and retried forever: the service never ingested and never surfaced the cause. Valid keys of any decodable length (1-byte default `AQ==`, 16/32-byte PSKs) are @@ -2517,4 +2517,7 @@ suite (`test_meshtastic_udp_unit.py` — the provider consumes the validated key and group-bound socket unchanged), `test_config_unit.py`'s UDP-var defaults (blank-fallback semantics unchanged), and the bridge watermark/poison tests (the new test only adds coverage; `poll_once` is untouched). The web app, -federation wire, and Flutter app are not touched by this batch. +federation wire, and Flutter app are behaviorally untouched by this batch — +the only edits outside the four fixes are the lockstep 0.7.2 version-bump +stamps (manifests, lockfiles, iOS plist, README pinned tags, S-A1), verified +by `tests/test_version_sync.py`.