diff --git a/config.yaml.example b/config.yaml.example index ccd1436..2cadef6 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -39,6 +39,20 @@ repeater: # with its node information (node type 2 - repeater) allow_discovery: true + # Security settings for login/authentication (shared across all identities) + security: + # Maximum number of authenticated clients (across all identities) + max_clients: 1 + + # Admin password for full access + admin_password: "admin123" + + # Guest password for limited access + guest_password: "guest123" + + # Allow read-only access for clients without password/not in ACL + allow_read_only: false + # Mesh Network Configuration mesh: # Global flood policy - controls whether the repeater allows or denies flooding by default @@ -46,6 +60,33 @@ mesh: # Individual transport keys can override this setting global_flood_allow: true +# Multiple Identity Configuration (Optional) +# Define additional identities for the repeater to manage +# Each identity operates independently with its own key pair and configuration +# Note: All identities share the same ACL (security settings from repeater.security) +identities: + # Room Server Identities + # Each room server acts as a separate logical node on the mesh + room_servers: + # Example room server configuration (commented out by default) + # - name: "TestBBS" + # identity_key: "your_room_identity_key_hex_here" + # type: "room_server" + # + # # Room-specific settings + # settings: + # node_name: "Test BBS Room" + # latitude: 0.0 + # longitude: 0.0 + # disable_fwd: true # Room servers typically don't forward + + # Add more room servers as needed + # - name: "SocialHub" + # identity_key: "another_identity_key_hex_here" + # type: "room_server" + # settings: + # node_name: "Social Hub" + radio: # Frequency in Hz (869.618 MHz for EU) frequency: 869618000 diff --git a/pyproject.toml b/pyproject.toml index f49e382..af5b61b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ keywords = ["mesh", "networking", "lora", "repeater", "daemon", "iot"] dependencies = [ - "pymc_core[hardware] @ git+https://github.com/rightup/pyMC_core.git@dev", + "pymc_core[hardware] @ git+https://github.com/rightup/pyMC_core.git@feat/anon-req", "pyyaml>=6.0.0", "cherrypy>=18.0.0", "paho-mqtt>=1.6.0", diff --git a/repeater/handler_helpers/__init__.py b/repeater/handler_helpers/__init__.py index 08f5104..1c7a52a 100644 --- a/repeater/handler_helpers/__init__.py +++ b/repeater/handler_helpers/__init__.py @@ -3,5 +3,6 @@ from .trace import TraceHelper from .discovery import DiscoveryHelper from .advert import AdvertHelper +from .login import LoginHelper -__all__ = ["TraceHelper", "DiscoveryHelper", "AdvertHelper"] +__all__ = ["TraceHelper", "DiscoveryHelper", "AdvertHelper", "LoginHelper"] diff --git a/repeater/handler_helpers/acl.py b/repeater/handler_helpers/acl.py new file mode 100644 index 0000000..d36b608 --- /dev/null +++ b/repeater/handler_helpers/acl.py @@ -0,0 +1,127 @@ +""" +Access Control List for pyMC Repeater. + +Manages authenticated clients with permission-based access control. +Shared across all identities (repeater and room servers). +""" + +import logging +import time +from typing import Dict, Optional + +from pymc_core.protocol import Identity +from pymc_core.protocol.constants import PUB_KEY_SIZE + +logger = logging.getLogger("ACL") + +PERM_ACL_GUEST = 0x01 +PERM_ACL_ADMIN = 0x02 +PERM_ACL_READ_WRITE = 0x01 +PERM_ACL_ROLE_MASK = 0x03 + + +class ClientInfo: + """Represents an authenticated client in the access control list.""" + + def __init__(self, identity: Identity, permissions: int = 0): + self.id = identity + self.permissions = permissions + self.shared_secret = b"" + self.last_timestamp = 0 + self.last_activity = 0 + self.last_login_success = 0 + self.out_path_len = -1 + self.out_path = bytearray() + + def is_admin(self) -> bool: + return (self.permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_ADMIN + + def is_guest(self) -> bool: + return (self.permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST + + +class ACL: + + def __init__( + self, + max_clients: int = 50, + admin_password: str = "admin123", + guest_password: str = "guest123", + allow_read_only: bool = True, + ): + self.max_clients = max_clients + self.admin_password = admin_password + self.guest_password = guest_password + self.allow_read_only = allow_read_only + self.clients: Dict[bytes, ClientInfo] = {} + + def authenticate_client( + self, client_identity: Identity, shared_secret: bytes, password: str, timestamp: int + ) -> tuple[bool, int]: + + pub_key = client_identity.get_public_key()[:PUB_KEY_SIZE] + + if not password: + client = self.clients.get(pub_key) + if client is None: + if self.allow_read_only: + logger.info("Blank password, allowing read-only guest access") + return True, PERM_ACL_GUEST + else: + logger.info("Blank password, sender not in ACL and read-only disabled") + return False, 0 + logger.info(f"ACL-based login for {pub_key[:6].hex()}...") + return True, client.permissions + + permissions = 0 + if password == self.admin_password: + permissions = PERM_ACL_ADMIN + logger.info("Admin password validated") + elif self.guest_password and password == self.guest_password: + permissions = PERM_ACL_READ_WRITE + logger.info("Guest password validated") + else: + logger.info("Invalid password") + return False, 0 + + client = self.clients.get(pub_key) + if client is None: + if len(self.clients) >= self.max_clients: + logger.warning("ACL full, cannot add client") + return False, 0 + + client = ClientInfo(client_identity, 0) + self.clients[pub_key] = client + logger.info(f"Added new client {pub_key[:6].hex()}...") + + if timestamp <= client.last_timestamp: + logger.warning( + f"Possible replay attack! timestamp={timestamp}, last={client.last_timestamp}" + ) + return False, 0 + + client.last_timestamp = timestamp + client.last_activity = int(time.time()) + client.last_login_success = int(time.time()) + client.permissions &= ~PERM_ACL_ROLE_MASK + client.permissions |= permissions + client.shared_secret = shared_secret + + logger.info(f"Login success! Permissions: {'ADMIN' if client.is_admin() else 'GUEST'}") + return True, client.permissions + + def get_client(self, pub_key: bytes) -> Optional[ClientInfo]: + return self.clients.get(pub_key[:PUB_KEY_SIZE]) + + def get_num_clients(self) -> int: + return len(self.clients) + + def get_all_clients(self): + return list(self.clients.values()) + + def remove_client(self, pub_key: bytes) -> bool: + key = pub_key[:PUB_KEY_SIZE] + if key in self.clients: + del self.clients[key] + return True + return False diff --git a/repeater/handler_helpers/login.py b/repeater/handler_helpers/login.py new file mode 100644 index 0000000..3216261 --- /dev/null +++ b/repeater/handler_helpers/login.py @@ -0,0 +1,90 @@ +""" +Login/ANON_REQ packet handling helper for pyMC Repeater. + +This module processes login requests and manages authentication for all identities. +""" + +import asyncio +import logging + +from pymc_core.node.handlers.login_server import LoginServerHandler + +logger = logging.getLogger("LoginHelper") + + +class LoginHelper: + """Helper class for processing ANON_REQ login packets in the repeater.""" + + def __init__(self, identity_manager, packet_injector=None, acl=None, log_fn=None): + + self.identity_manager = identity_manager + self.packet_injector = packet_injector + self.log_fn = log_fn or logger.info + self.acl = acl + + self.handlers = {} + + def register_identity(self, name: str, identity, identity_type: str = "room_server"): + + if not self.acl: + logger.warning(f"Cannot register identity '{name}': no ACL configured") + return + + handler = LoginServerHandler( + local_identity=identity, + log_fn=self.log_fn, + authenticate_callback=self.acl.authenticate_client, + ) + + handler.set_send_packet_callback(self._send_packet_with_delay) + + hash_byte = identity.get_public_key()[0] + self.handlers[hash_byte] = handler + + logger.info(f"Registered {identity_type} '{name}' login handler: hash=0x{hash_byte:02X}") + + async def process_login_packet(self, packet): + + try: + if len(packet.payload) < 1: + return False + + dest_hash = packet.payload[0] + + handler = self.handlers.get(dest_hash) + if handler: + logger.debug(f"Routing login to identity: hash=0x{dest_hash:02X}") + await handler(packet) + packet.mark_do_not_retransmit() + return True + else: + logger.debug(f"No login handler registered for hash 0x{dest_hash:02X}, allowing forward") + return False + + except Exception as e: + logger.error(f"Error processing login packet: {e}") + return False + + def _send_packet_with_delay(self, packet, delay_ms: int): + + if self.packet_injector: + asyncio.create_task(self._delayed_send(packet, delay_ms)) + else: + logger.error("No packet injector configured, cannot send login response") + + async def _delayed_send(self, packet, delay_ms: int): + + await asyncio.sleep(delay_ms / 1000.0) + try: + await self.packet_injector(packet, wait_for_ack=False) + logger.debug(f"Sent login response after {delay_ms}ms delay") + except Exception as e: + logger.error(f"Error sending login response: {e}") + + def get_acl(self): + return self.acl + + def list_authenticated_clients(self): + if self.acl: + return self.acl.get_all_clients() + return [] diff --git a/repeater/identity_manager.py b/repeater/identity_manager.py new file mode 100644 index 0000000..d626adf --- /dev/null +++ b/repeater/identity_manager.py @@ -0,0 +1,64 @@ +import logging +from typing import Dict, Optional, Tuple, Any + +logger = logging.getLogger("IdentityManager") + + +class IdentityManager: + + def __init__(self, config: dict): + self.config = config + self.identities: Dict[int, Tuple[Any, dict, str]] = {} + self.named_identities: Dict[str, Tuple[Any, dict, str]] = {} + self.registered_hashes: Dict[int, str] = {} + + def register_identity(self, name: str, identity, config: dict, identity_type: str): + hash_byte = identity.get_public_key()[0] + + if hash_byte in self.identities: + existing_name = self.registered_hashes.get(hash_byte, "unknown") + logger.error( + f"Hash collision! Identity '{name}' (hash=0x{hash_byte:02X}) " + f"conflicts with existing identity '{existing_name}'" + ) + return False + + self.identities[hash_byte] = (identity, config, identity_type) + self.named_identities[name] = (identity, config, identity_type) + self.registered_hashes[hash_byte] = f"{identity_type}:{name}" + + logger.info( + f"Identity registered: name={name}, hash=0x{hash_byte:02X}, type={identity_type}" + ) + return True + + def get_identity_by_hash(self, hash_byte: int) -> Optional[Tuple[Any, dict, str]]: + return self.identities.get(hash_byte) + + def get_identity_by_name(self, name: str) -> Optional[Tuple[Any, dict, str]]: + return self.named_identities.get(name) + + def has_identity(self, hash_byte: int) -> bool: + return hash_byte in self.identities + + def list_identities(self) -> list: + identities = [] + for hash_byte, (identity, config, id_type) in self.identities.items(): + name = self.registered_hashes.get(hash_byte, "unknown") + identities.append({ + "hash": f"0x{hash_byte:02X}", + "name": name, + "type": id_type, + "address": identity.get_address_bytes().hex() if identity else "N/A" + }) + return identities + + def has_identity_type(self, identity_type: str) -> bool: + return any(id_type == identity_type for _, _, id_type in self.identities.values()) + + def get_identities_by_type(self, identity_type: str) -> list: + results = [] + for name, (identity, config, id_type) in self.named_identities.items(): + if id_type == identity_type: + results.append((name, identity, config)) + return results diff --git a/repeater/main.py b/repeater/main.py index ef11e51..1a87bf3 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -6,8 +6,9 @@ import sys from repeater.config import get_radio_for_board, load_config from repeater.engine import RepeaterHandler from repeater.web.http_server import HTTPStatsServer, _log_buffer -from repeater.handler_helpers import TraceHelper, DiscoveryHelper, AdvertHelper +from repeater.handler_helpers import TraceHelper, DiscoveryHelper, AdvertHelper, LoginHelper from repeater.packet_router import PacketRouter +from repeater.identity_manager import IdentityManager logger = logging.getLogger("RepeaterDaemon") @@ -22,10 +23,13 @@ class RepeaterDaemon: self.repeater_handler = None self.local_hash = None self.local_identity = None + self.identity_manager = None self.http_server = None self.trace_helper = None self.advert_helper = None self.discovery_helper = None + self.login_helper = None + self.acl = None self.router = None @@ -84,6 +88,11 @@ class RepeaterDaemon: self.dispatcher = Dispatcher(self.radio) logger.info("Dispatcher initialized") + # Initialize Identity Manager for additional identities (e.g., room servers) + self.identity_manager = IdentityManager(self.config) + logger.info("Identity manager initialized") + + # Set up default repeater identity (not managed by identity manager) identity_key = self.config.get("mesh", {}).get("identity_key") if not identity_key: logger.error("No identity key found in configuration. Cannot init repeater.") @@ -95,10 +104,13 @@ class RepeaterDaemon: pubkey = local_identity.get_public_key() self.local_hash = pubkey[0] + logger.info(f"Local identity set: {local_identity.get_address_bytes().hex()}") - local_hash_hex = f"0x{self.local_hash: 02x}" + local_hash_hex = f"0x{self.local_hash:02x}" logger.info(f"Local node hash (from identity): {local_hash_hex}") + # Load additional identities from config (e.g., room servers) + await self._load_additional_identities() self.dispatcher._is_own_packet = lambda pkt: False @@ -120,6 +132,7 @@ class RepeaterDaemon: local_hash=self.local_hash, repeater_handler=self.repeater_handler, packet_injector=self.router.inject_packet, + identity_manager=self.identity_manager, log_fn=logger.info, ) logger.info("Trace processing helper initialized") @@ -128,6 +141,7 @@ class RepeaterDaemon: self.advert_helper = AdvertHelper( local_identity=self.local_identity, storage=self.repeater_handler.storage if self.repeater_handler else None, + identity_manager=self.identity_manager, log_fn=logger.info, ) logger.info("Advert processing helper initialized") @@ -138,6 +152,7 @@ class RepeaterDaemon: self.discovery_helper = DiscoveryHelper( local_identity=self.local_identity, packet_injector=self.router.inject_packet, + identity_manager=self.identity_manager, node_type=2, log_fn=logger.info, ) @@ -145,10 +160,89 @@ class RepeaterDaemon: else: logger.info("Discovery response handler disabled") + # Create shared ACL for all identities + from repeater.handler_helpers.acl import ACL + + repeater_config = self.config.get("repeater", {}) + security_config = repeater_config.get("security", {}) + + shared_acl = ACL( + max_clients=security_config.get("max_clients", 50), + admin_password=security_config.get("admin_password", "admin123"), + guest_password=security_config.get("guest_password", "guest123"), + allow_read_only=security_config.get("allow_read_only", True), + ) + self.acl = shared_acl + logger.info("Shared ACL initialized") + + # Create login helper with shared ACL + self.login_helper = LoginHelper( + identity_manager=self.identity_manager, + packet_injector=self.router.inject_packet, + acl=shared_acl, + log_fn=logger.info, + ) + + # Register default repeater identity + self.login_helper.register_identity( + name="repeater", + identity=self.local_identity, + identity_type="repeater" + ) + + # Register room server identities + for name, identity, config in self.identity_manager.get_identities_by_type("room_server"): + self.login_helper.register_identity(name, identity, identity_type="room_server") + + logger.info("Login processing helper initialized") + except Exception as e: logger.error(f"Failed to initialize dispatcher: {e}") raise + async def _load_additional_identities(self): + from pymc_core import LocalIdentity + + identities_config = self.config.get("identities", {}) + + # Load room server identities + room_servers = identities_config.get("room_servers", []) + for room_config in room_servers: + try: + name = room_config.get("name") + identity_key = room_config.get("identity_key") + + if not name or not identity_key: + logger.warning( + f"Skipping room server config: missing name or identity_key" + ) + continue + + # Create the identity + room_identity = LocalIdentity(seed=identity_key) + + # Register with the manager + success = self.identity_manager.register_identity( + name=name, + identity=room_identity, + config=room_config, + identity_type="room_server" + ) + + if success: + room_hash = room_identity.get_public_key()[0] + logger.info( + f"Loaded room server '{name}': hash=0x{room_hash:02x}, " + f"address={room_identity.get_address_bytes().hex()}" + ) + + except Exception as e: + logger.error(f"Failed to load room server identity '{name}': {e}") + + # Summary logging + total_identities = len(self.identity_manager.list_identities()) + logger.info(f"Identity manager loaded {total_identities} total identities") + async def _router_callback(self, packet): """ Single entry point for ALL packets. diff --git a/repeater/packet_router.py b/repeater/packet_router.py index 8621c6f..5fd5d44 100644 --- a/repeater/packet_router.py +++ b/repeater/packet_router.py @@ -12,6 +12,7 @@ import logging from pymc_core.node.handlers.trace import TraceHandler from pymc_core.node.handlers.control import ControlHandler from pymc_core.node.handlers.advert import AdvertHandler +from pymc_core.node.handlers.login_server import LoginServerHandler logger = logging.getLogger("PacketRouter") @@ -125,6 +126,14 @@ class PacketRouter: snr = getattr(packet, "snr", 0.0) await self.daemon.advert_helper.process_advert_packet(packet, rssi, snr) + elif payload_type == LoginServerHandler.payload_type(): + # Process ANON_REQ login packet for all identities + if self.daemon.login_helper: + handled = await self.daemon.login_helper.process_login_packet(packet) + # Only skip forwarding if we actually handled it + if handled: + processed_by_injection = True + # Only pass to repeater engine if not already processed by injection if self.daemon.repeater_handler and not processed_by_injection: metadata = {