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
7 changes: 3 additions & 4 deletions config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,9 @@ repeater:

# Mesh Network Configuration
mesh:
# Global flood policy - controls whether the repeater allows or denies flooding by default
# true = allow flooding globally, false = deny flooding globally
# Individual transport keys can override this setting
global_flood_allow: true
# Unscoped flood policy - controls whether the repeater allows or denies unscoped flooding
# true = allow unscoped flooding, false = deny flooding globally
unscoped_flood_allow: true

# Path hash mode for flood packets (0-hop): per-hop hash size in path encoding
# 0 = 1-byte hashes (legacy), 1 = 2-byte, 2 = 3-byte. Must match mesh convention.
Expand Down
9 changes: 5 additions & 4 deletions repeater/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,12 +152,12 @@ def save_config(config_data: Dict[str, Any], config_path: Optional[str] = None)
return False


def update_global_flood_policy(allow: bool, config_path: Optional[str] = None) -> bool:
def update_unscoped_flood_policy(allow: bool, config_path: Optional[str] = None) -> bool:
"""
Update the global flood policy in the configuration.
Update the unscoped flood policy in the configuration.

Args:
allow: True to allow flooding globally, False to deny
allow: True to allow unscoped flooding, False to deny
config_path: Path to config file (uses default if None)

Returns:
Expand All @@ -173,12 +173,13 @@ def update_global_flood_policy(allow: bool, config_path: Optional[str] = None) -

# Set global flood policy
config["mesh"]["global_flood_allow"] = allow
config["mesh"]["unscoped_flood_allow"] = allow

# Save updated config
return save_config(config, config_path)

except Exception as e:
logger.error(f"Failed to update global flood policy: {e}")
logger.error(f"Failed to update unscoped flood policy: {e}")
return False


Expand Down
35 changes: 18 additions & 17 deletions repeater/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -623,10 +623,10 @@ def _get_drop_reason(self, packet: Packet) -> str:
route_type = packet.header & PH_ROUTE_MASK

if route_type == ROUTE_TYPE_FLOOD:
# Check if global flood policy blocked it
global_flood_allow = self.config.get("mesh", {}).get("global_flood_allow", True)
if not global_flood_allow:
return "Global flood policy disabled"
# Check if unscoped flood policy blocked it
unscoped_flood_allow = self.config.get("mesh", {}).get("unscoped_flood_allow", self.config.get("mesh", {}).get("global_flood_allow", True))
if not unscoped_flood_allow:
return "Unscoped flood policy disabled"

if route_type == ROUTE_TYPE_DIRECT:
hash_size = packet.get_path_hash_size()
Expand Down Expand Up @@ -800,19 +800,20 @@ def flood_forward(self, packet: Packet) -> Optional[Packet]:
if not packet.drop_reason:
packet.drop_reason = "Marked do not retransmit"
return None

# Check unscoped flood policy
unscoped_flood_allow = self.config.get("mesh", {}).get("unscoped_flood_allow", self.config.get("mesh", {}).get("global_flood_allow", True))
route_type = packet.header & PH_ROUTE_MASK
if route_type == ROUTE_TYPE_FLOOD:
if not unscoped_flood_allow:
packet.drop_reason = "Unscoped flood policy disabled"
return None

# Check global flood policy
global_flood_allow = self.config.get("mesh", {}).get("global_flood_allow", True)
if not global_flood_allow:
route_type = packet.header & PH_ROUTE_MASK
if route_type == ROUTE_TYPE_FLOOD or route_type == ROUTE_TYPE_TRANSPORT_FLOOD:

allowed, check_reason = self._check_transport_codes(packet)
if not allowed:
packet.drop_reason = check_reason
return None
else:
packet.drop_reason = "Global flood policy disabled"
#Check transport scopes flood policy
if route_type == ROUTE_TYPE_TRANSPORT_FLOOD:
allowed, check_reason = self._check_transport_codes(packet)
if not allowed:
packet.drop_reason = "Transport code not allowed to flood"
return None

mode = self._get_loop_detect_mode()
Expand Down Expand Up @@ -1134,7 +1135,7 @@ def get_stats(self) -> dict:
"web": self.config.get("web", {}), # Include web configuration
"mesh": {
"loop_detect": self.config.get("mesh", {}).get("loop_detect", "off"),
"global_flood_allow": self.config.get("mesh", {}).get("global_flood_allow", True),
"unscoped_flood_allow": self.config.get("mesh", {}).get("unscoped_flood_allow", self.config.get("mesh", {}).get("global_flood_allow", True)),
"path_hash_mode": self.config.get("mesh", {}).get("path_hash_mode", 0),
},
"letsmesh": self.config.get("letsmesh", {}),
Expand Down
40 changes: 20 additions & 20 deletions repeater/web/api_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
find_companion_index,
heal_companion_empty_names,
)
from repeater.config import update_global_flood_policy
from repeater.config import update_unscoped_flood_policy

from .auth.middleware import require_auth
from .auth_endpoints import AuthAPIEndpoints
Expand Down Expand Up @@ -93,8 +93,8 @@
# DELETE /api/transport_key?key_id=X - Delete transport key

# Network Policy
# GET /api/global_flood_policy - Get global flood policy
# POST /api/global_flood_policy - Update global flood policy
# GET /api/unscoped_flood_policy - Get unscoped flood policy
# POST /api/unscoped_flood_policy - Update unscoped flood policy
# POST /api/ping_neighbor - Ping a neighbor node

# Identity Management
Expand Down Expand Up @@ -2153,57 +2153,57 @@ def transport_key(self, key_id):
@cherrypy.expose
@cherrypy.tools.json_out()
@cherrypy.tools.json_in()
def global_flood_policy(self):
def unscoped_flood_policy(self):
"""
Update global flood policy configuration
Update unscoped flood policy configuration

