diff --git a/CHANGELOG.md b/CHANGELOG.md index d882b02..881a6be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.2] - 2025-11-15 + +### Added +- pipx installation support with automated D-Bus dependency handling +- BlueZ LE-only mode configuration in installer (prevents BR/EDR fallback) +- Scanner watchdog to detect and recover from Bluetooth stack corruption +- Service UUID filtering for more efficient peer discovery +- Pre-built wheel support for Pi Zero W Python 3.13 (saves 20+ min install time) + ### Fixed - **Connection race condition causing "Operation already in progress" errors** - Added `_connecting_peers` state tracking in `linux_bluetooth_driver.py` to prevent concurrent connection attempts to the same peer @@ -24,7 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Prevents BlueZ from maintaining stale connection state after abandoned connection attempts - Enables successful reconnection after blacklist period expires - Fixes issue where devices could not reconnect after multiple failed attempts due to corrupted BlueZ state - - Files: `src/RNS/Interfaces/linux_bluetooth_driver.py` (lines 786-830, 980-1069), `src/RNS/Interfaces/BLEInterface.py` (lines 1475-1490) + - Files: `src/RNS/Interfaces/linux_bluetooth_driver.py`, `src/RNS/Interfaces/BLEInterface.py` - **Scanner interference causing "Operation already in progress" errors during connection attempts** - Added `_should_pause_scanning()` method to check for active connections before starting scanner @@ -34,7 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Prevents BlueZ "InProgress" errors from scanner.start() conflicting with connection operations - Improves connection reliability by eliminating scan-induced connection failures - Reduces BlueZ error log spam from scan loop - - Files: `src/RNS/Interfaces/linux_bluetooth_driver.py` (lines 539-551, 586-588) + - Files: `src/RNS/Interfaces/linux_bluetooth_driver.py` - Tests: `tests/test_scanner_connection_coordination.py` - **BR/EDR fallback - clarify ConnectDevice() object path return as success** @@ -44,7 +53,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Some BlueZ versions report BR/EDR profile unavailable while LE connection succeeds - this is expected - Improved logging shows object path for debugging visibility - Clarifies that object path return means success, not error - - Files: `src/RNS/Interfaces/linux_bluetooth_driver.py` (lines 1121-1132) + - Files: `src/RNS/Interfaces/linux_bluetooth_driver.py` - Tests: `tests/test_breddr_fallback_prevention.py` - **GATT server initialization race causing "Reticulum service not found" errors** @@ -55,9 +64,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Eliminates "service not found" errors during server startup window (typically 50-200ms) - Graceful degradation: warns if verification times out but doesn't fail startup - Typical verification time: 100-300ms, no runtime performance impact - - Files: `src/RNS/Interfaces/linux_bluetooth_driver.py` (lines 1493-1559, 1527-1538) + - Files: `src/RNS/Interfaces/linux_bluetooth_driver.py` - Tests: `tests/test_gatt_server_readiness.py` +- D-Bus disconnect monitoring switched to ObjectManager with polling fallback +- Peripheral disconnect cleanup preventing new connections after hitting peer limit +- Identity mapping cleanup on disconnect (prevents stale peer tracking) +- RSSI sentinel value filtering (-127 from BlueZ) +- Columba Android compatibility (filter 1-byte keepalive packets) + +### Changed +- Refactored to driver-based architecture (future Windows/macOS/Android support) + ## [0.1.1] - 2025-11-10 ### Fixed @@ -145,7 +163,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Known Issues - MAC address randomization can cause connection issues (fixed in v2.2.0) -- Race condition from concurrent connection attempts (fixed in unreleased) +- Race condition from concurrent connection attempts (fixed in v0.2.2) - BR/EDR fallback on dual-mode devices (fixed in v2.2.0) --- diff --git a/src/RNS/Interfaces/BLEInterface.py b/src/RNS/Interfaces/BLEInterface.py index f7a089f..22ac5ba 100644 --- a/src/RNS/Interfaces/BLEInterface.py +++ b/src/RNS/Interfaces/BLEInterface.py @@ -284,6 +284,26 @@ class BLEInterface(Interface): super().__init__() + # CRITICAL: Set HW_MTU as instance attribute after super().__init__() + # + # Bug explanation: + # - Base Interface.__init__() sets self.HW_MTU = None + # - BLEInterface.HW_MTU = 500 is a CLASS attribute, not instance + # - After super().__init__(), self.HW_MTU is None (instance shadows class) + # - BLEPeerInterface copies: self.HW_MTU = parent.HW_MTU (gets None) + # + # Impact when HW_MTU is None: + # - Transport.py line ~1855 checks: if packet.receiving_interface.HW_MTU == None + # - If true, it TRUNCATES packet.data by 3 bytes (LINK_MTU_SIZE) before + # passing to Link.validate_request() + # - Link.link_id_from_lr_packet() uses len(packet.data) to compute truncation + # - Since packet.data was pre-truncated, it computes WRONG link_id + # - Link proof's destination_hash won't match pending link's link_id + # - Result: Links time out despite proof arriving correctly + # + # This bug ONLY affects BLE because other interfaces set HW_MTU in __init__ + self.HW_MTU = BLEInterface.HW_MTU + # Parse configuration c = Interface.get_config_obj(configuration) @@ -364,6 +384,7 @@ class BLEInterface(Interface): self.fragmenters = {} # address -> BLEFragmenter (per MTU) self.reassemblers = {} # address -> BLEReassembler self.frag_lock = threading.Lock() + self.pending_mtu = {} # address -> mtu (for MTU/identity race condition) # Discovery state with prioritization @@ -751,7 +772,7 @@ class BLEInterface(Interface): role = self.driver.get_peer_role(address) if peer_identity is not None: - # Central mode: identity provided by driver + # Identity provided by driver (central mode direct, peripheral mode via late callback) if len(peer_identity) == 16: identity_hash = self._compute_identity_hash(peer_identity) @@ -759,8 +780,15 @@ class BLEInterface(Interface): self.address_to_identity[address] = peer_identity self.identity_to_address[identity_hash] = address - RNS.log(f"{self} connected to {address} as CENTRAL, received identity: {identity_hash}", RNS.LOG_INFO) + role_str = role.upper() if role else "UNKNOWN" + RNS.log(f"{self} connected to {address} as {role_str}, received identity: {identity_hash}", RNS.LOG_INFO) self._record_connection_success(address) + + # Check for pending MTU (race condition: MTU negotiated before identity) + if address in self.pending_mtu: + pending_mtu = self.pending_mtu.pop(address) + RNS.log(f"{self} creating deferred fragmenter for {address} (MTU={pending_mtu})", RNS.LOG_DEBUG) + self._mtu_negotiated_callback(address, pending_mtu) else: RNS.log(f"{self} invalid identity from {address} (wrong length), disconnecting", RNS.LOG_WARNING) self.driver.disconnect(address) @@ -819,7 +847,10 @@ class BLEInterface(Interface): # Get peer identity peer_identity = self.address_to_identity.get(address) if not peer_identity: - RNS.log(f"{self} no identity for {address}, cannot create fragmenter", RNS.LOG_WARNING) + # Race condition: MTU negotiated before identity received + # Store pending MTU and create fragmenter when identity arrives + RNS.log(f"{self} no identity for {address}, storing pending MTU {mtu}", RNS.LOG_DEBUG) + self.pending_mtu[address] = mtu return # Create or update fragmenter @@ -980,6 +1011,50 @@ class BLEInterface(Interface): if frag_key in self.reassemblers: del self.reassemblers[frag_key] + # Clean up pending MTU (from MTU/identity race condition) + if address in self.pending_mtu: + del self.pending_mtu[address] + + def _cleanup_stale_interface(self, identity_hash: str, old_address: str): + """ + Clean up stale interface after MAC rotation. + + Called when we detect the same identity at a new MAC address but the + old connection is no longer alive. This allows reconnection to the + peer at their new MAC address. + + Args: + identity_hash: 16-character hex hash of the peer's identity + old_address: The old MAC address that is no longer valid + """ + # Get peer identity for fragmenter cleanup + peer_identity = self.address_to_identity.get(old_address) + + # Detach and remove old interface + if identity_hash in self.spawned_interfaces: + old_interface = self.spawned_interfaces.pop(identity_hash) + old_interface.detach() + RNS.log(f"{self} detached stale interface for {identity_hash[:8]}", RNS.LOG_DEBUG) + + # Clean up address mappings + if identity_hash in self.identity_to_address: + del self.identity_to_address[identity_hash] + + # Clean up fragmenter/reassembler for old address + if peer_identity: + frag_key = self._get_fragmenter_key(peer_identity, old_address) + with self.frag_lock: + if frag_key in self.fragmenters: + del self.fragmenters[frag_key] + if frag_key in self.reassemblers: + del self.reassemblers[frag_key] + + # Clean up pending MTU for old address + if old_address in self.pending_mtu: + del self.pending_mtu[old_address] + + RNS.log(f"{self} cleaned up stale state for {old_address}", RNS.LOG_DEBUG) + def _error_callback(self, severity: str, message: str, exc: Exception = None): """ Driver callback: Handle driver errors. @@ -1186,15 +1261,37 @@ class BLEInterface(Interface): RNS.LOG_DEBUG) continue - # Protocol v2.2: Skip if interface exists for this identity (any connection type) - # This prevents dual connections (central + peripheral to same peer) + # Protocol v2.2: Skip if interface exists AND is still alive + # This prevents dual connections but allows MAC rotation recovery peer_identity = self.address_to_identity.get(address) if peer_identity: identity_hash = self._compute_identity_hash(peer_identity) if identity_hash in self.spawned_interfaces: - RNS.log(f"{self} [v2.2] skipping {peer.name} - interface exists for identity {identity_hash[:8]}", - RNS.LOG_DEBUG) - continue + # Check if existing interface is still connected + existing_address = self.identity_to_address.get(identity_hash) + if existing_address and existing_address != address: + # Same identity at different MAC = MAC rotation + # Check if old connection is still alive + if existing_address in self.peers: + # Old connection still active - skip (correct behavior) + RNS.log(f"{self} [v2.2] skipping {peer.name} - already connected via {existing_address[-8:]}", + RNS.LOG_DEBUG) + continue + else: + # Old connection dead - clean up and allow new connection + RNS.log(f"{self} [v2.2] MAC rotation: {identity_hash[:8]} moved from {existing_address[-8:]} to {address[-8:]}, cleaning up stale interface", + RNS.LOG_INFO) + self._cleanup_stale_interface(identity_hash, existing_address) + # Bypass MAC sorting - we must reconnect after MAC rotation + # regardless of which device has the higher MAC address + score = self._score_peer(peer) + scored_peers.append((score, peer)) + continue # Skip remaining checks, peer already added + elif existing_address == address: + # Same address, interface exists - skip + RNS.log(f"{self} [v2.2] skipping {peer.name} - interface exists for identity {identity_hash[:8]}", + RNS.LOG_DEBUG) + continue # Protocol v2.2: MAC address sorting - deterministic connection direction # Lower MAC initiates (central), higher MAC only accepts (peripheral) diff --git a/tests/test_v2_2_mac_sorting.py b/tests/test_v2_2_mac_sorting.py index 0e50a61..9ca5038 100644 --- a/tests/test_v2_2_mac_sorting.py +++ b/tests/test_v2_2_mac_sorting.py @@ -59,11 +59,9 @@ if not hasattr(RNS, 'Identity'): RNS.Identity = MagicMock() RNS.Identity.full_hash = lambda x: (x * 2)[:16] -# Mock RNS.Interfaces.Interface (required by BLEInterface.py) -if 'RNS.Interfaces' not in _sys.modules: - rns_interfaces_mock = MagicMock() - _sys.modules['RNS.Interfaces'] = rns_interfaces_mock - +# Mock RNS.Interfaces.Interface module (the base class module, not the whole namespace) +# We only mock the Interface.py module, allowing BLEInterface.py to be imported from src/ +if 'RNS.Interfaces.Interface' not in _sys.modules: # Create mock Interface base class class MockInterface: MODE_FULL = 1 @@ -72,7 +70,40 @@ if 'RNS.Interfaces' not in _sys.modules: self.OUT = True self.online = False - rns_interfaces_mock.Interface = MockInterface + @staticmethod + def get_config_obj(configuration): + """Mock config object wrapper - just returns a dict-like object.""" + class ConfigObj: + def __init__(self, config): + self._config = config if config else {} + + def __getitem__(self, key): + return self._config.get(key) + + def get(self, key, default=None): + return self._config.get(key, default) + + def as_string(self, key, default=None): + val = self._config.get(key, default) + return str(val) if val is not None else default + + def as_int(self, key, default=None): + val = self._config.get(key, default) + return int(val) if val is not None else default + + def as_bool(self, key, default=False): + val = self._config.get(key, default) + if isinstance(val, bool): + return val + if isinstance(val, str): + return val.lower() in ('true', 'yes', '1', 'on') + return bool(val) if val is not None else default + return ConfigObj(configuration) + + # Create a mock module for RNS.Interfaces.Interface + interface_module = MagicMock() + interface_module.Interface = MockInterface + _sys.modules['RNS.Interfaces.Interface'] = interface_module from tests.mock_ble_driver import MockBLEDriver from RNS.Interfaces.BLEInterface import BLEInterface, DiscoveredPeer @@ -212,10 +243,13 @@ class TestMACEdgeCases: peers_to_connect = interface._select_peers_to_connect() peer_addresses = [p.address for p in peers_to_connect] - # Should only connect to peer with lower MAC (00) - assert "AA:BB:CC:DD:EE:00" in peer_addresses - assert "AA:BB:CC:DD:EE:02" not in peer_addresses - assert "AA:BB:CC:DD:EE:FF" not in peer_addresses + # MAC sorting: lower MAC initiates. Our MAC is AA:BB:CC:DD:EE:01 + # - AA:BB:CC:DD:EE:00 is LOWER than us, so THEY initiate (we skip) + # - AA:BB:CC:DD:EE:02 is HIGHER than us, so WE initiate + # - AA:BB:CC:DD:EE:FF is HIGHER than us, so WE initiate + assert "AA:BB:CC:DD:EE:00" not in peer_addresses # They initiate + assert "AA:BB:CC:DD:EE:02" in peer_addresses # We initiate + assert "AA:BB:CC:DD:EE:FF" in peer_addresses # We initiate class TestDualConnectionPrevention: @@ -269,11 +303,12 @@ class TestDualConnectionPrevention: interface.local_address = driver.local_address # Add peers with MACs above and below ours + # MAC sorting: lower MAC initiates. Our MAC is 55:55:55:55:55:55 peers_data = [ - ("11:11:11:11:11:11", -60), # Below (should connect) - ("22:22:22:22:22:22", -60), # Below (should connect) - ("AA:AA:AA:AA:AA:AA", -60), # Above (should NOT connect) - ("FF:FF:FF:FF:FF:FF", -60), # Above (should NOT connect) + ("11:11:11:11:11:11", -60), # LOWER than us - THEY initiate, we skip + ("22:22:22:22:22:22", -60), # LOWER than us - THEY initiate, we skip + ("AA:AA:AA:AA:AA:AA", -60), # HIGHER than us - WE initiate + ("FF:FF:FF:FF:FF:FF", -60), # HIGHER than us - WE initiate ] for addr, rssi in peers_data: @@ -284,11 +319,11 @@ class TestDualConnectionPrevention: peers_to_connect = interface._select_peers_to_connect() peer_addresses = [p.address for p in peers_to_connect] - # Should connect to lower MACs only - assert "11:11:11:11:11:11" in peer_addresses - assert "22:22:22:22:22:22" in peer_addresses - assert "AA:AA:AA:AA:AA:AA" not in peer_addresses - assert "FF:FF:FF:FF:FF:FF" not in peer_addresses + # MAC sorting: lower MAC initiates, so we connect to HIGHER MACs + assert "11:11:11:11:11:11" not in peer_addresses # They initiate + assert "22:22:22:22:22:22" not in peer_addresses # They initiate + assert "AA:AA:AA:AA:AA:AA" in peer_addresses # We initiate + assert "FF:FF:FF:FF:FF:FF" in peer_addresses # We initiate class TestMACParsingErrors: @@ -317,5 +352,175 @@ class TestMACParsingErrors: pytest.fail(f"Invalid MAC should be handled gracefully: {e}") +class TestMACRotationBypassesSorting: + """ + Test that MAC rotation bypasses MAC sorting. + + Bug fix: After MAC rotation cleanup, the peer must be added to the connection + list regardless of MAC sorting. Previously, the code fell through to the MAC + sorting check which could skip the peer if local MAC > peer MAC. + + Fix: After _cleanup_stale_interface(), immediately add peer and continue, + bypassing the MAC sorting check. + """ + + def test_mac_rotation_bypasses_sorting_when_local_mac_higher(self): + """ + Test that MAC rotation adds peer even when local MAC is higher. + + This is the core bug fix test. Without the fix: + - MAC rotation detected, stale interface cleaned up + - Code falls through to MAC sorting check + - Local MAC (FF:...) > Peer MAC (11:...) → peer skipped + - Peer interface never recreated! + + With the fix: + - MAC rotation detected, stale interface cleaned up + - Peer immediately added, continue (bypass MAC sorting) + - Peer interface recreated correctly + """ + driver = MockBLEDriver(local_address="FF:FF:FF:FF:FF:FF") # Higher MAC + owner = MockOwner() + + config = {"name": "Test", "enable_central": True} + interface = BLEInterface(owner, config) + interface.driver = driver + interface.local_address = driver.local_address + + # Set up MAC rotation scenario: + # - Identity exists at old address + # - Peer discovered at new address (lower MAC) + # - Old connection is stale (not in peers dict) + old_address = "AA:AA:AA:AA:AA:AA" + new_address = "11:22:33:44:55:66" # Lower than local MAC + peer_identity = bytes.fromhex("ab5609dfffb33b21a102e1ff81196be5") + identity_hash = interface._compute_identity_hash(peer_identity) + + # Set up existing identity mapping at old address + interface.identity_to_address[identity_hash] = old_address + interface.address_to_identity[new_address] = peer_identity + + # Create a mock spawned interface (stale) + mock_peer_interface = MagicMock() + interface.spawned_interfaces[identity_hash] = mock_peer_interface + + # old_address NOT in interface.peers (connection is dead/stale) + + # Discover peer at new address + peer = DiscoveredPeer(new_address, "RNS-ab5609", -60) + interface.discovered_peers[new_address] = peer + + # Select peers to connect + peers_to_connect = interface._select_peers_to_connect() + peer_addresses = [p.address for p in peers_to_connect] + + # Even though local MAC > peer MAC, peer should be added due to MAC rotation + assert new_address in peer_addresses, \ + "MAC rotation should bypass MAC sorting and add peer" + + def test_mac_rotation_cleanup_is_called(self): + """Test that _cleanup_stale_interface is called during MAC rotation.""" + driver = MockBLEDriver(local_address="FF:FF:FF:FF:FF:FF") + owner = MockOwner() + + config = {"name": "Test", "enable_central": True} + interface = BLEInterface(owner, config) + interface.driver = driver + interface.local_address = driver.local_address + + # Track cleanup calls + cleanup_calls = [] + original_cleanup = interface._cleanup_stale_interface + + def tracked_cleanup(identity_hash, old_address): + cleanup_calls.append((identity_hash, old_address)) + return original_cleanup(identity_hash, old_address) + + interface._cleanup_stale_interface = tracked_cleanup + + # Set up MAC rotation scenario + old_address = "AA:AA:AA:AA:AA:AA" + new_address = "11:22:33:44:55:66" + peer_identity = bytes.fromhex("ab5609dfffb33b21a102e1ff81196be5") + identity_hash = interface._compute_identity_hash(peer_identity) + + interface.identity_to_address[identity_hash] = old_address + interface.address_to_identity[new_address] = peer_identity + + mock_peer_interface = MagicMock() + interface.spawned_interfaces[identity_hash] = mock_peer_interface + + # Discover peer at new address + peer = DiscoveredPeer(new_address, "RNS-ab5609", -60) + interface.discovered_peers[new_address] = peer + + # Select peers + interface._select_peers_to_connect() + + # Verify cleanup was called + assert len(cleanup_calls) == 1 + assert cleanup_calls[0] == (identity_hash, old_address) + + def test_active_connection_prevents_rotation_cleanup(self): + """Test that active connection prevents MAC rotation cleanup.""" + driver = MockBLEDriver(local_address="FF:FF:FF:FF:FF:FF") + owner = MockOwner() + + config = {"name": "Test", "enable_central": True} + interface = BLEInterface(owner, config) + interface.driver = driver + interface.local_address = driver.local_address + + # Set up scenario where old connection is ACTIVE + old_address = "AA:AA:AA:AA:AA:AA" + new_address = "11:22:33:44:55:66" + peer_identity = bytes.fromhex("ab5609dfffb33b21a102e1ff81196be5") + identity_hash = interface._compute_identity_hash(peer_identity) + + interface.identity_to_address[identity_hash] = old_address + interface.address_to_identity[new_address] = peer_identity + + mock_peer_interface = MagicMock() + interface.spawned_interfaces[identity_hash] = mock_peer_interface + + # OLD connection is ACTIVE (in peers dict) + interface.peers[old_address] = {"mtu": 512} + + # Discover peer at new address + peer = DiscoveredPeer(new_address, "RNS-ab5609", -60) + interface.discovered_peers[new_address] = peer + + # Select peers + peers_to_connect = interface._select_peers_to_connect() + peer_addresses = [p.address for p in peers_to_connect] + + # Should NOT add peer (old connection still active) + assert new_address not in peer_addresses, \ + "Active connection should prevent MAC rotation" + + def test_normal_mac_sorting_still_works(self): + """Test that normal MAC sorting still works when no rotation.""" + driver = MockBLEDriver(local_address="FF:FF:FF:FF:FF:FF") # Higher MAC + owner = MockOwner() + + config = {"name": "Test", "enable_central": True} + interface = BLEInterface(owner, config) + interface.driver = driver + interface.local_address = driver.local_address + + # No existing identity mapping - this is a completely new peer + peer_address = "11:22:33:44:55:66" # Lower MAC + peer = DiscoveredPeer(peer_address, "NewPeer", -60) + interface.discovered_peers[peer_address] = peer + + # Select peers + peers_to_connect = interface._select_peers_to_connect() + peer_addresses = [p.address for p in peers_to_connect] + + # Should NOT add peer (they have lower MAC, they should initiate) + assert peer_address not in peer_addresses, \ + "Normal MAC sorting should skip peer with lower MAC" + + if __name__ == "__main__": pytest.main([__file__, "-v"])