Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
101 changes: 96 additions & 5 deletions ACCEPTANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -2430,3 +2426,98 @@ 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._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
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 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`.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions app/ios/Flutter/AppFrameworkInfo.plist
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>0.7.1</string>
<string>0.7.2</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>0.7.1</string>
<string>0.7.2</string>
<key>MinimumOSVersion</key>
<string>14.0</string>
</dict>
Expand Down
2 changes: 1 addition & 1 deletion app/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion data/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
25 changes: 23 additions & 2 deletions data/mesh_ingestor/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from __future__ import annotations

import base64
import math
import os
from datetime import datetime, timezone
Expand Down Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions data/mesh_ingestor/protocols/meshtastic_udp_socket.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"``.
Expand All @@ -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")
Expand Down
4 changes: 3 additions & 1 deletion data/tools/capture_udp_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion matrix/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion matrix/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

[package]
name = "potatomesh-matrix-bridge"
version = "0.7.1"
version = "0.7.2"
edition = "2021"

[dependencies]
Expand Down
Loading
Loading