POST /global_flood_policy
Body: {"global_flood_allow": true/false}
POST /unscoped_flood_policy
Body: {"unscoped_flood_allow": true/false}
"""
if cherrypy.request.method == "POST":
try:
data = cherrypy.request.json or {}
global_flood_allow = data.get("global_flood_allow")
unscoped_flood_allow = data.get("unscoped_flood_allow")

if global_flood_allow is None:
return self._error("Missing required field: global_flood_allow")
if unscoped_flood_allow is None:
return self._error("Missing required field: unscoped_flood_allow")

if not isinstance(global_flood_allow, bool):
return self._error("global_flood_allow must be a boolean value")
if not isinstance(unscoped_flood_allow, bool):
return self._error("unscoped_flood_allow must be a boolean value")

# Update the running configuration first (like CAD settings)
if "mesh" not in self.config:
self.config["mesh"] = {}
self.config["mesh"]["global_flood_allow"] = global_flood_allow
self.config["mesh"]["unscoped_flood_allow"] = unscoped_flood_allow

# Get the actual config path from daemon instance (same as CAD settings)
config_path = getattr(self, "_config_path", "/etc/pymc_repeater/config.yaml")
if self.daemon_instance and hasattr(self.daemon_instance, "config_path"):
config_path = self.daemon_instance.config_path

logger.info(f"Using config path for global flood policy: {config_path}")
logger.info(f"Using config path for unscoped flood policy: {config_path}")

# Update the configuration file using ConfigManager
try:
saved = self.config_manager.save_to_file()
if saved:
logger.info(
f"Updated running config and saved global flood policy to file: {'allow' if global_flood_allow else 'deny'}"
f"Updated running config and saved unscoped flood policy to file: {'allow' if unscoped_flood_allow else 'deny'}"
)
else:
logger.error("Failed to save global flood policy to file")
logger.error("Failed to save unscoped flood policy to file")
return self._error("Failed to save configuration to file")
except Exception as e:
logger.error(f"Failed to save global flood policy to file: {e}")
logger.error(f"Failed to save unscoped flood policy to file: {e}")
return self._error(f"Failed to save configuration to file: {e}")

return self._success(
{"global_flood_allow": global_flood_allow},
message=f"Global flood policy updated to {'allow' if global_flood_allow else 'deny'} (live and saved)",
{"unscoped_flood_allow": unscoped_flood_allow},
message=f"Unscoped flood policy updated to {'allow' if unscoped_flood_allow else 'deny'} (live and saved)",
)

except Exception as e:
logger.error(f"Error updating global flood policy: {e}")
logger.error(f"Error updating unscoped flood policy: {e}")
return self._error(e)
else:
return self._error("Method not supported")
Expand Down
6 changes: 3 additions & 3 deletions repeater/web/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1235,10 +1235,10 @@ paths:
# ============================================================================
# Network Policy
# ============================================================================
/global_flood_policy:
/unscoped_flood_policy:
get:
tags: [Network Policy]
summary: Get global flood policy
summary: Get unscoped flood policy
description: Retrieve current network flood policy configuration
security:
- BearerAuth: []
Expand All @@ -1252,7 +1252,7 @@ paths:
type: object
post:
tags: [Network Policy]
summary: Update global flood policy
summary: Update unscoped flood policy
description: Modify network flood policy settings
security:
- BearerAuth: []
Expand Down
33 changes: 16 additions & 17 deletions tests/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def _make_config(**overrides) -> dict:
"node_name": "test-node",
},
"mesh": {
"global_flood_allow": True,
"unscoped_flood_allow": True,
"loop_detect": "off",
},
"delays": {
Expand Down Expand Up @@ -228,11 +228,10 @@ def test_do_not_retransmit_dropped(self, handler):
assert result is None
assert "do not retransmit" in pkt.drop_reason.lower()

def test_global_flood_deny_plain_flood(self, handler):
handler.config["mesh"]["global_flood_allow"] = False
def test_unscoped_flood_deny_plain_flood(self, handler):
handler.config["mesh"]["unscoped_flood_allow"] = False
pkt = _make_flood_packet()
# When global_flood_allow=False, flood_forward calls _check_transport_codes
# which will fail because there are no transport codes on a plain flood
# When unscoped_flood_allow=False, flood_forward should fail on a packet type without a transport code defined
result = handler.flood_forward(pkt)
assert result is None

Expand Down Expand Up @@ -656,26 +655,26 @@ def test_direct_second_identical_detected_as_duplicate(self, handler):


# ===================================================================
# 9. Global flood policy
# 9. unscoped flood policy
# ===================================================================

class TestGlobalFloodPolicy:
"""global_flood_allow=False blocks plain flood, transport checked."""
class TestUnscopedFloodPolicy:
"""unscoped_flood_allow=False blocks plain flood, transport checked."""

def test_flood_blocked_by_policy(self, handler):
handler.config["mesh"]["global_flood_allow"] = False
handler.config["mesh"]["unscoped_flood_allow"] = False
pkt = _make_flood_packet()
result = handler.flood_forward(pkt)
assert result is None

def test_direct_unaffected_by_flood_policy(self, handler):
handler.config["mesh"]["global_flood_allow"] = False
handler.config["mesh"]["unscoped_flood_allow"] = False
pkt = _make_direct_packet()
result = handler.direct_forward(pkt)
assert result is not None # direct is not blocked by flood policy

def test_transport_flood_checked_when_policy_off(self, handler):
handler.config["mesh"]["global_flood_allow"] = False
handler.config["mesh"]["unscoped_flood_allow"] = False
pkt = _make_transport_flood_packet()
# Will call _check_transport_codes which will fail (no storage keys)
result = handler.flood_forward(pkt)
Expand Down Expand Up @@ -812,7 +811,7 @@ def test_path_too_long_reason(self, handler):
assert "Path too long" in reason

def test_flood_policy_reason(self, handler):
handler.config["mesh"]["global_flood_allow"] = False
handler.config["mesh"]["unscoped_flood_allow"] = False
pkt = _make_flood_packet()
reason = handler._get_drop_reason(pkt)
assert "flood" in reason.lower()
Expand Down Expand Up @@ -1202,7 +1201,7 @@ def test_zero_payload_still_has_preamble(self, handler):
"no path"),

("bad_flood_policy_off",
"Plain flood when global_flood_allow=False (needs config override)",
"Plain flood when unscoped_flood_allow=False (needs config override)",
lambda: _make_flood_packet(payload=b"\x01\x02"),
"transport codes"),

Expand Down Expand Up @@ -1323,9 +1322,9 @@ class TestBadPacketArray:
BAD_PACKETS, ids=_bad_ids,
)
def test_process_packet_drops(self, handler, name, desc, builder, expected_reason):
# Two entries need global_flood_allow=False
# Two entries need unscoped_flood_allow=False
if "policy_off" in name:
handler.config["mesh"]["global_flood_allow"] = False
handler.config["mesh"]["unscoped_flood_allow"] = False

pkt = builder()
result = handler.process_packet(pkt, snr=5.0)
Expand All @@ -1337,7 +1336,7 @@ def test_process_packet_drops(self, handler, name, desc, builder, expected_reaso
)
def test_drop_reason_set(self, handler, name, desc, builder, expected_reason):
if "policy_off" in name:
handler.config["mesh"]["global_flood_allow"] = False
handler.config["mesh"]["unscoped_flood_allow"] = False

pkt = builder()
handler.process_packet(pkt, snr=5.0)
Expand All @@ -1353,7 +1352,7 @@ def test_drop_reason_set(self, handler, name, desc, builder, expected_reason):
def test_bad_packet_not_marked_seen(self, handler, name, desc, builder, expected_reason):
"""Dropped packets must NOT pollute the seen cache."""
if "policy_off" in name:
handler.config["mesh"]["global_flood_allow"] = False
handler.config["mesh"]["unscoped_flood_allow"] = False

pkt = builder()
handler.process_packet(pkt, snr=5.0)
Expand Down
24 changes: 12 additions & 12 deletions tests/test_flood_loop_dedup.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
- Loop detection modes (off, minimal, moderate, strict) with real path bytes
- Flood re-forwarding prevention (own hash already in path)
- Multi-byte hash mode interaction with loop/dedup
- Global flood policy enforcement
- Unscoped flood policy enforcement
- mark_seen / is_duplicate cache behaviour
- do_not_retransmit flag handling
"""
Expand Down Expand Up @@ -50,7 +50,7 @@ def _make_handler(
loop_detect="off",
path_hash_mode=0,
local_hash_bytes=None,
global_flood_allow=True,
unscoped_flood_allow=True,
):
"""Create a RepeaterHandler with real engine logic, mocking only hardware."""
lhb = local_hash_bytes or LOCAL_HASH_BYTES
Expand All @@ -64,7 +64,7 @@ def _make_handler(
"node_name": "test-node",
},
"mesh": {
"global_flood_allow": global_flood_allow,
"unscoped_flood_allow": unscoped_flood_allow,
"loop_detect": loop_detect,
"path_hash_mode": path_hash_mode,
},
Expand Down Expand Up @@ -398,29 +398,29 @@ def test_2_byte_flood_forward_appends_correctly(self):


