Skip to content
Open
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
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -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+).

Expand All @@ -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: 3hop_start preserved for observability
After: hop_limit: 0 hop_start: 0both 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:
Expand Down Expand Up @@ -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. |
Expand Down Expand Up @@ -296,13 +298,13 @@ MeshPacket {
to: 0xffffffff
id: 3827461829
hop_limit: 0 ← zeroed — gateway will not rebroadcast
hop_start: 3preserved for mesh-distance observability
hop_start: 0zeroed so receivers don't render ghost hops in traceroute
channel: 0
payload: <encrypted Data protobuf> (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

Expand Down
2 changes: 1 addition & 1 deletion src/floodgate/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
9 changes: 9 additions & 0 deletions src/floodgate/zerohop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down Expand Up @@ -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


Expand Down
14 changes: 14 additions & 0 deletions tests/integration/test-driver/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion tests/test_portnum.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
Expand Down
75 changes: 75 additions & 0 deletions tests/test_zerohop.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
parse_meshtastic_topic,
process_message,
zerohop_json,
zerohop_protobuf,
)

# Directory of real-world Meshtastic payloads captured from gateways.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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"])
Expand All @@ -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"

Expand Down
Loading