Merge pull request #170 from zindello/feat/fixRegions

Feat/fix regions: rename global_flood_allow to unscoped_flood_allow,
fix region handling to correctly separate unscoped traffic from scoped
regions, maintain backward compat with existing config files.
This commit is contained in:
Lloyd
2026-04-09 08:45:29 +01:00
8 changed files with 78 additions and 78 deletions

View File

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

View File

@@ -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:
@@ -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

View File

@@ -623,10 +623,10 @@ class RepeaterHandler(BaseHandler):
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()
@@ -800,19 +800,20 @@ class RepeaterHandler(BaseHandler):
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()
@@ -1134,7 +1135,7 @@ class RepeaterHandler(BaseHandler):
"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", {}),

View File

@@ -15,7 +15,7 @@ from repeater.companion.identity_resolve import (
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
@@ -93,8 +93,8 @@ logger = logging.getLogger("HTTPServer")
# 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
@@ -2153,57 +2153,57 @@ class APIEndpoints:
@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")

View File

@@ -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: []
@@ -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: []

View File

@@ -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": {
@@ -228,11 +228,10 @@ class TestFloodForward:
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
@@ -656,26 +655,26 @@ class TestHashStabilityThroughForwarding:
# ===================================================================
# 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)
@@ -812,7 +811,7 @@ class TestGetDropReason:
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()
@@ -1202,7 +1201,7 @@ BAD_PACKETS = [
"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"),
@@ -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)
@@ -1337,7 +1336,7 @@ class TestBadPacketArray:
)
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)
@@ -1353,7 +1352,7 @@ class TestBadPacketArray:
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)

View File

@@ -7,7 +7,7 @@ objects to verify:
- 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
"""
@@ -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
@@ -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,
},
@@ -398,29 +398,29 @@ class TestLoopDetectionMultiByte:
# ===================================================================
# 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()

View File

@@ -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,
},