# ===================================================================
# 5. Global flood policy
# 5. Unscoped flood policy
# ===================================================================


class TestGlobalFloodPolicy:
"""Test global_flood_allow=False blocks flood packets."""
class TestUnscopedFloodPolicy:
"""Test unscoped=False blocks flood packets."""

def test_global_flood_disabled_drops_flood(self):
h = _make_handler(global_flood_allow=False)
def test_unscoped_flood_disabled_drops_flood(self):
h = _make_handler(unscoped_flood_allow=False)
pkt = _make_flood_packet(payload=b"\x01\x02")
result = h.flood_forward(pkt)
assert result is None
assert pkt.drop_reason is not None

def test_global_flood_enabled_allows_flood(self):
h = _make_handler(global_flood_allow=True)
def test_unscoped_flood_enabled_allows_flood(self):
h = _make_handler(unscoped_flood_allow=True)
pkt = _make_flood_packet(payload=b"\x01\x02")
result = h.flood_forward(pkt)
assert result is not None

def test_transport_flood_without_codes_drops(self):
"""ROUTE_TYPE_TRANSPORT_FLOOD with global_flood_allow=False and no valid codes."""
h = _make_handler(global_flood_allow=False)
"""ROUTE_TYPE_TRANSPORT_FLOOD with unscoped_flood_allow=False and no valid codes."""
h = _make_handler(unscoped_flood_allow=False)
# Nullify the storage to ensure transport code check fails
h.storage = None
pkt = Packet()
Expand Down
2 changes: 1 addition & 1 deletion tests/test_path_hash_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ def _make_handler(path_hash_mode=0, local_hash_bytes=None):
"node_name": "test-node",
},
"mesh": {
"global_flood_allow": True,
"unscoped_flood_allow": True,
"loop_detect": "off",
"path_hash_mode": path_hash_mode,
},
Expand Down