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:
Lloyd
2025-12-16 22:39:26 +00:00
parent f37f3e6caf
commit 5df266c83e
8 changed files with 430 additions and 4 deletions
+41
View File
@@ -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
View File
@@ -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",
+2 -1
View File
@@ -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"]
+127
View File
@@ -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
+90
View File
@@ -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 []
+64
View File
@@ -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
View File
@@ -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.
+9
View File
@@ -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 = {