diff --git a/README.md b/README.md index 179d99b..06f95ae 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # floodgate -Zero-hop MQTT anti-flood service for self-hosted [EMQX](https://www.emqx.io/) brokers serving [Meshtastic](https://meshtastic.org/) networks. Intercepts MQTT PUBLISH events via [EMQX ExHook](https://www.emqx.io/docs/en/latest/extensions/exhook.html) (gRPC) and sets `MeshPacket.hop_limit=0` in-flight before delivery to subscribers, preventing LoRa rebroadcast floods when gateways downlink MQTT packets. +Zero-hop MQTT anti-flood service for self-hosted [EMQX](https://www.emqx.io/) brokers serving [Meshtastic](https://meshtastic.org/) networks. Intercepts MQTT PUBLISH events via [EMQX ExHook](https://www.emqx.io/docs/en/latest/extensions/exhook.html) (gRPC) and zeros both `MeshPacket.hop_limit` and `hop_start` in-flight before delivery to subscribers, preventing LoRa rebroadcast floods when gateways downlink MQTT packets. Tested against EMQX 6.2.0 (ExHook v3 proto, EMQX 5.9.0+). @@ -22,9 +22,11 @@ Unlike a standard MQTT subscriber, floodgate modifies payloads **in-flight** — ``` Before: hop_limit: 3 hop_start: 3 -After: hop_limit: 0 hop_start: 3 ← hop_start preserved for observability +After: hop_limit: 0 hop_start: 0 ← both zeroed so receivers don't render ghost hops in traceroute ``` +Original sender values remain visible in floodgate's per-message log records (extracted before modification). Zeroing `hop_start` alongside `hop_limit` prevents receiving firmware from computing `hopsTaken = hop_start - hop_limit > 0` and padding `RouteDiscovery.route[]` with `0xFFFFFFFF` sentinels (rendered as "Meshtastic ffff (ffff)" in client apps). + See [Meshtastic Mesh Algorithm](https://meshtastic.org/docs/overview/mesh-algo/) for details on `hop_limit` and `hop_start`. Each message produces one outcome: @@ -127,7 +129,7 @@ See [config.yaml](config.yaml) for a fully annotated example. | Key | Default | Description | |-----|---------|-------------| | `zerohop_enabled` | `true` | Master switch for the zero-hop modifier. | -| `zerohop_channels` | 8 standard presets | Channels whose packets get `hop_limit` zeroed. | +| `zerohop_channels` | 8 standard presets | Channels whose packets get `hop_limit` and `hop_start` zeroed. | | `drop_enabled` | `false` | Master switch for the drop filter (deny entirely). | | `drop_channels` | `"zerohop_channels"` | Channels on which the drop filter runs. List of channels, the literal string `"zerohop_channels"` to inherit, or `null` for all channels. | | `drop_portnums` | `[]` | Meshtastic portnums (proto enum names like `RANGE_TEST_APP`) to drop. | @@ -296,13 +298,13 @@ MeshPacket { to: 0xffffffff id: 3827461829 hop_limit: 0 ← zeroed — gateway will not rebroadcast - hop_start: 3 ← preserved for mesh-distance observability + hop_start: 0 ← zeroed so receivers don't render ghost hops in traceroute channel: 0 payload: (unchanged) } ``` -The JSON topic mirror (`/json/LongFast/!a2e1a8c4`) is zeroed the same way — `hop_limit` is set to `0` in the JSON object. +The JSON topic mirror (`/json/LongFast/!a2e1a8c4`) is zeroed the same way — `hop_limit`, `hop_start`, and `hops_away` are set to `0` in the JSON object when present. ## Development diff --git a/src/floodgate/__main__.py b/src/floodgate/__main__.py index dc6d046..2a5a58a 100644 --- a/src/floodgate/__main__.py +++ b/src/floodgate/__main__.py @@ -12,7 +12,7 @@ def main(): prog="floodgate", description=( "Zero-hop MQTT anti-flood service for Meshtastic/EMQX.\n" - "Sets MeshPacket.hop_limit=0 in-flight via EMQX ExHook before delivery to subscribers." + "Zeros MeshPacket.hop_limit and hop_start in-flight via EMQX ExHook before delivery to subscribers." ), formatter_class=argparse.RawDescriptionHelpFormatter, ) diff --git a/src/floodgate/zerohop.py b/src/floodgate/zerohop.py index e2845a2..60d51a4 100644 --- a/src/floodgate/zerohop.py +++ b/src/floodgate/zerohop.py @@ -259,6 +259,7 @@ def zerohop_protobuf(payload: bytes) -> tuple[bytes | None, int | None, dict]: return None, 0, meta envelope.packet.hop_limit = 0 + envelope.packet.hop_start = 0 modified = envelope.SerializeToString() logger.debug("zerohop_protobuf: serialized %d bytes", len(modified)) @@ -309,7 +310,15 @@ def zerohop_json(payload: bytes) -> tuple[bytes | None, int | None, dict]: if old_hop == 0: return None, 0, meta + # Zero hop_limit, hop_start, and hops_away together. Setting only + # hop_limit makes downstream consumers compute non-zero hops-taken + # (hop_start - hop_limit, or hop_start - hops_away) which produces + # ghost-hop renderings in traceroute UIs. See issue #46. data["hop_limit"] = 0 + if "hop_start" in data: + data["hop_start"] = 0 + if "hops_away" in data: + data["hops_away"] = 0 return _json.dumps(data).encode(), old_hop, meta diff --git a/tests/integration/test-driver/run.py b/tests/integration/test-driver/run.py index f7d2e8a..fb496be 100644 --- a/tests/integration/test-driver/run.py +++ b/tests/integration/test-driver/run.py @@ -189,6 +189,16 @@ def _parse_hop_limit(payload: bytes) -> int | None: return None +def _parse_hop_start(payload: bytes) -> int | None: + """Return MeshPacket.hop_start from a serialized ServiceEnvelope, or None.""" + try: + env = mqtt_pb2.ServiceEnvelope() + env.ParseFromString(payload) + return env.packet.hop_start if env.HasField("packet") else None + except Exception: + return None + + def _packet_id_of(payload: bytes) -> int | None: try: env = mqtt_pb2.ServiceEnvelope() @@ -235,6 +245,10 @@ def case_zerohop(pub: Publisher, sub: Subscriber) -> Outcome: hop = _parse_hop_limit(delivered[-1].payload) if hop != 0: return Outcome(name, False, f"delivered hop_limit={hop}, expected 0") + hop_start = _parse_hop_start(delivered[-1].payload) + if hop_start != 0: + return Outcome(name, False, + f"delivered hop_start={hop_start}, expected 0 (#46)") post = health_stats() if post.get("zerohop", 0) - pre.get("zerohop", 0) < 1: diff --git a/tests/test_portnum.py b/tests/test_portnum.py index 0eff278..d97a0a1 100644 --- a/tests/test_portnum.py +++ b/tests/test_portnum.py @@ -91,7 +91,8 @@ def meshtastic_pb2(): def _build_encrypted_envelope(mesh_pb2, mqtt_pb2, portnum, payload_bytes, packet_id=0xAABBCCDD, from_node=0x12345678, - channel_name="LongFast", hop_limit=0): + channel_name="LongFast", hop_limit=0, + hop_start=None): """Construct a ServiceEnvelope whose inner MeshPacket has the named portnum encrypted under the default Meshtastic key.""" data = mesh_pb2.Data() @@ -105,6 +106,8 @@ def _build_encrypted_envelope(mesh_pb2, mqtt_pb2, portnum, payload_bytes, setattr(pkt, pkt.DESCRIPTOR.fields_by_name["from"].name, from_node) pkt.encrypted = ciphertext pkt.hop_limit = hop_limit + if hop_start is not None: + pkt.hop_start = hop_start env = mqtt_pb2.ServiceEnvelope() env.packet.CopyFrom(pkt) diff --git a/tests/test_zerohop.py b/tests/test_zerohop.py index 55e72a5..f04c726 100644 --- a/tests/test_zerohop.py +++ b/tests/test_zerohop.py @@ -17,6 +17,7 @@ parse_meshtastic_topic, process_message, zerohop_json, + zerohop_protobuf, ) # Directory of real-world Meshtastic payloads captured from gateways. @@ -155,6 +156,29 @@ def test_sets_hop_limit_to_zero_explicit(self): assert modified is not None assert json.loads(modified)["hop_limit"] == 0 + def test_hop_start_is_zeroed_when_present(self): + """hop_start must also be zeroed; otherwise JSON consumers that + compute hops-taken from hop_start see a misleading non-zero value + (issue #46, JSON parity with the protobuf fix).""" + modified, _, _ = zerohop_json(self._payload(hop_limit=3, hop_start=3)) + assert modified is not None + data = json.loads(modified) + assert data["hop_limit"] == 0 + assert data["hop_start"] == 0 + + def test_hops_away_is_zeroed_when_present(self): + """hops_away must be zeroed in the realistic Meshtastic JSON shape + so consumers computing hops-taken = hop_start - hops_away get 0, + not the original hop_start (issue #46).""" + modified, _, _ = zerohop_json( + self._payload_meshtastic(hop_start=5, hops_away=0) + ) + assert modified is not None + data = json.loads(modified) + assert data["hop_limit"] == 0 + assert data["hop_start"] == 0 + assert data["hops_away"] == 0 + def test_uses_hops_away_when_no_hop_limit(self): modified, old_hop, _ = zerohop_json(self._payload_meshtastic(hop_start=5, hops_away=0)) assert old_hop == 5 # effective: hop_start(5) - hops_away(0) @@ -204,6 +228,55 @@ def test_preserves_other_fields(self): assert data["channel"] == 0 +# --------------------------------------------------------------------------- +# zerohop_protobuf — hop field zeroing (issue #46) +# --------------------------------------------------------------------------- + +class TestZerohopProtobufHopFields: + """Both hop_limit AND hop_start must be zeroed. + + Setting only hop_limit=0 (with hop_start unchanged) makes Meshtastic + firmware compute hopsTaken = hop_start - hop_limit > 0, which then + pads RouteDiscovery.route[] with 0xFFFFFFFF sentinels rendered as + 'Meshtastic ffff (ffff)' in the apps. See issue #46. + """ + + def test_hop_start_is_zeroed_alongside_hop_limit(self): + pytest.importorskip("meshtastic") + from tests.test_portnum import _build_encrypted_envelope + from meshtastic import mesh_pb2, mqtt_pb2, portnums_pb2 + + envelope_bytes = _build_encrypted_envelope( + mesh_pb2, mqtt_pb2, + portnum=portnums_pb2.PortNum.TEXT_MESSAGE_APP, + payload_bytes=b"x", + packet_id=0x11223344, + from_node=0xAABBCCDD, + channel_name="LongFast", + hop_limit=3, + hop_start=3, + ) + # Verify test premise: both fields are set to 3 in the input envelope. + sanity = mqtt_pb2.ServiceEnvelope() + sanity.ParseFromString(envelope_bytes) + assert sanity.packet.hop_limit == 3 + assert sanity.packet.hop_start == 3 + + modified_bytes, old_hop, _meta = zerohop_protobuf(envelope_bytes) + + assert old_hop == 3 + assert modified_bytes is not None + + modified = mqtt_pb2.ServiceEnvelope() + modified.ParseFromString(modified_bytes) + assert modified.packet.hop_limit == 0 + assert modified.packet.hop_start == 0, ( + "hop_start must be zeroed too; otherwise receiving firmware " + "computes hopsTaken = hop_start - hop_limit > 0 and pads the " + "RouteDiscovery route with 'Meshtastic ffff (ffff)' sentinels" + ) + + # --------------------------------------------------------------------------- # process_message — routing logic with mocked zerohop functions # --------------------------------------------------------------------------- @@ -768,6 +841,7 @@ def test_synthetic_envelope_zerohop_round_trip(self, caplog): from_node=from_node, channel_name="LongFast", hop_limit=3, + hop_start=3, ) config = _make_config(zerohop_channels=["LongFast"]) @@ -782,6 +856,7 @@ def test_synthetic_envelope_zerohop_round_trip(self, caplog): modified = mqtt_pb2.ServiceEnvelope() modified.ParseFromString(result.payload) assert modified.packet.hop_limit == 0 + assert modified.packet.hop_start == 0 assert modified.packet.id == packet_id assert modified.channel_id == "LongFast"