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`.