mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-08-07 09:23:06 +02:00
Implement access control and login management for identities
- Added security settings in config.yaml.example for managing authenticated clients. - Introduced ACL class for handling access control and client authentication in acl.py. - Created LoginHelper class for processing login requests and managing authentication in login.py. - Added IdentityManager class for managing multiple identities in identity_manager.py. - Updated main.py to initialize IdentityManager and LoginHelper, and register identities. - Added packet_router to process ANON_REQ login packets through the LoginHelper.
This commit is contained in:
@@ -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
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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 []
|
||||
@@ -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
|
||||
+96
-2
@@ -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.
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user