From 7112da98c204adf5aa3f2951678bc6a23bb65e18 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Tue, 30 Dec 2025 00:10:48 +0000 Subject: [PATCH] feat: Add authentication endpoints and JWT support - Implemented JWT authentication with auto-generated secret if not provided. - Added API token management functionality. - Created authentication endpoints for login, token refresh, verification, and password change. - Introduced API documentation endpoints for Swagger UI and OpenAPI spec. - Enhanced CORS support for API and documentation endpoints. - Updated OpenAPI specification to include new authentication and system endpoints. --- config.yaml.example | 4 + pyproject.toml | 10 + repeater/data_acquisition/sqlite_handler.py | 117 +++ repeater/handler_helpers/trace.py | 56 ++ repeater/web/api_endpoints.py | 342 +++++-- repeater/web/auth/__init__.py | 9 + repeater/web/auth/api_tokens.py | 49 ++ repeater/web/auth/cherrypy_tool.py | 66 ++ repeater/web/auth/jwt_handler.py | 38 + repeater/web/auth_endpoints.py | 444 ++++++++++ repeater/web/http_server.py | 204 ++++- repeater/web/openapi.yaml | 930 +++++++++++++++++++- 12 files changed, 2201 insertions(+), 68 deletions(-) create mode 100644 repeater/web/auth/__init__.py create mode 100644 repeater/web/auth/api_tokens.py create mode 100644 repeater/web/auth/cherrypy_tool.py create mode 100644 repeater/web/auth/jwt_handler.py create mode 100644 repeater/web/auth_endpoints.py diff --git a/config.yaml.example b/config.yaml.example index eb23ab1..2d4e2c8 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -52,6 +52,10 @@ repeater: # Allow read-only access for clients without password/not in ACL allow_read_only: false + + # JWT secret key for signing tokens (auto-generated if not provided) + # Generate with: python -c "import secrets; print(secrets.token_hex(32))" + jwt_secret: "" # Mesh Network Configuration mesh: diff --git a/pyproject.toml b/pyproject.toml index af5b61b..cd08342 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ dependencies = [ "paho-mqtt>=1.6.0", "cherrypy-cors==1.7.0", "psutil>=5.9.0", + "pyjwt>=2.8.0", ] @@ -56,6 +57,15 @@ pymc-repeater = "repeater.main:main" [tool.setuptools] packages = ["repeater"] +[tool.setuptools.package-data] +repeater = [ + "web/html/*.html", + "web/html/*.ico", + "web/html/assets/**/*", + "web/*.yaml", + "web/*.html", +] + [tool.black] line-length = 100 target-version = ['py38', 'py39', 'py310', 'py311', 'py312'] diff --git a/repeater/data_acquisition/sqlite_handler.py b/repeater/data_acquisition/sqlite_handler.py index 746e0e1..104b864 100644 --- a/repeater/data_acquisition/sqlite_handler.py +++ b/repeater/data_acquisition/sqlite_handler.py @@ -91,6 +91,16 @@ class SQLiteHandler: ) """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS api_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + created_at REAL NOT NULL, + last_used REAL + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_packets_timestamp ON packets(timestamp)") conn.execute("CREATE INDEX IF NOT EXISTS idx_packets_type ON packets(type)") conn.execute("CREATE INDEX IF NOT EXISTS idx_packets_hash ON packets(packet_hash)") @@ -209,11 +219,118 @@ class SQLiteHandler: ) logger.info(f"Migration '{migration_name}' applied successfully") + # Migration 3: Add api_tokens table + migration_name = "add_api_tokens_table" + existing = conn.execute( + "SELECT migration_name FROM migrations WHERE migration_name = ?", + (migration_name,) + ).fetchone() + + if not existing: + # Check if api_tokens table already exists + cursor = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='api_tokens'" + ) + + if not cursor.fetchone(): + conn.execute(""" + CREATE TABLE api_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + created_at REAL NOT NULL, + last_used REAL + ) + """) + logger.info("Created api_tokens table") + + # Mark migration as applied + conn.execute( + "INSERT INTO migrations (migration_name, applied_at) VALUES (?, ?)", + (migration_name, time.time()) + ) + logger.info(f"Migration '{migration_name}' applied successfully") + conn.commit() except Exception as e: logger.error(f"Failed to run migrations: {e}") + # API Token methods + def create_api_token(self, name: str, token_hash: str) -> int: + """Create a new API token entry""" + try: + with sqlite3.connect(self.sqlite_path) as conn: + cursor = conn.execute( + "INSERT INTO api_tokens (name, token_hash, created_at) VALUES (?, ?, ?)", + (name, token_hash, time.time()) + ) + return cursor.lastrowid + except Exception as e: + logger.error(f"Failed to create API token: {e}") + raise + + def verify_api_token(self, token_hash: str) -> Optional[Dict[str, Any]]: + """Verify API token and update last_used timestamp""" + try: + with sqlite3.connect(self.sqlite_path) as conn: + cursor = conn.execute( + "SELECT id, name, created_at FROM api_tokens WHERE token_hash = ?", + (token_hash,) + ) + row = cursor.fetchone() + + if row: + token_id, name, created_at = row + + # Update last_used timestamp + conn.execute( + "UPDATE api_tokens SET last_used = ? WHERE id = ?", + (time.time(), token_id) + ) + conn.commit() + + return { + 'id': token_id, + 'name': name, + 'created_at': created_at + } + return None + except Exception as e: + logger.error(f"Failed to verify API token: {e}") + return None + + def revoke_api_token(self, token_id: int) -> bool: + """Revoke (delete) an API token""" + try: + with sqlite3.connect(self.sqlite_path) as conn: + cursor = conn.execute("DELETE FROM api_tokens WHERE id = ?", (token_id,)) + return cursor.rowcount > 0 + except Exception as e: + logger.error(f"Failed to revoke API token: {e}") + return False + + def list_api_tokens(self) -> List[Dict[str, Any]]: + """List all API tokens (without sensitive data)""" + try: + with sqlite3.connect(self.sqlite_path) as conn: + cursor = conn.execute( + "SELECT id, name, created_at, last_used FROM api_tokens ORDER BY created_at DESC" + ) + + tokens = [] + for row in cursor.fetchall(): + tokens.append({ + 'id': row[0], + 'name': row[1], + 'created_at': row[2], + 'last_used': row[3] + }) + return tokens + except Exception as e: + logger.error(f"Failed to list API tokens: {e}") + return [] + def store_packet(self, record: dict): try: with sqlite3.connect(self.sqlite_path) as conn: diff --git a/repeater/handler_helpers/trace.py b/repeater/handler_helpers/trace.py index 0a84dbf..d9da49d 100644 --- a/repeater/handler_helpers/trace.py +++ b/repeater/handler_helpers/trace.py @@ -6,6 +6,7 @@ which are used for network diagnostics to track the path and SNR of packets through the mesh network. """ +import asyncio import logging import time from typing import Dict, Any @@ -34,6 +35,9 @@ class TraceHelper: self.repeater_handler = repeater_handler self.packet_injector = packet_injector # Function to inject packets into router + # Ping callback system - track pending ping requests by tag + self.pending_pings = {} # {tag: {'event': asyncio.Event(), 'result': dict, 'target': int, 'sent_at': float}} + # Create TraceHandler internally as a parsing utility self.trace_handler = TraceHandler(log_fn=log_fn or logger.info) @@ -64,6 +68,21 @@ class TraceHelper: trace_path = parsed_data["trace_path"] trace_path_len = len(trace_path) + # Check if this is a response to one of our pings + trace_tag = parsed_data.get("tag") + if trace_tag in self.pending_pings: + ping_info = self.pending_pings[trace_tag] + # Store response data + ping_info['result'] = { + 'path': trace_path, + 'snr': packet.get_snr(), + 'rssi': getattr(packet, "rssi", 0), + 'received_at': time.time() + } + # Signal the waiting coroutine + ping_info['event'].set() + logger.info(f"Ping response received for tag {trace_tag}") + # Record the trace packet for dashboard/statistics if self.repeater_handler: packet_record = self._create_trace_record(packet, trace_path, parsed_data) @@ -269,3 +288,40 @@ class TraceHelper: logger.info(f"Not our turn (next hop: 0x{expected_hash:02x})") elif self.repeater_handler and self.repeater_handler.is_duplicate(packet): logger.info("Duplicate packet, ignoring") + + def register_ping(self, tag: int, target_hash: int) -> asyncio.Event: + """Register a ping request and return an event to wait on. + + Args: + tag: The unique trace tag for this ping + target_hash: The hash of the target node + + Returns: + asyncio.Event that will be set when response is received + """ + event = asyncio.Event() + self.pending_pings[tag] = { + 'event': event, + 'result': None, + 'target': target_hash, + 'sent_at': time.time() + } + logger.debug(f"Registered ping with tag {tag} for target 0x{target_hash:02x}") + return event + + def cleanup_stale_pings(self, max_age_seconds: int = 30): + """Remove pending pings older than max_age_seconds. + + Args: + max_age_seconds: Maximum age in seconds before a ping is considered stale + """ + current_time = time.time() + stale_tags = [ + tag for tag, info in self.pending_pings.items() + if current_time - info['sent_at'] > max_age_seconds + ] + for tag in stale_tags: + self.pending_pings.pop(tag) + logger.debug(f"Cleaned up stale ping with tag {tag}") + if stale_tags: + logger.info(f"Cleaned up {len(stale_tags)} stale ping(s)") diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index efb93a4..1db36aa 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -8,74 +8,112 @@ import cherrypy from repeater import __version__ from repeater.config import update_global_flood_policy from .cad_calibration_engine import CADCalibrationEngine +from .auth.middleware import require_auth +from .auth_endpoints import AuthAPIEndpoints from pymc_core.protocol import CryptoUtils logger = logging.getLogger("HTTPServer") +# ============================================================================ +# API ENDPOINT DOCUMENTATION +# ============================================================================ + +# Authentication (see auth_endpoints.py for implementation) +# POST /auth/login - Authenticate and get JWT token +# POST /auth/refresh - Refresh JWT token +# GET /auth/verify - Verify current authentication +# POST /auth/change_password - Change admin password +# GET /api/auth/tokens - List all API tokens (RESTful) +# POST /api/auth/tokens - Create new API token (RESTful) +# DELETE /api/auth/tokens/{token_id} - Revoke API token (RESTful) + # System -# GET /api/stats -# GET /api/logs - -# Packets -# GET /api/packet_stats?hours=24 -# GET /api/recent_packets?limit=100 -# GET /api/filtered_packets?type=4&route=1&start_timestamp=X&end_timestamp=Y&limit=1000 -# GET /api/packet_by_hash?packet_hash=abc123 -# GET /api/packet_type_stats?hours=24 - -# Charts & RRD -# GET /api/rrd_data?start_time=X&end_time=Y&resolution=average -# GET /api/packet_type_graph_data?hours=24&resolution=average&types=all -# GET /api/metrics_graph_data?hours=24&resolution=average&metrics=all - -# Noise Floor -# GET /api/noise_floor_history?hours=24 -# GET /api/noise_floor_stats?hours=24 -# GET /api/noise_floor_chart_data?hours=24 +# GET /api/stats - Get system statistics +# GET /api/logs - Get system logs +# GET /api/hardware_stats - Get hardware statistics +# GET /api/hardware_processes - Get process information +# POST /api/restart_service - Restart the repeater service +# GET /api/openapi - Get OpenAPI specification # Repeater Control -# POST /api/send_advert -# POST /api/set_mode {"mode": "forward|monitor"} -# POST /api/set_duty_cycle {"enabled": true|false} -# POST /api/restart_service +# POST /api/send_advert - Send repeater advertisement +# POST /api/set_mode {"mode": "forward|monitor"} - Set repeater mode +# POST /api/set_duty_cycle {"enabled": true|false} - Enable/disable duty cycle +# POST /api/update_duty_cycle_config {"enabled": true, "on_time": 300, "off_time": 60} - Update duty cycle config +# POST /api/update_radio_config - Update radio configuration + +# Packets +# GET /api/packet_stats?hours=24 - Get packet statistics +# GET /api/packet_type_stats?hours=24 - Get packet type statistics +# GET /api/route_stats?hours=24 - Get route statistics +# GET /api/recent_packets?limit=100 - Get recent packets +# GET /api/filtered_packets?type=4&route=1&start_timestamp=X&end_timestamp=Y&limit=1000 - Get filtered packets +# GET /api/packet_by_hash?packet_hash=abc123 - Get specific packet by hash + +# Charts & RRD +# GET /api/rrd_data?start_time=X&end_time=Y&resolution=average - Get RRD data +# GET /api/packet_type_graph_data?hours=24&resolution=average&types=all - Get packet type graph data +# GET /api/metrics_graph_data?hours=24&resolution=average&metrics=all - Get metrics graph data + +# Noise Floor +# GET /api/noise_floor_history?hours=24 - Get noise floor history +# GET /api/noise_floor_stats?hours=24 - Get noise floor statistics +# GET /api/noise_floor_chart_data?hours=24 - Get noise floor chart data # CAD Calibration -# POST /api/cad_calibration_start {"samples": 8, "delay": 100} -# POST /api/cad_calibration_stop -# POST /api/save_cad_settings {"peak": 127, "min_val": 64} -# GET /api/cad_calibration_stream (SSE) +# POST /api/cad_calibration_start {"samples": 8, "delay": 100} - Start CAD calibration +# POST /api/cad_calibration_stop - Stop CAD calibration +# POST /api/save_cad_settings {"peak": 127, "min_val": 64} - Save CAD settings +# GET /api/cad_calibration_stream - CAD calibration SSE stream + +# Adverts & Contacts +# GET /api/adverts_by_contact_type?contact_type=X&limit=100&hours=24 - Get adverts by contact type +# GET /api/advert?advert_id=123 - Get specific advert + +# Transport Keys +# GET /api/transport_keys - List all transport keys +# POST /api/transport_keys - Create new transport key +# GET /api/transport_key?key_id=X - Get specific transport key +# 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 +# POST /api/ping_neighbor - Ping a neighbor node # Identity Management # GET /api/identities - List all identities # GET /api/identity?name= - Get specific identity -# POST /api/create_identity {"name": "...", "identity_key": "...", "type": "room_server", "settings": {...}} -# PUT /api/update_identity {"name": "...", "new_name": "...", "identity_key": "...", "settings": {...}} -# DELETE /api/delete_identity?name= -# POST /api/send_room_server_advert {"name": "..."} - Send advert for room server +# POST /api/create_identity {"name": "...", "identity_key": "...", "type": "room_server", "settings": {...}} - Create identity +# PUT /api/update_identity {"name": "...", "new_name": "...", "identity_key": "...", "settings": {...}} - Update identity +# DELETE /api/delete_identity?name= - Delete identity +# POST /api/send_room_server_advert {"name": "...", "node_name": "...", "latitude": 0.0, "longitude": 0.0} - Send room server advert # ACL (Access Control List) -# GET /api/acl_info - Get ACL configuration and stats for all identities -# GET /api/acl_clients?identity_hash=0x42&identity_name=repeater - List authenticated clients -# POST /api/acl_remove_client {"public_key": "...", "identity_hash": "0x42"} - Remove client from ACL -# GET /api/acl_stats - Overall ACL statistics +# GET /api/acl_info - Get ACL configuration and stats for all identities +# GET /api/acl_clients?identity_hash=0x42&identity_name=repeater - List authenticated clients +# POST /api/acl_remove_client {"public_key": "...", "identity_hash": "0x42"} - Remove client from ACL +# GET /api/acl_stats - Overall ACL statistics # Room Server -# GET /api/room_messages?room_name=General&limit=50&offset=0 - Get messages from room -# GET /api/room_messages?room_hash=0x42&limit=50 - Get messages by hash -# POST /api/room_post_message {"room_name": "General", "message": "Hello", "author_pubkey": "abc123"} - Post message -# GET /api/room_stats?room_name=General - Get room statistics -# GET /api/room_stats - Get all rooms statistics -# GET /api/room_clients?room_name=General - Get clients synced to room +# GET /api/room_messages?room_name=General&limit=50&offset=0&since_timestamp=X - Get messages from room +# GET /api/room_messages?room_hash=0x42&limit=50 - Get messages by room hash +# POST /api/room_post_message {"room_name": "General", "message": "Hello", "author_pubkey": "abc123"} - Post message +# GET /api/room_stats?room_name=General - Get room statistics +# GET /api/room_stats - Get all rooms statistics +# GET /api/room_clients?room_name=General - Get clients synced to room # DELETE /api/room_message?room_name=General&message_id=123 - Delete specific message -# DELETE /api/room_messages?room_name=General - Clear all messages in room +# DELETE /api/room_messages_clear?room_name=General - Clear all messages in room # Common Parameters -# hours - Time range (default: 24) -# resolution - 'average', 'max', 'min' (default: 'average') -# limit - Max results (default varies) +# hours - Time range in hours (default: 24) +# resolution - Data resolution: 'average', 'max', 'min' (default: 'average') +# limit - Maximum results (default varies by endpoint) +# offset - Result offset for pagination (default: 0) # type - Packet type 0-15 # route - Route type 1-3 +# ============================================================================ @@ -97,6 +135,9 @@ class APIEndpoints: config=self.config, daemon_instance=daemon_instance ) + + # Create nested auth object for /api/auth/* routes + self.auth = AuthAPIEndpoints() def _is_cors_enabled(self): return self.config.get("web", {}).get("cors_enabled", False) @@ -271,6 +312,67 @@ class APIEndpoints: logger.error(f"Error setting duty cycle: {e}", exc_info=True) return self._error(e) + @cherrypy.expose + @cherrypy.tools.json_out() + @cherrypy.tools.json_in() + def update_duty_cycle_config(self): + self._set_cors_headers() + + if cherrypy.request.method == "OPTIONS": + return "" + + try: + self._require_post() + data = cherrypy.request.json or {} + + applied = [] + + # Ensure config section exists + if "duty_cycle" not in self.config: + self.config["duty_cycle"] = {} + + # Update max airtime percentage + if "max_airtime_percent" in data: + percent = float(data["max_airtime_percent"]) + if percent < 0.1 or percent > 100.0: + return self._error("Max airtime percent must be 0.1-100.0") + # Convert percent to milliseconds per minute + max_airtime_ms = int((percent / 100) * 60000) + self.config["duty_cycle"]["max_airtime_per_minute"] = max_airtime_ms + applied.append(f"max_airtime={percent}%") + + # Update enforcement enabled/disabled + if "enforcement_enabled" in data: + enabled = bool(data["enforcement_enabled"]) + self.config["duty_cycle"]["enforcement_enabled"] = enabled + applied.append(f"enforcement={'enabled' if enabled else 'disabled'}") + + if not applied: + return self._error("No valid settings provided") + + # Save to config file and live update daemon + result = self.config_manager.update_and_save( + updates={}, + live_update=True, + live_update_sections=['duty_cycle'] + ) + + logger.info(f"Duty cycle config updated: {', '.join(applied)}") + + return self._success({ + "applied": applied, + "persisted": result.get("saved", False), + "live_update": result.get("live_updated", False), + "restart_required": False, + "message": "Duty cycle settings applied immediately." + }) + + except cherrypy.HTTPError: + raise + except Exception as e: + logger.error(f"Error updating duty cycle config: {e}") + return self._error(str(e)) + @cherrypy.expose @cherrypy.tools.json_out() @cherrypy.tools.json_in() @@ -1184,31 +1286,120 @@ class APIEndpoints: else: return self._error("Method not supported") + @cherrypy.expose @cherrypy.expose @cherrypy.tools.json_out() @cherrypy.tools.json_in() def ping_neighbor(self): + # Enable CORS for this endpoint only if configured self._set_cors_headers() + # Handle OPTIONS request for CORS preflight + if cherrypy.request.method == "OPTIONS": + return "" + try: self._require_post() data = cherrypy.request.json or {} target_id = data.get("target_id") + timeout = int(data.get("timeout", 10)) if not target_id: return self._error("Missing target_id parameter") - # TODO: Implement actual ping functionality when available - # For now, return success to indicate the endpoint works - logger.info(f"Ping request for neighbor: {target_id}") - return self._success({"target_id": target_id}, message="Ping sent successfully") + # Parse target hash (accepts hex string like "0xA5" or "a5") + try: + target_hash = int(target_id, 16) if isinstance(target_id, str) else int(target_id) + if target_hash < 0 or target_hash > 255: + return self._error("target_id must be a valid byte (0x00-0xFF)") + except ValueError: + return self._error(f"Invalid target_id format: {target_id}") + + # Check if router and trace_helper are available + if not hasattr(self.daemon_instance, 'router'): + return self._error("Packet router not available") + + router = self.daemon_instance.router + if not hasattr(self.daemon_instance, 'trace_helper'): + return self._error("Trace helper not available") + + trace_helper = self.daemon_instance.trace_helper + + # Generate unique tag for this ping + import random + trace_tag = random.randint(0, 0xFFFFFFFF) + + # Create trace packet + from pymc_core.protocol import PacketBuilder + packet = PacketBuilder.create_trace( + tag=trace_tag, + auth_code=0x12345678, + flags=0x00, + path=[target_hash] + ) + + # Wait for response with timeout + import asyncio + + async def send_and_wait(): + """Async helper to send ping and wait for response""" + # Register ping with TraceHelper (must be done in async context) + event = trace_helper.register_ping(trace_tag, target_hash) + + # Send packet via router + await router.inject_packet(packet) + logger.info(f"Ping sent to 0x{target_hash:02x} with tag {trace_tag}") + + try: + await asyncio.wait_for(event.wait(), timeout=timeout) + return True + except asyncio.TimeoutError: + return False + + # Run the async send and wait in the daemon's event loop + try: + if self.event_loop is None: + return self._error("Event loop not available") + + future = asyncio.run_coroutine_threadsafe(send_and_wait(), self.event_loop) + response_received = future.result(timeout=timeout + 1) + except Exception as e: + logger.error(f"Error waiting for ping response: {e}") + trace_helper.pending_pings.pop(trace_tag, None) + return self._error(f"Error waiting for response: {str(e)}") + + if response_received: + # Get result + ping_info = trace_helper.pending_pings.pop(trace_tag, None) + if not ping_info: + return self._error("Ping info not found after response") + + result = ping_info.get('result') + if result: + # Calculate round-trip time + rtt_ms = (result['received_at'] - ping_info['sent_at']) * 1000 + + return self._success({ + "target_id": f"0x{target_hash:02x}", + "rtt_ms": round(rtt_ms, 2), + "snr_db": result['snr'], + "rssi": result['rssi'], + "path": [f"0x{h:02x}" for h in result['path']], + "tag": trace_tag + }, message="Ping successful") + else: + return self._error("Received response but no data") + else: + # Timeout + trace_helper.pending_pings.pop(trace_tag, None) + return self._error(f"Ping timeout after {timeout}s") except cherrypy.HTTPError: raise except Exception as e: - logger.error(f"Error pinging neighbor: {e}") - return self._error(e) + logger.error(f"Error pinging neighbor: {e}", exc_info=True) + return self._error(str(e)) # ========== Identity Management Endpoints ========== @@ -2776,15 +2967,42 @@ class APIEndpoints: @cherrypy.expose def docs(self): """Serve Swagger UI for interactive API documentation.""" - # Load HTML template from file - template_path = os.path.join(os.path.dirname(__file__), 'html', 'swagger-ui.html') - try: - with open(template_path, 'r', encoding='utf-8') as f: - swagger_html = f.read() - except FileNotFoundError: - logger.error(f"Swagger UI template not found at {template_path}") - cherrypy.response.status = 500 - return b"Swagger UI template not found" - + html = """ + + + + + pyMC Repeater API Documentation + + + + +
+ + + + +""" cherrypy.response.headers['Content-Type'] = 'text/html' - return swagger_html.encode('utf-8') \ No newline at end of file + return html.encode('utf-8') \ No newline at end of file diff --git a/repeater/web/auth/__init__.py b/repeater/web/auth/__init__.py new file mode 100644 index 0000000..a38c19f --- /dev/null +++ b/repeater/web/auth/__init__.py @@ -0,0 +1,9 @@ +from .jwt_handler import JWTHandler +from .api_tokens import APITokenManager +from .middleware import require_auth + +__all__ = [ + 'JWTHandler', + 'APITokenManager', + 'require_auth' +] diff --git a/repeater/web/auth/api_tokens.py b/repeater/web/auth/api_tokens.py new file mode 100644 index 0000000..55dcff7 --- /dev/null +++ b/repeater/web/auth/api_tokens.py @@ -0,0 +1,49 @@ +import secrets +import hmac +import hashlib +from typing import Optional, List, Dict +import logging + +logger = logging.getLogger(__name__) + + +class APITokenManager: + def __init__(self, sqlite_handler, secret_key: str): + + self.db = sqlite_handler + self.secret_key = secret_key.encode('utf-8') + + def generate_api_token(self) -> str: + return secrets.token_hex(32) + + def hash_token(self, token: str) -> str: + return hmac.new( + self.secret_key, + token.encode('utf-8'), + hashlib.sha256 + ).hexdigest() + + def create_token(self, name: str) -> tuple[int, str]: + plaintext_token = self.generate_api_token() + token_hash = self.hash_token(plaintext_token) + + token_id = self.db.create_api_token(name, token_hash) + + logger.info(f"Created API token '{name}' with ID {token_id}") + return token_id, plaintext_token + + def verify_token(self, token: str) -> Optional[Dict]: + token_hash = self.hash_token(token) + return self.db.verify_api_token(token_hash) + + def revoke_token(self, token_id: int) -> bool: + deleted = self.db.revoke_api_token(token_id) + + if deleted: + logger.info(f"Revoked API token ID {token_id}") + + return deleted + + def list_tokens(self) -> List[Dict]: + return self.db.list_api_tokens() + diff --git a/repeater/web/auth/cherrypy_tool.py b/repeater/web/auth/cherrypy_tool.py new file mode 100644 index 0000000..bbe646f --- /dev/null +++ b/repeater/web/auth/cherrypy_tool.py @@ -0,0 +1,66 @@ +import logging +import cherrypy + +logger = logging.getLogger("HTTPServer") + + +def check_auth(): + """ + CherryPy tool to check authentication before processing request. + + Checks for either JWT in Authorization header or API token in X-API-Key header. + Sets cherrypy.request.user on success. + Returns 401 JSON response on failure. + """ + # Skip auth check for OPTIONS requests (CORS preflight) + if cherrypy.request.method == "OPTIONS": + return + + # Skip auth check for /auth/login endpoint + if cherrypy.request.path_info == "/auth/login": + return + + # Get auth handlers from config + jwt_handler = cherrypy.config.get("jwt_handler") + token_manager = cherrypy.config.get("token_manager") + + if not jwt_handler or not token_manager: + logger.error("Auth handlers not initialized in cherrypy.config") + cherrypy.response.status = 500 + return {"success": False, "error": "Authentication system not configured"} + + # Check for JWT token first + auth_header = cherrypy.request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + token = auth_header[7:] # Remove "Bearer " prefix + payload = jwt_handler.verify_jwt(token) + + if payload: + cherrypy.request.user = { + "username": payload.get("sub"), + "client_id": payload.get("client_id"), + "auth_type": "jwt" + } + return + + # Check for API token + api_key = cherrypy.request.headers.get("X-API-Key", "") + if api_key: + token_info = token_manager.verify_token(api_key) + + if token_info: + cherrypy.request.user = { + "token_id": token_info["id"], + "token_name": token_info["name"], + "auth_type": "api_token" + } + return + + # No valid authentication found + logger.warning(f"Unauthorized access attempt to {cherrypy.request.path_info}") + raise cherrypy.HTTPError(401, "Unauthorized - Valid JWT or API token required") + + +# Register the tool +cherrypy.tools.require_auth = cherrypy.Tool('before_handler', check_auth) +logger.info("CherryPy require_auth tool registered") diff --git a/repeater/web/auth/jwt_handler.py b/repeater/web/auth/jwt_handler.py new file mode 100644 index 0000000..a1dd22c --- /dev/null +++ b/repeater/web/auth/jwt_handler.py @@ -0,0 +1,38 @@ +import jwt +import time +from typing import Dict, Optional +import logging + +logger = logging.getLogger(__name__) + +class JWTHandler: + def __init__(self, secret: str, expiry_minutes: int = 15): + self.secret = secret + self.expiry_minutes = expiry_minutes + + def create_jwt(self, username: str, client_id: str) -> str: + + now = int(time.time()) + expiry = now + (self.expiry_minutes * 60) + + payload = { + 'sub': username, + 'exp': expiry, + 'iat': now, + 'client_id': client_id + } + + token = jwt.encode(payload, self.secret, algorithm='HS256') + logger.info(f"Created JWT for user '{username}' with client_id '{client_id[:8]}...'") + return token + + def verify_jwt(self, token: str) -> Optional[Dict]: + try: + payload = jwt.decode(token, self.secret, algorithms=['HS256']) + return payload + except jwt.ExpiredSignatureError: + logger.warning("JWT token expired") + return None + except jwt.InvalidTokenError as e: + logger.warning(f"Invalid JWT token: {e}") + return None diff --git a/repeater/web/auth_endpoints.py b/repeater/web/auth_endpoints.py new file mode 100644 index 0000000..b6fa134 --- /dev/null +++ b/repeater/web/auth_endpoints.py @@ -0,0 +1,444 @@ +""" +Authentication endpoints for login and token management +""" +import cherrypy +import logging +from .auth.middleware import require_auth + +logger = logging.getLogger(__name__) + + +class AuthAPIEndpoints: + """Nested endpoint for /api/auth/* RESTful routes""" + + def __init__(self): + # Create tokens nested endpoint for /api/auth/tokens + self.tokens = TokensAPIEndpoint() + + +class TokensAPIEndpoint: + """RESTful token management endpoints for /api/auth/tokens""" + + @cherrypy.expose + @cherrypy.tools.json_out() + @require_auth + def index(self): + # Handle CORS preflight + if cherrypy.request.method == 'OPTIONS': + return {} + + # Get token manager from cherrypy config + token_manager = cherrypy.config.get('token_manager') + if not token_manager: + cherrypy.response.status = 500 + return {'success': False, 'error': 'Token manager not available'} + + if cherrypy.request.method == 'GET': + try: + tokens = token_manager.list_tokens() + return { + 'success': True, + 'tokens': tokens + } + except Exception as e: + logger.error(f"Token list error: {e}") + cherrypy.response.status = 500 + return { + 'success': False, + 'error': 'Failed to list tokens' + } + + elif cherrypy.request.method == 'POST': + try: + import json + body = cherrypy.request.body.read().decode('utf-8') + data = json.loads(body) if body else {} + name = data.get('name', '').strip() + + if not name: + cherrypy.response.status = 400 + return { + 'success': False, + 'error': 'Token name is required' + } + + # Create the token + token_id, plaintext_token = token_manager.create_token(name) + + logger.info(f"Generated API token '{name}' (ID: {token_id}) by user {cherrypy.request.user['username']}") + + return { + 'success': True, + 'token': plaintext_token, + 'token_id': token_id, + 'name': name, + 'warning': 'Save this token securely - it will not be shown again' + } + + except Exception as e: + logger.error(f"Token generation error: {e}") + cherrypy.response.status = 500 + return { + 'success': False, + 'error': 'Failed to generate token' + } + else: + raise cherrypy.HTTPError(405, "Method not allowed") + + @cherrypy.expose + @cherrypy.tools.json_out() + @require_auth + def default(self, token_id=None): + # Handle CORS preflight + if cherrypy.request.method == 'OPTIONS': + return {} + + # Get token manager from cherrypy config + token_manager = cherrypy.config.get('token_manager') + if not token_manager: + cherrypy.response.status = 500 + return {'success': False, 'error': 'Token manager not available'} + + if cherrypy.request.method == 'DELETE': + try: + if not token_id: + cherrypy.response.status = 400 + return { + 'success': False, + 'error': 'Token ID is required' + } + + # Convert to int + try: + token_id_int = int(token_id) + except ValueError: + cherrypy.response.status = 400 + return { + 'success': False, + 'error': 'Invalid token ID' + } + + # Revoke the token + success = token_manager.revoke_token(token_id_int) + + if success: + logger.info(f"Revoked API token ID {token_id_int} by user {cherrypy.request.user['username']}") + return { + 'success': True, + 'message': 'Token revoked successfully' + } + else: + cherrypy.response.status = 404 + return { + 'success': False, + 'error': 'Token not found' + } + + except Exception as e: + logger.error(f"Token revocation error: {e}") + cherrypy.response.status = 500 + return { + 'success': False, + 'error': 'Failed to revoke token' + } + else: + raise cherrypy.HTTPError(405, "Method not allowed") + + +class AuthEndpoints: + + def __init__(self, config, jwt_handler, token_manager, config_manager=None): + self.config = config + self.jwt_handler = jwt_handler + self.token_manager = token_manager + self.config_manager = config_manager + + @cherrypy.expose + def login(self, **kwargs): + + cherrypy.response.headers['Content-Type'] = 'application/json' + + # Handle CORS preflight + if cherrypy.request.method == 'OPTIONS': + cherrypy.response.headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS' + cherrypy.response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-API-Key' + return b'' + + if cherrypy.request.method != 'POST': + raise cherrypy.HTTPError(405, "Method not allowed") + + try: + # Parse JSON body manually since we can't use json_in decorator with OPTIONS + import json + body = cherrypy.request.body.read().decode('utf-8') + data = json.loads(body) if body else {} + + username = data.get('username', '').strip() + password = data.get('password', '') + client_id = data.get('client_id', '').strip() + + if not username or not password or not client_id: + return json.dumps({ + 'success': False, + 'error': 'Missing required fields: username, password, client_id' + }).encode('utf-8') + + # Validate credentials against config + # Check if username is 'admin' and password matches config + security_config = self.config.get('security', {}) + config_password = security_config.get('admin_password', 'admin123') + + if username == 'admin' and password == config_password: + # Create JWT token + token = self.jwt_handler.create_jwt(username, client_id) + + logger.info(f"Successful login for user '{username}' from client '{client_id[:8]}...'") + + return json.dumps({ + 'success': True, + 'token': token, + 'expires_in': self.jwt_handler.expiry_minutes * 60, + 'username': username + }).encode('utf-8') + else: + logger.warning(f"Failed login attempt for user '{username}'") + + # Don't reveal which part was wrong + return json.dumps({ + 'success': False, + 'error': 'Invalid username or password' + }).encode('utf-8') + + except Exception as e: + logger.error(f"Login error: {e}") + return json.dumps({ + 'success': False, + 'error': 'Internal server error' + }).encode('utf-8') + + @cherrypy.expose + @cherrypy.tools.json_out() + @require_auth + def verify(self): + if cherrypy.request.method != 'GET': + raise cherrypy.HTTPError(405, "Method not allowed") + + return { + 'success': True, + 'authenticated': True, + 'user': cherrypy.request.user + } + + @cherrypy.expose + def refresh(self, **kwargs): + + cherrypy.response.headers['Content-Type'] = 'application/json' + + # Handle CORS preflight + if cherrypy.request.method == 'OPTIONS': + cherrypy.response.headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS' + cherrypy.response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-API-Key' + return b'' + + if cherrypy.request.method != 'POST': + raise cherrypy.HTTPError(405, "Method not allowed") + + try: + import json + + # Manual authentication check (can't use @require_auth since we need to handle OPTIONS) + auth_header = cherrypy.request.headers.get('Authorization', '') + api_key = cherrypy.request.headers.get('X-API-Key', '') + + jwt_handler = cherrypy.config.get('jwt_handler') + token_manager = cherrypy.config.get('token_manager') + + user_info = None + + # Check JWT first + if auth_header.startswith('Bearer '): + token = auth_header[7:] + payload = jwt_handler.verify_jwt(token) + if payload: + user_info = { + 'username': payload['sub'], + 'client_id': payload.get('client_id'), + 'auth_method': 'jwt' + } + + # Check API token + if not user_info and api_key: + token_data = token_manager.verify_token(api_key) + if token_data: + user_info = { + 'username': 'admin', + 'token_id': token_data['id'], + 'auth_method': 'api_token' + } + + if not user_info: + return json.dumps({ + 'success': False, + 'error': 'Unauthorized - Valid JWT or API token required' + }).encode('utf-8') + + # Parse request body + body = cherrypy.request.body.read().decode('utf-8') + data = json.loads(body) if body else {} + + client_id = data.get('client_id', user_info.get('client_id', '')).strip() + + if not client_id: + return json.dumps({ + 'success': False, + 'error': 'Client ID is required' + }).encode('utf-8') + + # Create new JWT token (refreshes expiry time) + new_token = self.jwt_handler.create_jwt(user_info['username'], client_id) + + logger.info(f"Token refreshed for user '{user_info['username']}' from client '{client_id[:8]}...'") + + return json.dumps({ + 'success': True, + 'token': new_token, + 'expires_in': self.jwt_handler.expiry_minutes * 60, + 'username': user_info['username'] + }).encode('utf-8') + + except Exception as e: + logger.error(f"Token refresh error: {e}") + return json.dumps({ + 'success': False, + 'error': 'Failed to refresh token' + }).encode('utf-8') + + @cherrypy.expose + def change_password(self): + + import json + + cherrypy.response.headers['Content-Type'] = 'application/json' + + # Handle CORS preflight + if cherrypy.request.method == 'OPTIONS': + cherrypy.response.headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS' + cherrypy.response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-API-Key' + return b'' + + if cherrypy.request.method != 'POST': + raise cherrypy.HTTPError(405, "Method not allowed") + + # Require authentication for POST + # Get auth handlers from global cherrypy config + jwt_handler = cherrypy.config.get('jwt_handler') + token_manager = cherrypy.config.get('token_manager') + + if not jwt_handler or not token_manager: + logger.error("Auth handlers not configured") + raise cherrypy.HTTPError(500, "Authentication not configured") + + # Try JWT authentication first + auth_header = cherrypy.request.headers.get('Authorization', '') + user = None + + if auth_header.startswith('Bearer '): + token = auth_header[7:] # Remove 'Bearer ' prefix + payload = jwt_handler.verify_jwt(token) + + if payload: + user = { + 'username': payload['sub'], + 'client_id': payload['client_id'], + 'auth_type': 'jwt' + } + + # Try API token authentication if JWT failed + if not user: + api_key = cherrypy.request.headers.get('X-API-Key', '') + if api_key: + token_info = token_manager.verify_token(api_key) + + if token_info: + user = { + 'username': 'api_token', + 'token_name': token_info['name'], + 'token_id': token_info['id'], + 'auth_type': 'api_token' + } + + if not user: + cherrypy.response.status = 401 + return json.dumps({ + 'success': False, + 'error': 'Unauthorized - Valid JWT or API token required' + }).encode('utf-8') + + try: + # Parse JSON body manually + body = cherrypy.request.body.read().decode('utf-8') + data = json.loads(body) if body else {} + + current_password = data.get('current_password', '') + new_password = data.get('new_password', '') + + if not current_password or not new_password: + cherrypy.response.status = 400 + return json.dumps({ + 'success': False, + 'error': 'Both current_password and new_password are required' + }).encode('utf-8') + + # Validate new password strength + if len(new_password) < 8: + cherrypy.response.status = 400 + return json.dumps({ + 'success': False, + 'error': 'New password must be at least 8 characters long' + }).encode('utf-8') + + # Verify current password + security_config = self.config.get('security', {}) + config_password = security_config.get('admin_password', 'admin123') + + if current_password != config_password: + cherrypy.response.status = 401 + return json.dumps({ + 'success': False, + 'error': 'Current password is incorrect' + }).encode('utf-8') + + # Update password in config + if 'security' not in self.config: + self.config['security'] = {} + + self.config['security']['admin_password'] = new_password + + # Save to config file using ConfigManager + if self.config_manager: + if self.config_manager.save_to_file(): + logger.info(f"Admin password changed successfully by user {user['username']}") + return json.dumps({ + 'success': True, + 'message': 'Password changed successfully. Please log in again with your new password.' + }).encode('utf-8') + else: + cherrypy.response.status = 500 + return json.dumps({ + 'success': False, + 'error': 'Failed to save password to config file' + }).encode('utf-8') + else: + cherrypy.response.status = 500 + return json.dumps({ + 'success': False, + 'error': 'Config manager not available' + }).encode('utf-8') + + except Exception as e: + logger.error(f"Password change error: {e}") + cherrypy.response.status = 500 + return json.dumps({ + 'success': False, + 'error': 'Failed to change password' + }).encode('utf-8') \ No newline at end of file diff --git a/repeater/web/http_server.py b/repeater/web/http_server.py index ed47db1..6e0a5c7 100644 --- a/repeater/web/http_server.py +++ b/repeater/web/http_server.py @@ -2,6 +2,7 @@ import json import logging import os import re +import secrets from collections import deque from datetime import datetime from typing import Callable, Optional @@ -12,6 +13,10 @@ from pymc_core.protocol.utils import PAYLOAD_TYPES, ROUTE_TYPES from repeater import __version__ from .api_endpoints import APIEndpoints +from .auth_endpoints import AuthEndpoints +from .auth.jwt_handler import JWTHandler +from .auth.api_tokens import APITokenManager +from .auth import cherrypy_tool # Import to register the tool logger = logging.getLogger("HTTPServer") @@ -42,6 +47,45 @@ class LogBuffer(logging.Handler): # Global log buffer instance _log_buffer = LogBuffer(max_lines=100) + +class DocEndpoint: + """Simple wrapper to serve API docs at /doc""" + + def __init__(self, api_endpoints): + self.api_endpoints = api_endpoints + + @cherrypy.expose + def index(self): + """Serve Swagger UI at /doc""" + return self.api_endpoints.docs() + + @cherrypy.expose + def docs(self): + """Serve Swagger UI at /doc/docs""" + return self.api_endpoints.docs() + + @cherrypy.expose + def openapi_json(self): + """Serve OpenAPI spec in JSON format at /doc/openapi.json""" + import os + import yaml + import json + + spec_path = os.path.join(os.path.dirname(__file__), 'openapi.yaml') + try: + with open(spec_path, 'r') as f: + spec_content = yaml.safe_load(f) + + cherrypy.response.headers['Content-Type'] = 'application/json' + return json.dumps(spec_content).encode('utf-8') + except FileNotFoundError: + cherrypy.response.status = 404 + return json.dumps({"error": "OpenAPI spec not found"}).encode('utf-8') + except Exception as e: + cherrypy.response.status = 500 + return json.dumps({"error": f"Error loading OpenAPI spec: {e}"}).encode('utf-8') + + class StatsApp: def __init__( @@ -69,6 +113,9 @@ class StatsApp: # Create nested API object for routing self.api = APIEndpoints(stats_getter, send_advert_func, self.config, event_loop, daemon_instance, config_path) + + # Create doc endpoint for API documentation + self.doc = DocEndpoint(self.api) @cherrypy.expose def index(self): @@ -117,18 +164,84 @@ class HTTPStatsServer: self.host = host self.port = port self.config = config or {} + self.config_path = config_path + + # Initialize authentication handlers + self._init_auth_handlers() + self.app = StatsApp( stats_getter, node_name, pub_key, send_advert_func, config, event_loop, daemon_instance, config_path ) + # Create auth endpoints (APIEndpoints has the config_manager) + self.auth_app = AuthEndpoints(self.config, self.jwt_handler, self.token_manager, self.app.api.config_manager) + + # Create documentation endpoints as separate app + self.doc_app = DocEndpoint(self.app.api) + # Set up CORS at the server level if enabled self._cors_enabled = self.config.get("web", {}).get("cors_enabled", False) logger.info(f"CORS enabled: {self._cors_enabled}") + def _init_auth_handlers(self): + """Initialize JWT handler and API token manager.""" + # Get or generate JWT secret + security_config = self.config.get("security", {}) + jwt_secret = security_config.get("jwt_secret", "") + + if not jwt_secret: + # Auto-generate JWT secret + jwt_secret = secrets.token_hex(32) + logger.warning("No JWT secret found in config, auto-generated one. Please save this to config.yaml:") + logger.warning(f"security.jwt_secret: {jwt_secret}") + + # Try to save to config if config_path is available + if self.config_path: + try: + import yaml + with open(self.config_path, 'r') as f: + config_data = yaml.safe_load(f) or {} + + if 'security' not in config_data: + config_data['security'] = {} + config_data['security']['jwt_secret'] = jwt_secret + + with open(self.config_path, 'w') as f: + yaml.dump(config_data, f, default_flow_style=False) + + logger.info(f"Saved auto-generated JWT secret to {self.config_path}") + except Exception as e: + logger.error(f"Failed to save JWT secret to config: {e}") + + # Initialize JWT handler (15 minute expiry) + self.jwt_handler = JWTHandler(jwt_secret, expiry_minutes=15) + logger.info("JWT handler initialized") + + # Initialize API token manager + storage_dir = self.config.get("storage", {}).get("storage_dir", ".") + + # Ensure storage directory exists + os.makedirs(storage_dir, exist_ok=True) + + db_path = os.path.join(storage_dir, "api_tokens.db") + self.token_manager = APITokenManager(db_path, jwt_secret) + logger.info(f"API token manager initialized with database at {db_path}") + def _setup_server_cors(self): """Set up CORS using cherrypy_cors.install()""" + # Configure CORS to allow Authorization header + # cherrypy-cors will handle preflight requests automatically cherrypy_cors.install() - logger.info("CORS support enabled") + + logger.info("CORS support enabled with Authorization header") + + def _json_error_handler(self, status, message, traceback, version): + """Return JSON error responses instead of HTML for API endpoints""" + cherrypy.response.headers["Content-Type"] = "application/json" + return json.dumps({ + "success": False, + "error": message + }) def start(self): @@ -157,12 +270,42 @@ class HTTPStatsServer: 'txt': 'text/plain' }, }, + # Require authentication for all /api endpoints + "/api": { + "tools.require_auth.on": True, + }, + # Public documentation endpoints (no auth required) + "/api/openapi": { + "tools.require_auth.on": False, + }, + "/api/docs": { + "tools.require_auth.on": False, + }, "/favicon.ico": { "tools.staticfile.on": True, "tools.staticfile.filename": os.path.join(html_dir, "favicon.ico"), }, } + # Add CORS configuration if enabled + if self._cors_enabled: + cors_config = { + "cors.expose.on": True, + "tools.response_headers.on": True, + "tools.response_headers.headers": [ + ('Access-Control-Allow-Origin', '*'), + ('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'), + ('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-API-Key'), + ('Access-Control-Allow-Credentials', 'true'), + ], + # Disable automatic trailing slash redirects to prevent CORS issues + "tools.trailing_slash.on": False, + } + + # Apply CORS to paths + config["/"].update(cors_config) + config["/api"].update(cors_config) + # Add Vue.js assets support only if assets directory exists if os.path.isdir(assets_dir): config["/assets"] = { @@ -189,9 +332,8 @@ class HTTPStatsServer: }, } - # Only add CORS config entries if CORS is enabled + # Only add CORS to static assets if CORS is enabled if self._cors_enabled: - config["/"]["cors.expose.on"] = True if "/assets" in config: config["/assets"]["cors.expose.on"] = True if "/_next" in config: @@ -206,10 +348,66 @@ class HTTPStatsServer: "log.screen": False, "log.access_file": "", # Disable access log file "log.error_file": "", # Disable error log file + # Disable automatic trailing slash redirects globally + "tools.trailing_slash.on": False, + # Custom error handler to return JSON for API endpoints + "error_page.401": self._json_error_handler, } ) + # Mount main app cherrypy.tree.mount(self.app, "/", config) + + # Mount auth endpoints + auth_config = { + "/": { + "tools.response_headers.on": True, + "tools.response_headers.headers": [ + ('Content-Type', 'application/json'), + ], + # Disable automatic trailing slash redirects + "tools.trailing_slash.on": False, + } + } + if self._cors_enabled: + auth_config["/"]["cors.expose.on"] = True + # Add CORS headers for OPTIONS requests + auth_config["/"]["tools.response_headers.headers"].extend([ + ('Access-Control-Allow-Origin', '*'), + ('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'), + ('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-API-Key'), + ('Access-Control-Allow-Credentials', 'true'), + ]) + + cherrypy.tree.mount(self.auth_app, "/auth", auth_config) + + # Mount documentation endpoints as separate app (no auth required for docs) + doc_config = { + "/": { + "tools.require_auth.on": False, # Docs are publicly accessible + "tools.response_headers.on": True, + "tools.response_headers.headers": [ + ('Content-Type', 'text/html; charset=utf-8'), + ], + "tools.trailing_slash.on": False, + } + } + if self._cors_enabled: + doc_config["/"]["cors.expose.on"] = True + doc_config["/"]["tools.response_headers.headers"].extend([ + ('Access-Control-Allow-Origin', '*'), + ('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'), + ('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-API-Key'), + ]) + + cherrypy.tree.mount(self.doc_app, "/doc", doc_config) + + # Store auth handlers in cherrypy config for middleware access + cherrypy.config.update({ + "jwt_handler": self.jwt_handler, + "token_manager": self.token_manager, + "security_config": self.config.get("security", {}), + }) # Completely disable access logging cherrypy.log.access_log.propagate = False diff --git a/repeater/web/openapi.yaml b/repeater/web/openapi.yaml index f9ea39e..2f32422 100644 --- a/repeater/web/openapi.yaml +++ b/repeater/web/openapi.yaml @@ -18,6 +18,10 @@ info: url: https://github.com/rightup/pyMC_Repeater servers: + - url: /api + description: Current server (relative URLs with /api prefix) + - url: / + description: Current server root (for /auth endpoints) - url: http://localhost:8080/api description: Local development server - url: http://{host}:8080/api @@ -25,9 +29,11 @@ servers: variables: host: default: localhost - description: Repeater IP address + description: Repeater IP address or hostname tags: + - name: Authentication + description: User authentication and API token management - name: System description: System statistics and control - name: Packets @@ -38,6 +44,12 @@ tags: description: Noise floor monitoring - name: CAD Calibration description: Channel Activity Detection calibration + - name: Adverts + description: Advertisement and contact management + - name: Transport Keys + description: Transport encryption key management + - name: Network Policy + description: Network flood policy and neighbor management - name: Identities description: Identity management - name: ACL @@ -46,6 +58,237 @@ tags: description: Room server messaging paths: + # ============================================================================ + # Authentication Endpoints + # ============================================================================ + /auth/login: + post: + tags: [Authentication] + summary: User login + description: Authenticate with username/password and receive JWT token + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [username, password, client_id] + properties: + username: + type: string + example: admin + password: + type: string + format: password + example: admin123 + client_id: + type: string + description: Unique client identifier + example: web-client-abc123 + responses: + '200': + description: Login successful + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + token: + type: string + description: JWT token + expires_in: + type: integer + description: Token expiry in seconds + username: + type: string + '401': + description: Invalid credentials + + /auth/refresh: + post: + tags: [Authentication] + summary: Refresh JWT token + description: Extend session by refreshing JWT token + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [client_id] + properties: + client_id: + type: string + responses: + '200': + description: Token refreshed + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + token: + type: string + expires_in: + type: integer + username: + type: string + + /auth/verify: + get: + tags: [Authentication] + summary: Verify authentication + description: Check if current authentication is valid + security: + - BearerAuth: [] + - ApiKeyAuth: [] + responses: + '200': + description: Authentication valid + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + authenticated: + type: boolean + user: + type: object + + /auth/change_password: + post: + tags: [Authentication] + summary: Change admin password + description: Change the admin password (requires authentication) + security: + - BearerAuth: [] + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [current_password, new_password] + properties: + current_password: + type: string + format: password + new_password: + type: string + format: password + minLength: 8 + responses: + '200': + description: Password changed successfully + '401': + description: Current password incorrect + '400': + description: Invalid password format + + /auth/tokens: + get: + tags: [Authentication] + summary: List API tokens + description: Get list of all API tokens (RESTful endpoint) + security: + - BearerAuth: [] + - ApiKeyAuth: [] + responses: + '200': + description: List of tokens + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + tokens: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string + created_at: + type: string + format: date-time + last_used: + type: string + format: date-time + post: + tags: [Authentication] + summary: Create API token + description: Generate a new API token (RESTful endpoint) + security: + - BearerAuth: [] + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: + type: string + description: Friendly name for the token + example: "My API Token" + responses: + '200': + description: Token created + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + token: + type: string + description: The plaintext token (only shown once) + token_id: + type: integer + name: + type: string + warning: + type: string + + /auth/tokens/{token_id}: + delete: + tags: [Authentication] + summary: Revoke API token + description: Delete/revoke an API token by ID (RESTful endpoint) + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - name: token_id + in: path + required: true + schema: + type: integer + responses: + '200': + description: Token revoked + '404': + description: Token not found + + # ============================================================================ + # System Endpoints + # ============================================================================ /stats: get: tags: [System] @@ -90,6 +333,83 @@ paths: '405': description: Method not allowed + /logs: + get: + tags: [System] + summary: Get system logs + description: Retrieve recent system logs + responses: + '200': + description: Log entries + content: + application/json: + schema: + type: object + + /hardware_stats: + get: + tags: [System] + summary: Get hardware statistics + description: CPU, memory, disk usage statistics + responses: + '200': + description: Hardware stats + content: + application/json: + schema: + type: object + properties: + cpu_percent: + type: number + memory_percent: + type: number + disk_usage: + type: object + + /hardware_processes: + get: + tags: [System] + summary: Get process information + description: List running processes and their resource usage + responses: + '200': + description: Process list + content: + application/json: + schema: + type: array + items: + type: object + + /restart_service: + post: + tags: [System] + summary: Restart repeater service + description: Restart the repeater daemon service + security: + - BearerAuth: [] + - ApiKeyAuth: [] + responses: + '200': + description: Service restart initiated + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + + /openapi: + get: + tags: [System] + summary: Get OpenAPI specification + description: Returns the OpenAPI/Swagger specification for this API + responses: + '200': + description: OpenAPI spec + content: + application/json: + schema: + type: object + /set_mode: post: tags: [System] @@ -209,6 +529,66 @@ paths: schema: $ref: '#/components/schemas/SuccessResponse' + /update_duty_cycle_config: + post: + tags: [System] + summary: Update duty cycle configuration + description: Update detailed duty cycle timing configuration + security: + - BearerAuth: [] + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + enabled: + type: boolean + on_time: + type: integer + description: ON time in seconds + off_time: + type: integer + description: OFF time in seconds + example: + enabled: true + on_time: 300 + off_time: 60 + responses: + '200': + description: Duty cycle config updated + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + + /update_radio_config: + post: + tags: [System] + summary: Update radio configuration + description: Update LoRa radio parameters + security: + - BearerAuth: [] + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: Radio config updated + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + + # ============================================================================ + # Packet Endpoints + # ============================================================================ /recent_packets: get: tags: [Packets] @@ -253,6 +633,93 @@ paths: '200': description: Packet details + /packet_type_stats: + get: + tags: [Packets] + summary: Get packet type statistics + description: Statistics broken down by packet type + parameters: + - name: hours + in: query + schema: + type: integer + default: 24 + description: Time range in hours + responses: + '200': + description: Packet type stats + content: + application/json: + schema: + type: object + + /route_stats: + get: + tags: [Packets] + summary: Get route statistics + description: Statistics broken down by route type + parameters: + - name: hours + in: query + schema: + type: integer + default: 24 + description: Time range in hours + responses: + '200': + description: Route stats + content: + application/json: + schema: + type: object + + /filtered_packets: + get: + tags: [Packets] + summary: Get filtered packets + description: Retrieve packets filtered by type, route, and timestamp + parameters: + - name: type + in: query + schema: + type: integer + minimum: 0 + maximum: 15 + description: Packet type filter + - name: route + in: query + schema: + type: integer + minimum: 1 + maximum: 3 + description: Route type filter + - name: start_timestamp + in: query + schema: + type: integer + description: Start timestamp (Unix) + - name: end_timestamp + in: query + schema: + type: integer + description: End timestamp (Unix) + - name: limit + in: query + schema: + type: integer + default: 1000 + description: Maximum results + responses: + '200': + description: Filtered packets + content: + application/json: + schema: + type: object + + # ============================================================================ + # Charts & RRD Endpoints + # ============================================================================ /rrd_data: get: tags: [Charts] @@ -450,6 +917,63 @@ paths: items: type: object + /noise_floor_stats: + get: + tags: [Noise Floor] + summary: Get noise floor statistics + description: Statistical analysis of noise floor measurements + parameters: + - name: hours + in: query + schema: + type: integer + default: 24 + description: Time range in hours + responses: + '200': + description: Noise floor statistics + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + min: + type: number + max: + type: number + avg: + type: number + current: + type: number + + /noise_floor_chart_data: + get: + tags: [Noise Floor] + summary: Get noise floor chart data + description: Formatted noise floor data for charting + parameters: + - name: hours + in: query + schema: + type: integer + default: 24 + description: Time range in hours + responses: + '200': + description: Chart-ready noise floor data + content: + application/json: + schema: + type: object + + # ============================================================================ + # CAD Calibration Endpoints + # ============================================================================ /cad_calibration_start: post: tags: [CAD Calibration] @@ -505,6 +1029,271 @@ paths: schema: $ref: '#/components/schemas/SuccessResponse' + /save_cad_settings: + post: + tags: [CAD Calibration] + summary: Save CAD settings + description: Save calibrated CAD peak and min values to configuration + security: + - BearerAuth: [] + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [peak, min_val] + properties: + peak: + type: integer + minimum: 0 + maximum: 255 + description: CAD peak value + example: 127 + min_val: + type: integer + minimum: 0 + maximum: 255 + description: CAD minimum value + example: 64 + responses: + '200': + description: Settings saved + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + + /cad_calibration_stream: + get: + tags: [CAD Calibration] + summary: CAD calibration SSE stream + description: Server-Sent Events stream of calibration progress + responses: + '200': + description: SSE stream + content: + text/event-stream: + schema: + type: string + + # ============================================================================ + # Adverts & Contacts + # ============================================================================ + /adverts_by_contact_type: + get: + tags: [Adverts] + summary: Get adverts by contact type + description: Retrieve advertisements filtered by contact type + parameters: + - name: contact_type + in: query + schema: + type: integer + description: Contact type to filter by + - name: limit + in: query + schema: + type: integer + default: 100 + description: Maximum number of results + - name: hours + in: query + schema: + type: integer + default: 24 + description: Time range in hours + responses: + '200': + description: Filtered adverts + content: + application/json: + schema: + type: object + + /advert: + get: + tags: [Adverts] + summary: Get specific advert + description: Retrieve details of a specific advertisement + parameters: + - name: advert_id + in: query + required: true + schema: + type: integer + description: Advert ID to retrieve + responses: + '200': + description: Advert details + content: + application/json: + schema: + type: object + + # ============================================================================ + # Transport Keys + # ============================================================================ + /transport_keys: + get: + tags: [Transport Keys] + summary: List transport keys + description: Get list of all transport encryption keys + security: + - BearerAuth: [] + - ApiKeyAuth: [] + responses: + '200': + description: List of transport keys + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + data: + type: array + items: + type: object + post: + tags: [Transport Keys] + summary: Create transport key + description: Generate a new transport encryption key + security: + - BearerAuth: [] + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: Key name/identifier + responses: + '200': + description: Key created + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + + /transport_key: + get: + tags: [Transport Keys] + summary: Get transport key + description: Retrieve specific transport key details + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - name: key_id + in: query + required: true + schema: + type: string + description: Key ID to retrieve + responses: + '200': + description: Key details + content: + application/json: + schema: + type: object + delete: + tags: [Transport Keys] + summary: Delete transport key + description: Remove a transport encryption key + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - name: key_id + in: query + required: true + schema: + type: string + description: Key ID to delete + responses: + '200': + description: Key deleted + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + + # ============================================================================ + # Network Policy + # ============================================================================ + /global_flood_policy: + get: + tags: [Network Policy] + summary: Get global flood policy + description: Retrieve current network flood policy configuration + security: + - BearerAuth: [] + - ApiKeyAuth: [] + responses: + '200': + description: Current policy + content: + application/json: + schema: + type: object + post: + tags: [Network Policy] + summary: Update global flood policy + description: Modify network flood policy settings + security: + - BearerAuth: [] + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: Policy updated + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + + /ping_neighbor: + post: + tags: [Network Policy] + summary: Ping neighbor node + description: Send ping to a specific neighbor node + security: + - BearerAuth: [] + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + node_id: + type: string + description: Target node identifier + responses: + '200': + description: Ping sent + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + + # ============================================================================ + # Identity Management + # ============================================================================ /create_identity: post: tags: [Identities] @@ -631,6 +1420,137 @@ paths: items: $ref: '#/components/schemas/Identity' + /identity: + get: + tags: [Identities] + summary: Get specific identity + description: Retrieve details of a specific identity by name + parameters: + - name: name + in: query + required: true + schema: + type: string + description: Identity name to retrieve + responses: + '200': + description: Identity details + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + data: + $ref: '#/components/schemas/Identity' + + /update_identity: + put: + tags: [Identities] + summary: Update identity + description: Modify an existing identity's configuration + security: + - BearerAuth: [] + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: + type: string + description: Current identity name + new_name: + type: string + description: New identity name (optional) + identity_key: + type: string + description: New identity key (optional) + settings: + type: object + description: Updated settings + responses: + '200': + description: Identity updated + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + + /delete_identity: + delete: + tags: [Identities] + summary: Delete identity + description: Remove an identity from the system + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - name: name + in: query + required: true + schema: + type: string + description: Identity name to delete + responses: + '200': + description: Identity deleted + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + + /send_room_server_advert: + post: + tags: [Identities] + summary: Send room server advertisement + description: Broadcast a room server advertisement packet + security: + - BearerAuth: [] + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: + type: string + description: Room server identity name + node_name: + type: string + description: Node name for the advertisement + latitude: + type: number + format: float + description: GPS latitude + example: 0.0 + longitude: + type: number + format: float + description: GPS longitude + example: 0.0 + disable_fwd: + type: boolean + description: Disable forwarding flag + default: false + responses: + '200': + description: Advertisement sent + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + + # ============================================================================ + # ACL (Access Control List) + # ============================================================================ /acl_info: get: tags: [ACL] @@ -1520,12 +2440,16 @@ components: example: "0xC5" securitySchemes: - # Future: API key authentication + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: JWT token authentication via Authorization header ApiKeyAuth: type: apiKey in: header name: X-API-Key - description: API key authentication (not yet implemented) + description: API key authentication for machine-to-machine access # Future security - currently open # security: