secubox-openwrt/secubox-tools/webui/app/backends/http.py
CyberMind-FR 0d6aaa1111 feat(webui): add Project Hub workspace and remove Command Center glow effects
- Add complete Project Hub & Workspace Interface implementation
  - New data models: Project, ModuleKit, Workspace
  - 3 fixture projects (cybermind.fr, cybermood.eu, secubox-c3)
  - 4 module kits (Security, Network, Automation, Media)
  - Workspace routes with project switching and kit installation
  - 4 workspace tabs: Overview, Module Kits, Devices, Composer
  - New navigation item: Workspace (7th section)

- Remove all glowing effects from UI
  - Remove Command Center widget glow and backdrop blur
  - Remove device status indicator glow
  - Remove toggle button glow effects

- Extend DataStore with 13 new methods for workspace management
- Add 270+ lines of workspace-specific CSS with responsive layouts
- Create workspace templates and result partials

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-03 08:10:22 +01:00

485 lines
16 KiB
Python

"""
HTTP/REST API backend for OpenWrt devices via rpcd/ubus.
Connects to real OpenWrt devices using HTTP RPC protocol.
Supports authentication, session management, and ubus method calls.
"""
import json
import logging
from typing import Dict, Any, List, Optional, TYPE_CHECKING
from datetime import datetime, timedelta
import httpx
from .base import BackendBase
from .config import (
OPENWRT_RPC_ENDPOINTS,
HTTP_TIMEOUT,
HTTP_CONNECT_TIMEOUT,
SESSION_TTL,
MAX_RETRIES,
COMMAND_MAPPINGS,
)
if TYPE_CHECKING:
from ..models import ExecutionResult, Preset, Module, CommandResult
logger = logging.getLogger(__name__)
class OpenWrtConnectionError(Exception):
"""Raised when cannot connect to OpenWrt device."""
pass
class OpenWrtAuthenticationError(Exception):
"""Raised when authentication fails."""
pass
class HTTPBackend(BackendBase):
"""OpenWrt HTTP/RPC backend using rpcd/ubus."""
def __init__(self, connection_config: Dict[str, Any]) -> None:
"""
Initialize HTTP backend with connection configuration.
Args:
connection_config: Dict with host, port, username, password, protocol
"""
self.host = connection_config.get("host", "192.168.1.1")
self.port = connection_config.get("port", 80)
self.username = connection_config.get("username", "root")
self.password = connection_config.get("password", "")
self.protocol = connection_config.get("protocol", "http")
self.base_url = f"{self.protocol}://{self.host}:{self.port}"
self.session_id: Optional[str] = None
self.session_expires: Optional[datetime] = None
self.client: Optional[httpx.AsyncClient] = None
async def _get_client(self) -> httpx.AsyncClient:
"""Get or create HTTP client."""
if self.client is None:
self.client = httpx.AsyncClient(
timeout=httpx.Timeout(HTTP_TIMEOUT, connect=HTTP_CONNECT_TIMEOUT),
verify=False, # Allow self-signed certificates for dev
)
return self.client
async def _ensure_connected(self) -> None:
"""Ensure we have a valid session, authenticate if needed."""
now = datetime.now()
# Check if session is expired or doesn't exist
if (
self.session_id is None
or self.session_expires is None
or now >= self.session_expires
):
await self._authenticate()
async def _authenticate(self) -> None:
"""
Authenticate with OpenWrt and obtain session ID.
Supports both modern ubus and legacy RPC authentication.
Raises:
OpenWrtConnectionError: Cannot reach device
OpenWrtAuthenticationError: Invalid credentials
"""
client = await self._get_client()
# Try modern ubus authentication first
try:
session_id = await self._authenticate_modern_ubus()
if session_id:
self.session_id = session_id
self.session_expires = datetime.now() + timedelta(seconds=SESSION_TTL)
logger.info(f"Successfully authenticated to {self.host} (modern ubus)")
return
except Exception as e:
logger.debug(f"Modern ubus auth failed, trying legacy: {e}")
# Fallback to legacy RPC authentication
auth_url = f"{self.base_url}{OPENWRT_RPC_ENDPOINTS['auth']}"
try:
response = await client.post(
auth_url,
json={
"id": 1,
"method": "login",
"params": [self.username, self.password],
},
)
response.raise_for_status()
data = response.json()
result = data.get("result")
if not result:
raise OpenWrtAuthenticationError(
f"Authentication failed for user '{self.username}'"
)
self.session_id = result
self.session_expires = datetime.now() + timedelta(seconds=SESSION_TTL)
logger.info(f"Successfully authenticated to {self.host} (legacy RPC)")
except httpx.HTTPError as e:
raise OpenWrtConnectionError(
f"Cannot connect to OpenWrt at {self.base_url}: {e}"
)
async def _authenticate_modern_ubus(self) -> Optional[str]:
"""
Authenticate using modern ubus endpoint.
Returns:
Session ID (ubus_rpc_session) or None if failed
"""
client = await self._get_client()
ubus_url = f"{self.base_url}{OPENWRT_RPC_ENDPOINTS['luci_auth']}"
response = await client.post(
ubus_url,
json={
"jsonrpc": "2.0",
"id": 1,
"method": "call",
"params": [
"00000000000000000000000000000000", # Null session for login
"session",
"login",
{
"username": self.username,
"password": self.password
}
]
}
)
response.raise_for_status()
data = response.json()
# Check for errors
if "error" in data:
error_msg = data["error"].get("message", "Unknown error")
raise OpenWrtAuthenticationError(f"Authentication error: {error_msg}")
# Extract session ID from result
result = data.get("result")
if result and isinstance(result, list) and len(result) >= 2:
session_data = result[1]
if isinstance(session_data, dict):
session_id = session_data.get("ubus_rpc_session")
if session_id:
return session_id
raise OpenWrtAuthenticationError("No session ID in response")
async def _call_ubus(
self, path: str, method: str, params: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Call ubus RPC method.
Args:
path: ubus object path (e.g., "system")
method: ubus method name (e.g., "board")
params: Method parameters
Returns:
Response data from ubus
Raises:
OpenWrtConnectionError: Connection failed
"""
await self._ensure_connected()
client = await self._get_client()
# Use modern ubus endpoint
ubus_url = f"{self.base_url}{OPENWRT_RPC_ENDPOINTS['luci_auth']}"
# Build ubus request
ubus_params = [
self.session_id,
path,
method,
params or {},
]
try:
response = await client.post(
ubus_url,
json={
"jsonrpc": "2.0",
"id": 1,
"method": "call",
"params": ubus_params,
},
)
response.raise_for_status()
data = response.json()
# Check for ubus errors
if "error" in data:
error_msg = data["error"].get("message", "Unknown error")
raise OpenWrtConnectionError(f"Ubus error: {error_msg}")
# Modern ubus returns result as [status_code, data]
result = data.get("result")
if isinstance(result, list) and len(result) >= 2:
return result[1] if result[1] else {}
return result if isinstance(result, dict) else {}
except httpx.HTTPError as e:
raise OpenWrtConnectionError(f"Ubus call failed: {e}")
async def execute_command(
self,
command_id: str,
command_name: str,
context: Dict[str, Any],
) -> "CommandResult":
"""
Execute a single command via ubus.
Args:
command_id: Command identifier
command_name: Human-readable command name
context: Execution context
Returns:
CommandResult with real output from OpenWrt
"""
from ..models import CommandResult
# Look up command mapping
mapping = COMMAND_MAPPINGS.get(command_id)
if not mapping:
# Fallback: return error for unmapped commands
return CommandResult(
command_id=command_id,
name=command_name,
log=f"Command '{command_id}' not mapped to OpenWrt ubus API",
status="error",
)
try:
# Call ubus method
result = await self._call_ubus(
path=mapping["ubus_path"],
method=mapping["ubus_method"],
params=mapping.get("params", {}),
)
# Format output as JSON
output = json.dumps(result, indent=2)
return CommandResult(
command_id=command_id,
name=command_name,
log=output,
status="ok",
)
except (OpenWrtConnectionError, OpenWrtAuthenticationError) as e:
return CommandResult(
command_id=command_id,
name=command_name,
log=f"Error: {str(e)}",
status="error",
)
async def run_preset(
self,
preset: "Preset",
module: "Module",
extra_context: Optional[Dict[str, Any]] = None,
) -> "ExecutionResult":
"""
Execute a preset (sequence of commands) via HTTP.
Args:
preset: Preset configuration
module: Parent module
extra_context: Additional context
Returns:
ExecutionResult with all command outputs from OpenWrt
"""
from ..models import ExecutionResult, CommandResult
# Build context
context: Dict[str, Any] = {**preset.parameters}
if extra_context:
context.update(extra_context)
command_results: List[CommandResult] = []
warnings: List[str] = []
errors: List[str] = []
# Execute each command in sequence
for cmd_id in preset.command_sequence:
try:
command = module.commands.get(cmd_id)
if not command:
# Try to find command globally
from ..services import store
command = store.get_command(cmd_id)
result = await self.execute_command(
command_id=cmd_id,
command_name=command.name if command else cmd_id,
context=context,
)
command_results.append(result)
if result.status == "warn":
warnings.append(f"{result.name} reported warnings")
elif result.status == "error":
errors.append(f"{result.name} failed")
except Exception as e:
logger.error(f"Error executing command {cmd_id}: {e}")
command_results.append(
CommandResult(
command_id=cmd_id,
name=cmd_id,
log=f"Unexpected error: {str(e)}",
status="error",
)
)
errors.append(f"Command {cmd_id} failed")
# Build summary
summary = f"Preset '{preset.name}' completed for {module.name} via HTTP"
if errors:
summary += f" with {len(errors)} error(s)"
elif warnings:
summary += f" with {len(warnings)} warning(s)"
return ExecutionResult(
preset=preset,
summary=summary,
context={"module": module.name, "backend": "http", **context},
commands=command_results,
warnings=warnings or None,
)
async def test_connection(self) -> bool:
"""
Test HTTP backend connection.
Returns:
True if can authenticate, False otherwise
"""
try:
await self._authenticate()
return True
except (OpenWrtConnectionError, OpenWrtAuthenticationError) as e:
logger.warning(f"Connection test failed: {e}")
return False
async def disconnect(self) -> None:
"""Clean up HTTP client and session."""
if self.client:
await self.client.aclose()
self.client = None
self.session_id = None
self.session_expires = None
# =================================================================
# Command Center Real-time Metrics Methods
# =================================================================
async def get_system_metrics(self):
"""Get real system metrics from OpenWrt via ubus."""
try:
# Get system info from ubus
info = await self._call_ubus('system', 'info')
board = await self._call_ubus('system', 'board')
# Handle if info/board are still lists (debug)
if isinstance(info, list):
logger.warning(f"Info returned as list: {info}")
info = info[1] if len(info) >= 2 else {}
if isinstance(board, list):
logger.warning(f"Board returned as list: {board}")
board = board[1] if len(board) >= 2 else {}
# Parse CPU load
load_avg = info.get('load', [0, 0, 0]) if isinstance(info, dict) else [0, 0, 0]
cpu_usage = (load_avg[0] / 4) * 100 if load_avg else 0 # Assume 4 cores
# Parse memory
memory = info.get('memory', {}) if isinstance(info, dict) else {}
total_mem = memory.get('total', 0) // (1024 * 1024) # Convert to MB
used_mem = (memory.get('total', 0) - memory.get('free', 0)) // (1024 * 1024)
mem_percent = (used_mem / total_mem * 100) if total_mem > 0 else 0
return {
'cpu': {
'usage': round(cpu_usage, 2),
'cores': [round(cpu_usage, 2)] * 4, # TODO: Get actual per-core stats
'temperature': 0 # Not available via standard ubus
},
'memory': {
'used': used_mem,
'total': total_mem,
'percent': round(mem_percent, 2),
'available': total_mem - used_mem
},
'network': {
'rx_bytes': 0, # TODO: Parse from network.interface.dump
'tx_bytes': 0,
'rx_rate': 0,
'tx_rate': 0,
'connections': 0
},
'uptime': info.get('uptime', 0),
'load_avg': load_avg
}
except Exception as e:
logger.warning(f"Failed to get real metrics from OpenWrt, using simulation: {e}")
# Fallback to simulated metrics
from .metrics import MetricsCollector
return await MetricsCollector.get_system_metrics()
async def get_threat_feed(self):
"""Get threat feed (simulated for now, real CrowdSec integration TBD)."""
# TODO: Integrate with CrowdSec module when available
logger.debug("Using simulated threat feed (CrowdSec integration pending)")
from .metrics import MetricsCollector
return await MetricsCollector.get_threat_feed()
async def get_traffic_stats(self):
"""Get traffic stats (simulated for now, real Netifyd integration TBD)."""
# TODO: Integrate with Netifyd module when available
logger.debug("Using simulated traffic stats (Netifyd integration pending)")
from .metrics import MetricsCollector
return await MetricsCollector.get_traffic_stats()
async def get_command_output(self, command: str) -> str:
"""Execute command via ubus or SSH (future)."""
try:
# For now, use simulated output
# TODO: Implement real command execution via SSH or rpcd exec
logger.debug(f"Command execution requested: {command}")
from .metrics import MetricsCollector
return await MetricsCollector.get_command_output(command)
except Exception as e:
logger.error(f"Command execution failed: {e}")
from datetime import datetime
return f"[{datetime.now().strftime('%H:%M:%S')}] ERROR: {str(e)}\n"