mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-08-02 15:02:34 +02:00
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.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)")
|
||||
|
||||
+280
-62
@@ -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=<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=<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=<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 = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>pyMC Repeater API Documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@5.10.0/swagger-ui.css">
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5.10.0/swagger-ui-bundle.js"></script>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5.10.0/swagger-ui-standalone-preset.js"></script>
|
||||
<script>
|
||||
window.onload = function() {
|
||||
window.ui = SwaggerUIBundle({
|
||||
url: '/api/openapi',
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset
|
||||
],
|
||||
plugins: [
|
||||
SwaggerUIBundle.plugins.DownloadUrl
|
||||
],
|
||||
layout: "StandaloneLayout"
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
cherrypy.response.headers['Content-Type'] = 'text/html'
|
||||
return swagger_html.encode('utf-8')
|
||||
return html.encode('utf-8')
|
||||
@@ -0,0 +1,9 @@
|
||||
from .jwt_handler import JWTHandler
|
||||
from .api_tokens import APITokenManager
|
||||
from .middleware import require_auth
|
||||
|
||||
__all__ = [
|
||||
'JWTHandler',
|
||||
'APITokenManager',
|
||||
'require_auth'
|
||||
]
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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')
|
||||
+201
-3
@@ -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
|
||||
|
||||
+927
-3
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user