secubox-deb/packages/secubox-hub/api/main.py
CyberMind-FR f42ff0fd9c fix(health): VHost health prober placeholder categorization + Eye Remote metrics
- VHost Health: Add "placeholder" status category to prober
  - Placeholders no longer counted as "down"
  - Health % only reflects real services
  - Dashboard shows  indicator for placeholders

- Eye Remote API: Enhanced metrics exposure
  - Proper CPU calculation (not load average)
  - Memory free/total in MB
  - Disk free/total in GB
  - Active connections and sessions
  - Services running count (86 sockets)
  - VHost health summary

- HAProxy workflow script (scripts/haproxy-workflow.sh)
  - rehealth: Reload HAProxy, invalidate caches, restart probers
  - waf-sync: Sync vhost→backend routes to mitmproxy
  - certs: Check certificate status for all vhosts

- mitmproxy routing: Fixed 127.0.0.1 → 10.100.0.1 (LXC bridge IP)
  to prevent routing loop causing CPU spike

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-06 16:44:49 +02:00

1481 lines
53 KiB
Python

"""secubox-hub — Dashboard central SecuBox"""
from fastapi import FastAPI, APIRouter, Depends, HTTPException
from pydantic import BaseModel
from secubox_core.auth import router as auth_router, require_jwt
from secubox_core.config import get_board_info, get_config
from secubox_core.logger import get_logger
from secubox_core.kiosk import (
detect_board_type, get_board_profile, get_board_capabilities,
get_interface_classification,
)
import subprocess
import json
import asyncio
import time
from pathlib import Path
app = FastAPI(title="secubox-hub", version="1.7.0", root_path="/api/v1/hub")
# Auth router already has prefix="/auth" in secubox_core.auth
app.include_router(auth_router, prefix="/auth")
router = APIRouter()
public_router = APIRouter(prefix="/public", tags=["public"])
log = get_logger("hub")
# ══════════════════════════════════════════════════════════════════
# Menu Cache — Double-buffer pre-cache for instant response
# ══════════════════════════════════════════════════════════════════
MENU_CACHE_FILE = Path("/var/cache/secubox/menu.json")
_menu_cache: dict = {}
_menu_cache_lock = asyncio.Lock()
def _compute_menu_sync() -> dict:
"""Compute full menu (synchronous, called from thread).
Only includes modules that have actual www directories with HTML content.
Skips items without ID, console-only items, and modules without frontends.
"""
menu_items = _load_menu_definitions()
# Filter to only installed modules and check active status
installed_items = []
for item in menu_items:
module_id = item.get("id", "")
# Skip items without a valid ID
if not module_id:
continue
# Skip console-only items (no web interface)
if item.get("console_only"):
continue
# Hub is always installed
if module_id == "hub":
item_copy = item.copy()
item_copy["installed"] = True
item_copy["active"] = True
installed_items.append(item_copy)
continue
# Check if module is installed (has www directory with content)
if _check_module_installed(module_id):
item_copy = item.copy()
item_copy["installed"] = True
item_copy["active"] = _check_module_active(module_id)
installed_items.append(item_copy)
# Group by category
categories = {}
for item in installed_items:
cat = item.get("category", "other")
if cat not in categories:
cat_meta = CATEGORY_META.get(cat, {"name": cat.title(), "icon": "📦", "order": 99})
categories[cat] = {
"id": cat,
"name": cat_meta["name"],
"icon": cat_meta["icon"],
"order": cat_meta["order"],
"items": []
}
categories[cat]["items"].append(item)
# Sort items within each category
for cat in categories.values():
cat["items"].sort(key=lambda x: x.get("order", 999))
# Sort categories by order
sorted_categories = sorted(categories.values(), key=lambda x: x["order"])
return {
"categories": sorted_categories,
"total_installed": len(installed_items),
"total_active": sum(1 for i in installed_items if i.get("active")),
"cached_at": time.time(),
}
async def _refresh_menu_cache():
"""Background task to refresh menu cache every 30s."""
global _menu_cache
import concurrent.futures
while True:
try:
# Run synchronous computation in thread pool
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
loop = asyncio.get_event_loop()
menu_data = await loop.run_in_executor(pool, _compute_menu_sync)
# Update in-memory cache
async with _menu_cache_lock:
_menu_cache = menu_data
# Persist to file for fast startup
try:
MENU_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
MENU_CACHE_FILE.write_text(json.dumps(menu_data))
except Exception as e:
log.debug("Menu cache file write failed: %s", e)
log.debug("Menu cache refreshed: %d modules", menu_data.get("total_installed", 0))
except Exception as e:
log.error("Menu cache refresh failed: %s", e)
await asyncio.sleep(30) # Refresh every 30s
def _load_menu_cache_from_file() -> dict:
"""Load menu cache from file (for fast startup)."""
if MENU_CACHE_FILE.exists():
try:
return json.loads(MENU_CACHE_FILE.read_text())
except Exception:
pass
return {}
# ══════════════════════════════════════════════════════════════════
# Public Endpoints — No authentication required
# ══════════════════════════════════════════════════════════════════
@public_router.get("/menu")
async def public_menu():
"""Public menu endpoint for sidebar navigation (no auth required).
Returns basic menu structure without sensitive data.
Uses pre-computed cache for instant response.
"""
global _menu_cache
# Return from in-memory cache (instant)
if _menu_cache:
return _menu_cache
# Fallback to file cache (fast startup)
file_cache = _load_menu_cache_from_file()
if file_cache:
_menu_cache = file_cache
return file_cache
# Last resort: compute synchronously (only on first request before cache ready)
log.warning("Menu cache miss - computing synchronously")
return _compute_menu_sync()
@public_router.get("/info")
async def public_info():
"""Public info endpoint for login page (no auth required)."""
# Get version from build-info.json
version = "1.7.0"
build_info_path = Path("/etc/secubox/build-info.json")
if build_info_path.exists():
try:
import json
data = json.loads(build_info_path.read_text())
version = data.get("version", version)
except Exception:
pass
# Check auth mode
auth_mode = "Standard"
config_path = Path("/etc/secubox/secubox.conf")
if config_path.exists():
try:
import tomllib
with open(config_path, "rb") as f:
config = tomllib.load(f)
if config.get("auth", {}).get("zkp_enabled", False):
auth_mode = "ZKP"
except Exception:
pass
# Check for ZKP service
try:
r = subprocess.run(["systemctl", "is-active", "secubox-zkp"],
capture_output=True, text=True, timeout=2)
if r.stdout.strip() == "active":
auth_mode = "ZKP"
except Exception:
pass
return {
"version": version,
"auth_mode": auth_mode,
"name": "SecuBox",
}
app.include_router(public_router)
# ══════════════════════════════════════════════════════════════════
# Performance Cache — Avoid repeated subprocess calls
# ══════════════════════════════════════════════════════════════════
_cache = {
"services": {}, # module_id -> {name, active, socket}
"menu": None, # Full menu response
"system_stats": {}, # CPU, memory, disk
"last_refresh": 0,
}
CACHE_TTL = 5 # seconds - cache valid for 5 seconds
# MODULES dict is dynamically populated from installed services
# These are the "expected" core modules - actual list comes from systemd
CORE_MODULES = {
"crowdsec": "secubox-crowdsec",
"netdata": "secubox-netdata",
"wireguard":"secubox-wireguard",
"dpi": "secubox-dpi",
"netmodes": "secubox-netmodes",
"nac": "secubox-nac",
"qos": "secubox-qos",
"vhost": "secubox-vhost",
"system": "secubox-system",
"auth": "secubox-auth",
"mediaflow":"secubox-mediaflow",
"cdn": "secubox-cdn",
}
def _discover_modules() -> dict:
"""Dynamically discover enabled/active secubox modules from systemd."""
modules = {}
try:
# Get enabled or active services only
r = subprocess.run(
["systemctl", "list-units", "secubox-*.service", "--no-pager", "--no-legend", "--all"],
capture_output=True, text=True, timeout=10
)
for line in r.stdout.strip().split("\n"):
if not line.strip():
continue
# Format: UNIT LOAD ACTIVE SUB DESCRIPTION
parts = line.split(None, 4)
if len(parts) >= 4:
svc = parts[0].replace(".service", "")
# Skip runtime/core services
if svc in ("secubox-core", "secubox-runtime", "secubox-firstboot", "secubox-console"):
continue
mod_id = svc.replace("secubox-", "")
modules[mod_id] = svc
except Exception as e:
log.warning("Module discovery failed: %s, using defaults", e)
return CORE_MODULES.copy()
# If discovery found modules, use them; otherwise fall back to defaults
return modules if modules else CORE_MODULES.copy()
# Initialize MODULES with defaults - will be refreshed asynchronously
MODULES = CORE_MODULES.copy()
_modules_discovered = False
def _refresh_services_cache():
"""Refresh all service statuses in one batch (called by background task)."""
# Get all service statuses in parallel using a single systemctl call
all_services = list(MODULES.values())
try:
# Single call to get all service states
r = subprocess.run(
["systemctl", "is-active", "--"] + all_services,
capture_output=True, text=True, timeout=5
)
states = r.stdout.strip().split("\n")
for i, svc in enumerate(all_services):
state = states[i] if i < len(states) else "unknown"
sock = Path(f"/run/secubox/{svc.replace('secubox-','')}.sock")
_cache["services"][svc] = {
"name": svc,
"active": state == "active",
"socket": sock.exists()
}
except Exception as e:
log.warning("Cache refresh failed: %s", e)
def _refresh_system_stats():
"""Refresh system stats (CPU, memory, disk)."""
try:
import psutil
_cache["system_stats"] = {
"cpu_percent": psutil.cpu_percent(interval=None), # Non-blocking
"memory_percent": psutil.virtual_memory().percent,
"disk_percent": psutil.disk_usage("/").percent,
"load_avg": list(psutil.getloadavg()),
}
except Exception as e:
log.warning("System stats refresh failed: %s", e)
_version_refresh_counter = 0
async def _background_cache_refresh():
"""Background task to refresh cache every CACHE_TTL seconds."""
global _version_refresh_counter
while True:
try:
_refresh_services_cache()
_refresh_system_stats()
_cache["last_refresh"] = time.time()
# Refresh package versions every 12 cycles (~60s)
_version_refresh_counter += 1
if _version_refresh_counter >= 12:
_version_refresh_counter = 0
_refresh_package_versions()
except Exception as e:
log.error("Background cache error: %s", e)
await asyncio.sleep(CACHE_TTL)
@app.on_event("startup")
async def startup():
"""Start background cache refresh tasks."""
global MODULES, _modules_discovered, _menu_cache
# Discover modules (non-blocking via thread)
try:
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as pool:
future = pool.submit(_discover_modules)
discovered = future.result(timeout=15)
if discovered:
# Replace MODULES with discovered modules
MODULES.clear()
MODULES.update(discovered)
_modules_discovered = True
log.info("Discovered %d modules", len(MODULES))
except Exception as e:
log.warning("Module discovery timed out: %s, using defaults", e)
# Initial sync refresh (fast, no dpkg-query)
_refresh_services_cache()
_refresh_system_stats()
_cache["last_refresh"] = time.time()
# Load menu cache from file (instant navbar on startup)
_menu_cache = _load_menu_cache_from_file()
if _menu_cache:
log.info("Menu cache loaded from file: %d modules", _menu_cache.get("total_installed", 0))
# Start background tasks
asyncio.create_task(_background_cache_refresh())
asyncio.create_task(_refresh_menu_cache())
log.info("Background cache tasks started")
_version_cache: dict = {}
def _get_package_version(pkg_name: str) -> str:
"""Get installed package version via dpkg (cached)."""
if pkg_name in _version_cache:
return _version_cache[pkg_name]
# Don't block - return placeholder and let background task fill it
return "-"
def _refresh_package_versions():
"""Refresh all package versions in one batch call (non-blocking)."""
global _version_cache
try:
# Single dpkg call for all secubox packages
r = subprocess.run(
["dpkg-query", "-W", "-f=${Package} ${Version}\n", "secubox-*"],
capture_output=True, text=True, timeout=30
)
if r.returncode == 0:
for line in r.stdout.strip().split("\n"):
if " " in line:
pkg, ver = line.split(" ", 1)
_version_cache[pkg] = ver.split("-")[0] if ver else "-"
except Exception as e:
log.debug("Package version refresh failed: %s", e)
def _svc(name: str) -> dict:
"""Get service status from cache (fast) or direct call (fallback)."""
if name in _cache["services"] and (time.time() - _cache["last_refresh"]) < CACHE_TTL * 2:
svc_data = _cache["services"][name].copy()
# Add version from version cache
svc_data["version"] = _version_cache.get(name, "-")
return svc_data
# Fallback to direct call if cache miss (no version lookup to stay fast)
try:
r = subprocess.run(["systemctl", "is-active", name], capture_output=True, text=True, timeout=2)
sock = Path(f"/run/secubox/{name.replace('secubox-','')}.sock")
return {"name": name, "active": r.stdout.strip() == "active",
"socket": sock.exists(), "version": _version_cache.get(name, "-")}
except Exception:
return {"name": name, "active": False, "socket": False, "version": "-"}
@router.get("/status")
async def status(user=Depends(require_jwt)):
board = get_board_info()
modules_status = {k: _svc(v) for k,v in MODULES.items()}
active = sum(1 for m in modules_status.values() if m["active"])
return {**board, "modules": modules_status,
"active_modules": active, "total_modules": len(MODULES)}
@router.get("/modules")
async def modules(user=Depends(require_jwt)):
return [{"id": k, **_svc(v)} for k,v in MODULES.items()]
@router.get("/alerts")
async def alerts(user=Depends(require_jwt)):
alerts_list = []
for mod, svc in MODULES.items():
r = subprocess.run(["systemctl", "is-active", svc], capture_output=True, text=True)
if r.stdout.strip() != "active":
alerts_list.append({"type": "service_down", "module": mod,
"service": svc, "severity": "warning"})
return alerts_list
@router.get("/monitoring")
async def monitoring(user=Depends(require_jwt)):
# Use cached stats (non-blocking)
stats = _cache.get("system_stats", {})
return {
"cpu": stats.get("cpu_percent", 0),
"mem": stats.get("memory_percent", 0),
"load": stats.get("load_avg", [0, 0, 0])
}
@router.get("/settings")
async def settings(user=Depends(require_jwt)):
return get_config()
def _get_build_info() -> dict:
"""Get build metadata from /etc/secubox/build-info.json."""
build_file = Path("/etc/secubox/build-info.json")
if build_file.exists():
try:
return json.loads(build_file.read_text())
except Exception:
pass
return {"build_timestamp": None, "version": "dev"}
@router.get("/dashboard")
async def dashboard(user=Depends(require_jwt)):
"""Données complètes du dashboard (uses cached stats for speed)."""
board = get_board_info()
modules_status = {k: _svc(v) for k, v in MODULES.items()}
active = sum(1 for m in modules_status.values() if m["active"])
build_info = _get_build_info()
# Use cached system stats (non-blocking)
stats = _cache.get("system_stats", {})
return {
"board": board,
"modules": modules_status,
"active_modules": active,
"total_modules": len(MODULES),
"cpu_percent": stats.get("cpu_percent", 0),
"memory_percent": stats.get("memory_percent", 0),
"disk_percent": stats.get("disk_percent", 0),
"load_avg": stats.get("load_avg", [0, 0, 0]),
"uptime": int(float(Path("/proc/uptime").read_text().split()[0])),
"build_info": build_info,
}
@router.get("/widgets")
async def widgets(user=Depends(require_jwt)):
"""Configuration des widgets dashboard."""
widgets_file = Path("/var/lib/secubox/widgets.json")
if widgets_file.exists():
import json
return json.loads(widgets_file.read_text())
return [
{"id": "system", "type": "system_info", "position": 0, "enabled": True},
{"id": "modules", "type": "module_status", "position": 1, "enabled": True},
{"id": "security", "type": "security_alerts", "position": 2, "enabled": True},
{"id": "network", "type": "network_stats", "position": 3, "enabled": True},
]
class WidgetRequest(BaseModel):
widgets: list[dict]
@router.post("/save_widgets")
async def save_widgets(req: WidgetRequest, user=Depends(require_jwt)):
import json
widgets_file = Path("/var/lib/secubox/widgets.json")
widgets_file.parent.mkdir(parents=True, exist_ok=True)
widgets_file.write_text(json.dumps(req.widgets, indent=2))
return {"success": True}
@router.get("/security_summary")
async def security_summary(user=Depends(require_jwt)):
"""Résumé de sécurité."""
return {
"crowdsec_alerts": 0,
"blocked_ips": 0,
"quarantined_clients": 0,
"threats_blocked_24h": 0,
"last_scan": None,
}
@router.get("/network_summary")
async def network_summary(user=Depends(require_jwt)):
"""Résumé réseau with IP addresses."""
import json
# Get interface states
r = subprocess.run(["ip", "-j", "link", "show"], capture_output=True, text=True)
try:
links = json.loads(r.stdout)
ifaces = [l for l in links if l.get("ifname") != "lo"]
up_count = sum(1 for l in ifaces if "UP" in l.get("flags", []))
except Exception:
ifaces = []
up_count = 0
# Get IP addresses for common interfaces
lan_ip = None
wan_ip = None
# Try to get IP addresses using ip -j addr
r2 = subprocess.run(["ip", "-j", "addr", "show"], capture_output=True, text=True)
try:
addrs = json.loads(r2.stdout)
for iface in addrs:
ifname = iface.get("ifname", "")
addr_info = iface.get("addr_info", [])
for ai in addr_info:
if ai.get("family") == "inet":
ip = ai.get("local")
if ip:
# LAN interfaces
if ifname in ("br-lan", "br0", "lan0", "lan"):
lan_ip = ip
# WAN interfaces
elif ifname in ("br-wan", "wan", "eth0", "wan0", "enp1s0"):
wan_ip = ip
# If no LAN yet, use any bridged interface
elif ifname.startswith("br") and not lan_ip:
lan_ip = ip
except Exception:
pass
# Fallback to default network
if not lan_ip:
lan_ip = "192.168.10.1"
if not wan_ip:
wan_ip = "N/A"
return {
"interfaces": len(ifaces),
"interfaces_up": up_count,
"wan_status": "connected" if wan_ip and wan_ip != "N/A" else "disconnected",
"lan_clients": 0,
"lan_ip": lan_ip,
"wan_ip": wan_ip,
}
@router.get("/quick_actions")
async def quick_actions(user=Depends(require_jwt)):
"""Actions rapides disponibles."""
return [
{"id": "restart_services", "label": "Redémarrer les services", "icon": "refresh"},
{"id": "check_updates", "label": "Vérifier les mises à jour", "icon": "download"},
{"id": "run_diagnostic", "label": "Diagnostic système", "icon": "search"},
{"id": "backup_config", "label": "Sauvegarder la config", "icon": "save"},
]
class ActionRequest(BaseModel):
action: str
@router.post("/execute_action")
async def execute_action(req: ActionRequest, user=Depends(require_jwt)):
if req.action == "restart_services":
for svc in list(MODULES.values())[:5]:
subprocess.run(["systemctl", "restart", svc], capture_output=True)
return {"success": True, "message": "Services redémarrés"}
elif req.action == "check_updates":
r = subprocess.run(["apt", "update"], capture_output=True, text=True)
return {"success": r.returncode == 0}
elif req.action == "run_diagnostic":
return {"success": True, "redirect": "/api/v1/system/diagnostics"}
elif req.action == "backup_config":
return {"success": True, "redirect": "/api/v1/system/backup"}
return {"success": False, "error": "Unknown action"}
@router.get("/notifications")
async def notifications(user=Depends(require_jwt)):
"""Notifications système."""
notifs_file = Path("/var/lib/secubox/notifications.json")
if notifs_file.exists():
import json
return json.loads(notifs_file.read_text())
return []
@router.post("/dismiss_notification")
async def dismiss_notification(notification_id: str, user=Depends(require_jwt)):
import json
notifs_file = Path("/var/lib/secubox/notifications.json")
if notifs_file.exists():
notifs = json.loads(notifs_file.read_text())
notifs = [n for n in notifs if n.get("id") != notification_id]
notifs_file.write_text(json.dumps(notifs, indent=2))
return {"success": True}
@router.post("/dismiss_all_notifications")
async def dismiss_all_notifications(user=Depends(require_jwt)):
notifs_file = Path("/var/lib/secubox/notifications.json")
notifs_file.write_text("[]") if notifs_file.parent.exists() else None
return {"success": True}
@router.get("/theme")
async def theme(user=Depends(require_jwt)):
"""Thème de l'interface."""
import json
prefs_file = Path("/var/lib/secubox/preferences.json")
if prefs_file.exists():
prefs = json.loads(prefs_file.read_text())
return {"theme": prefs.get("theme", "dark")}
return {"theme": "dark"}
class ThemeRequest(BaseModel):
theme: str
@router.post("/set_theme")
async def set_theme(req: ThemeRequest, user=Depends(require_jwt)):
import json
prefs_file = Path("/var/lib/secubox/preferences.json")
prefs_file.parent.mkdir(parents=True, exist_ok=True)
prefs = {}
if prefs_file.exists():
prefs = json.loads(prefs_file.read_text())
prefs["theme"] = req.theme
prefs_file.write_text(json.dumps(prefs, indent=2))
return {"success": True}
@router.get("/version")
async def version(user=Depends(require_jwt)):
"""Version SecuBox."""
r = subprocess.run(["dpkg", "-l", "secubox-hub"], capture_output=True, text=True)
version_str = "1.0.0"
for line in r.stdout.splitlines():
if "secubox-hub" in line:
parts = line.split()
if len(parts) >= 3:
version_str = parts[2]
return {
"version": version_str,
"codename": "Armada",
"build_date": "2026-04",
}
@router.get("/about")
async def about(user=Depends(require_jwt)):
board = get_board_info()
return {
"product": "SecuBox",
"version": "1.7.0",
"board": board,
"project_url": "https://secubox.gondwana.systems",
"support_email": "support@cybermind.fr",
}
class ServiceActionRequest(BaseModel):
module: str
action: str # start, stop, restart, enable, disable
@router.post("/module_control")
async def module_control(req: ServiceActionRequest, user=Depends(require_jwt)):
"""Contrôler un module."""
if req.module not in MODULES:
return {"success": False, "error": "Module inconnu"}
svc = MODULES[req.module]
if req.action not in ("start", "stop", "restart", "enable", "disable"):
return {"success": False, "error": "Action invalide"}
r = subprocess.run(["systemctl", req.action, svc], capture_output=True, text=True)
log.info("module_control: %s %s%s", req.action, svc, r.returncode == 0)
return {"success": r.returncode == 0, "output": r.stderr[:200]}
@router.get("/module_status")
async def module_status(module: str, user=Depends(require_jwt)):
"""Statut d'un module."""
if module not in MODULES:
return {"error": "Module inconnu"}
return _svc(MODULES[module])
@router.get("/module_logs")
async def module_logs(module: str, lines: int = 50, user=Depends(require_jwt)):
"""Logs d'un module."""
if module not in MODULES:
return {"error": "Module inconnu"}
r = subprocess.run(
["journalctl", "-u", MODULES[module], "-n", str(lines), "--no-pager"],
capture_output=True, text=True, timeout=10
)
return {"lines": r.stdout.splitlines()}
@router.get("/uptime")
async def uptime(user=Depends(require_jwt)):
"""Uptime système."""
uptime_sec = int(float(Path("/proc/uptime").read_text().split()[0]))
days = uptime_sec // 86400
hours = (uptime_sec % 86400) // 3600
mins = (uptime_sec % 3600) // 60
return {
"seconds": uptime_sec,
"days": days,
"hours": hours,
"minutes": mins,
"uptime": f"{days}d {hours}h {mins}m",
"formatted": f"{days}d {hours}h {mins}m",
}
@router.get("/boot_mode")
async def boot_mode(user=Depends(require_jwt)):
"""Get current boot mode (kiosk or console)."""
kiosk_enabled = Path("/var/lib/secubox/.kiosk-enabled").exists()
kiosk_running = False
try:
import subprocess
r = subprocess.run(["systemctl", "is-active", "secubox-kiosk"], capture_output=True, text=True)
kiosk_running = r.stdout.strip() == "active"
except Exception:
pass
if kiosk_enabled and kiosk_running:
mode = "kiosk"
elif kiosk_enabled:
mode = "kiosk-pending"
else:
mode = "console"
return {
"mode": mode,
"kiosk_enabled": kiosk_enabled,
"kiosk_running": kiosk_running,
}
@router.get("/auth_mode")
async def auth_mode(user=Depends(require_jwt)):
"""Get current authentication mode (ZKP or standard)."""
# Check if ZKP authentication is enabled
zkp_enabled = False
config_path = Path("/etc/secubox/secubox.conf")
if config_path.exists():
try:
import tomllib
with open(config_path, "rb") as f:
config = tomllib.load(f)
zkp_enabled = config.get("auth", {}).get("zkp_enabled", False)
except Exception:
pass
# Also check for ZKP service
zkp_running = False
try:
import subprocess
r = subprocess.run(["systemctl", "is-active", "secubox-zkp"], capture_output=True, text=True)
zkp_running = r.stdout.strip() == "active"
except Exception:
pass
mode = "ZKP" if (zkp_enabled or zkp_running) else "Standard"
return {
"mode": mode,
"zkp_enabled": zkp_enabled,
"zkp_running": zkp_running,
}
@router.get("/cpu")
async def cpu(user=Depends(require_jwt)):
import psutil
# Use cached CPU percent (non-blocking)
stats = _cache.get("system_stats", {})
return {
"percent": stats.get("cpu_percent", 0),
"count": psutil.cpu_count(),
"freq_mhz": psutil.cpu_freq().current if psutil.cpu_freq() else 0,
}
@router.get("/memory")
async def memory(user=Depends(require_jwt)):
import psutil
mem = psutil.virtual_memory()
return {
"total_mb": mem.total // 1024 // 1024,
"used_mb": mem.used // 1024 // 1024,
"available_mb": mem.available // 1024 // 1024,
"percent": mem.percent,
}
@router.get("/disk")
async def disk(user=Depends(require_jwt)):
import psutil
d = psutil.disk_usage("/")
return {
"total_gb": d.total // 1024**3,
"used_gb": d.used // 1024**3,
"free_gb": d.free // 1024**3,
"percent": d.percent,
}
@router.get("/network_stats")
async def network_stats(user=Depends(require_jwt)):
import psutil
counters = psutil.net_io_counters()
return {
"bytes_sent": counters.bytes_sent,
"bytes_recv": counters.bytes_recv,
"packets_sent": counters.packets_sent,
"packets_recv": counters.packets_recv,
}
@router.get("/recent_events")
async def recent_events(user=Depends(require_jwt)):
"""Événements récents."""
return []
@router.get("/system_health")
async def system_health(user=Depends(require_jwt)):
"""Score de santé système (uses cached stats)."""
# Use cached stats (non-blocking)
stats = _cache.get("system_stats", {})
cpu = stats.get("cpu_percent", 0)
mem = stats.get("memory_percent", 0)
disk = stats.get("disk_percent", 0)
modules_status = [_svc(v) for v in MODULES.values()]
active = sum(1 for m in modules_status if m["active"])
module_health = (active / len(MODULES)) * 100
resource_health = 100 - max(cpu, mem, disk) / 2
overall = int((module_health + resource_health) / 2)
return {
"overall": overall,
"module_health": int(module_health),
"resource_health": int(resource_health),
"issues": [m["name"] for m in modules_status if not m["active"]],
}
@router.get("/preferences")
async def preferences(user=Depends(require_jwt)):
import json
prefs_file = Path("/var/lib/secubox/preferences.json")
if prefs_file.exists():
return json.loads(prefs_file.read_text())
return {"theme": "dark", "language": "fr", "notifications": True}
class PreferencesRequest(BaseModel):
theme: str = "dark"
language: str = "fr"
notifications: bool = True
@router.post("/save_preferences")
async def save_preferences(req: PreferencesRequest, user=Depends(require_jwt)):
import json
prefs_file = Path("/var/lib/secubox/preferences.json")
prefs_file.parent.mkdir(parents=True, exist_ok=True)
prefs_file.write_text(json.dumps(req.model_dump(), indent=2))
return {"success": True}
@router.get("/logs")
async def logs(lines: int = 100, user=Depends(require_jwt)):
"""Logs système."""
r = subprocess.run(
["journalctl", "-n", str(lines), "--no-pager", "-o", "short"],
capture_output=True, text=True, timeout=10
)
return {"lines": r.stdout.splitlines()}
@router.get("/check_updates")
async def check_updates(user=Depends(require_jwt)):
"""Vérifier les mises à jour."""
subprocess.run(["apt", "update"], capture_output=True)
r = subprocess.run(["apt", "list", "--upgradable"], capture_output=True, text=True)
updates = []
for line in r.stdout.splitlines()[1:]:
if line.strip():
updates.append(line.split("/")[0])
return {"updates_available": len(updates), "packages": updates[:20]}
@router.post("/apply_updates")
async def apply_updates(user=Depends(require_jwt)):
"""Appliquer les mises à jour."""
log.warning("System update requested")
return {"success": True, "message": "Run 'apt upgrade' manually for safety"}
@router.get("/health")
async def health():
return {"status": "ok", "module": "hub", "version": "1.7.0"}
# ══════════════════════════════════════════════════════════════════
# Module Health Monitor Endpoints
# ══════════════════════════════════════════════════════════════════
MODULE_HEALTH_CACHE = Path("/var/cache/secubox/health/modules.json")
VHOST_HEALTH_CACHE = Path("/var/cache/secubox/health/status.json")
@router.get("/module-health/summary")
async def module_health_summary(user=Depends(require_jwt)):
"""Get module health summary (healthy/degraded/down counts)."""
try:
if MODULE_HEALTH_CACHE.exists():
data = json.loads(MODULE_HEALTH_CACHE.read_text())
# Use pre-computed values from cache if available
if "ok" in data and "degraded" in data:
return {
"health_percent": data.get("health_pct", 0),
"healthy": data.get("ok", 0),
"degraded": data.get("degraded", 0),
"down": data.get("down", 0),
"total": data.get("total", 0),
}
# Fallback: compute from modules dict
modules = data.get("modules", {})
healthy = sum(1 for m in modules.values() if m.get("overall") == "ok")
degraded = sum(1 for m in modules.values() if m.get("overall") == "degraded")
down = sum(1 for m in modules.values() if m.get("overall") in ("down", "error"))
total = len(modules)
return {
"health_percent": (healthy / max(total, 1)) * 100,
"healthy": healthy,
"degraded": degraded,
"down": down,
"total": total,
}
except Exception as e:
log.debug("Module health cache read failed: %s", e)
# Fallback: compute from menu data
menu = _menu_cache or _load_menu_cache_from_file()
if menu and menu.get("categories"):
items = [i for c in menu["categories"] for i in c.get("items", [])]
active = sum(1 for i in items if i.get("active"))
total = len(items)
return {
"health_percent": (active / max(total, 1)) * 100,
"healthy": active,
"degraded": 0,
"down": total - active,
"total": total,
}
return {"health_percent": 0, "healthy": 0, "degraded": 0, "down": 0, "total": 0}
@router.get("/module-health/status")
async def module_health_status(user=Depends(require_jwt)):
"""Get detailed module health status."""
try:
if MODULE_HEALTH_CACHE.exists():
return json.loads(MODULE_HEALTH_CACHE.read_text())
except Exception:
pass
return {"modules": {}, "timestamp": time.time()}
@router.get("/module-health/alerts")
async def module_health_alerts(user=Depends(require_jwt)):
"""Get modules that are degraded or down."""
try:
if MODULE_HEALTH_CACHE.exists():
data = json.loads(MODULE_HEALTH_CACHE.read_text())
modules = data.get("modules", {})
alerts = [
{"name": name, "status": m.get("overall"), "message": m.get("message", "")}
for name, m in modules.items()
if m.get("overall") in ("degraded", "down", "error")
]
return {"alerts": alerts}
except Exception:
pass
return {"alerts": []}
@router.get("/health-monitor/summary")
async def vhost_health_summary(user=Depends(require_jwt)):
"""Get VHost health summary (ok/slow/placeholder/down counts)."""
try:
if VHOST_HEALTH_CACHE.exists():
data = json.loads(VHOST_HEALTH_CACHE.read_text())
# Use pre-computed values if available
if "ok" in data:
return {
"health_percent": data.get("health_pct", 0),
"ok": data.get("ok", 0),
"slow": data.get("slow", 0),
"placeholder": data.get("placeholder", 0),
"down": data.get("down", 0),
"total": data.get("total", 0),
}
# Fallback: compute from vhosts dict
vhosts = data.get("vhosts", {})
ok = sum(1 for v in vhosts.values() if v.get("status") == "ok")
slow = sum(1 for v in vhosts.values() if v.get("status") == "slow")
placeholder = sum(1 for v in vhosts.values() if v.get("status") == "placeholder")
down = sum(1 for v in vhosts.values() if v.get("status") in ("down", "error", "timeout"))
total = len(vhosts)
real_total = total - placeholder
return {
"health_percent": (ok + slow) / max(real_total, 1) * 100,
"ok": ok,
"slow": slow,
"placeholder": placeholder,
"down": down,
"total": total,
}
except Exception as e:
log.debug("VHost health cache read failed: %s", e)
return {"health_percent": 0, "ok": 0, "slow": 0, "placeholder": 0, "down": 0, "total": 0}
@router.get("/health-monitor/status")
async def vhost_health_status(user=Depends(require_jwt)):
"""Get detailed VHost health status."""
try:
if VHOST_HEALTH_CACHE.exists():
return json.loads(VHOST_HEALTH_CACHE.read_text())
except Exception:
pass
return {"vhosts": {}, "timestamp": time.time()}
@router.get("/health-monitor/alerts")
async def vhost_health_alerts(user=Depends(require_jwt)):
"""Get VHosts that are down or slow."""
try:
if VHOST_HEALTH_CACHE.exists():
data = json.loads(VHOST_HEALTH_CACHE.read_text())
vhosts = data.get("vhosts", {})
alerts = [
{"domain": domain, "status": v.get("status"), "response_time": v.get("response_time", 0)}
for domain, v in vhosts.items()
if v.get("status") in ("slow", "down", "error", "timeout")
]
return {"alerts": alerts[:20]} # Limit to 20
except Exception:
pass
return {"alerts": []}
# ══════════════════════════════════════════════════════════════════
# Network Mode Selection (integrates with secubox-netmodes)
# ══════════════════════════════════════════════════════════════════
NETMODES_STATE_FILE = Path("/var/lib/secubox/netmodes-state.json")
AVAILABLE_NETWORK_MODES = {
"router": {
"name": "Router",
"desc": "Full NAT router with DHCP, NAC, DPI",
"icon": "🔀",
"recommended_for": ["home", "office", "smb"],
},
"sniffer-inline": {
"name": "Inline Sniffer",
"desc": "Transparent bridge with dual-stream DPI (tc mirred)",
"icon": "🔍",
"recommended_for": ["security_audit", "monitoring"],
},
"sniffer-passive": {
"name": "Passive Sniffer",
"desc": "Out-of-band monitoring via SPAN/TAP port",
"icon": "👁️",
"recommended_for": ["readonly_monitoring", "compliance"],
},
"access-point": {
"name": "Access Point",
"desc": "WiFi AP 802.11r/k/v with band steering",
"icon": "📡",
"recommended_for": ["wireless_extension"],
},
"relay": {
"name": "VPN Relay",
"desc": "Network relay with WireGuard VPN and optimized MTU",
"icon": "🔗",
"recommended_for": ["remote_site", "vpn_gateway"],
},
}
def _get_netmodes_state() -> dict:
"""Read current network mode state."""
if NETMODES_STATE_FILE.exists():
try:
return json.loads(NETMODES_STATE_FILE.read_text())
except Exception:
pass
return {"current_mode": "router", "pending_mode": None, "last_change": None}
@router.get("/network_mode")
async def get_network_mode(user=Depends(require_jwt)):
"""
Get current network mode and available modes.
Used by dashboard to display network mode widget.
"""
state = _get_netmodes_state()
current_mode = state.get("current_mode", "router")
mode_info = AVAILABLE_NETWORK_MODES.get(current_mode, {})
# Get board info for recommendations
board_type = detect_board_type()
board_profile = get_board_profile(board_type)
iface_class = get_interface_classification(board_type)
return {
"current_mode": current_mode,
"mode_name": mode_info.get("name", current_mode),
"mode_desc": mode_info.get("desc", ""),
"mode_icon": mode_info.get("icon", "🔀"),
"pending_mode": state.get("pending_mode"),
"last_change": state.get("last_change"),
"board_type": board_type,
"board_profile": board_profile,
"interfaces": {
"wan": iface_class.get("wan", []),
"lan": iface_class.get("lan", []),
"sfp": iface_class.get("sfp", []),
},
"available_modes": [
{"id": k, **v} for k, v in AVAILABLE_NETWORK_MODES.items()
],
}
class NetworkModeRequest(BaseModel):
mode: str
dry_run: bool = False
@router.post("/network_mode")
async def set_network_mode(req: NetworkModeRequest, user=Depends(require_jwt)):
"""
Change network mode (requires authentication).
Proxies to secubox-netmodes API.
"""
if req.mode not in AVAILABLE_NETWORK_MODES:
raise HTTPException(400, f"Invalid mode: {req.mode}")
# Call secubox-netmodes API via socket
netmodes_sock = Path("/run/secubox/netmodes.sock")
if not netmodes_sock.exists():
return {"success": False, "error": "secubox-netmodes not running"}
try:
import httpx
async with httpx.AsyncClient(
transport=httpx.AsyncHTTPTransport(uds=str(netmodes_sock))
) as client:
if req.dry_run:
# Preview changes
resp = await client.get(
f"http://localhost/preview_changes?mode={req.mode}",
timeout=30
)
else:
# Apply mode
resp = await client.post(
"http://localhost/apply_mode",
json={"mode": req.mode},
timeout=60
)
if resp.status_code == 200:
result = resp.json()
log.info("Network mode change: %s -> %s", req.mode, result.get("success", "unknown"))
return result
else:
return {"success": False, "error": f"API error: {resp.status_code}"}
except ImportError:
# Fallback: direct subprocess call to netplan
log.warning("httpx not available, using fallback")
return {"success": False, "error": "httpx not installed for socket communication"}
except Exception as e:
log.error("network_mode error: %s", e)
return {"success": False, "error": str(e)}
@router.get("/network_mode/preview")
async def preview_network_mode(mode: str, user=Depends(require_jwt)):
"""Preview what configuration would be applied for a mode."""
if mode not in AVAILABLE_NETWORK_MODES:
raise HTTPException(400, f"Invalid mode: {mode}")
board_type = detect_board_type()
iface_class = get_interface_classification(board_type)
wan = iface_class.get("wan", ["eth0"])[0] if iface_class.get("wan") else "eth0"
lan = iface_class.get("lan", [])
lan_str = ", ".join(lan) if lan else ""
# Generate preview YAML based on mode
if mode == "router":
yaml_preview = f"""network:
version: 2
renderer: networkd
ethernets:
{wan}:
dhcp4: true
optional: true
"""
for iface in lan:
yaml_preview += f""" {iface}:
optional: true
"""
if lan:
yaml_preview += f"""
bridges:
br-lan:
interfaces: [{lan_str}]
addresses: [192.168.1.1/24]
dhcp4: false
"""
elif mode in ("sniffer-inline", "sniffer-passive"):
yaml_preview = f"""network:
version: 2
renderer: networkd
ethernets:
{wan}:
dhcp4: false
optional: true
"""
for iface in lan:
yaml_preview += f""" {iface}:
dhcp4: false
optional: true
"""
yaml_preview += f"""
bridges:
br0:
interfaces: [{wan}, {lan_str}]
dhcp4: true
parameters:
stp: false
"""
else:
yaml_preview = f"""network:
version: 2
renderer: networkd
ethernets:
{wan}:
dhcp4: true
"""
return {
"mode": mode,
"mode_info": AVAILABLE_NETWORK_MODES.get(mode, {}),
"board_type": board_type,
"interfaces": iface_class,
"yaml_preview": yaml_preview,
}
@router.get("/board_summary")
async def board_summary(user=Depends(require_jwt)):
"""
Quick board summary for dashboard widgets.
Uses secubox_core.kiosk functions.
"""
board_type = detect_board_type()
profile = get_board_profile(board_type)
caps = get_board_capabilities(board_type)
ifaces = get_interface_classification(board_type)
return {
"board_type": board_type,
"profile": profile,
"capabilities": caps,
"interfaces": ifaces,
}
# ── Dynamic Menu System ──────────────────────────────────────────
MENU_DIR = Path("/usr/share/secubox/menu.d")
# Default menu definitions (used if menu.d files don't exist)
DEFAULT_MENU = [
{"id": "hub", "name": "Dashboard", "category": "dashboard", "icon": "🏠", "path": "/", "order": 0},
{"id": "system", "name": "System Hub", "category": "dashboard", "icon": "🔧", "path": "/system/", "order": 10},
{"id": "crowdsec", "name": "CrowdSec", "category": "security", "icon": "🛡️", "path": "/crowdsec/", "order": 100},
{"id": "waf", "name": "WAF", "category": "security", "icon": "🔥", "path": "/waf/", "order": 105},
{"id": "wireguard", "name": "WireGuard VPN", "category": "security", "icon": "🔐", "path": "/wireguard/", "order": 110},
{"id": "auth", "name": "Auth Guardian", "category": "security", "icon": "🔑", "path": "/auth/", "order": 120},
{"id": "nac", "name": "Client Guardian", "category": "security", "icon": "👥", "path": "/nac/", "order": 130},
{"id": "netmodes", "name": "Network Modes", "category": "network", "icon": "🔀", "path": "/netmodes/", "order": 200},
{"id": "dpi", "name": "DPI", "category": "network", "icon": "🔍", "path": "/dpi/", "order": 210},
{"id": "qos", "name": "Bandwidth Manager", "category": "network", "icon": "📶", "path": "/qos/", "order": 220},
{"id": "vhost", "name": "Virtual Hosts", "category": "network", "icon": "🌍", "path": "/vhost/", "order": 230},
{"id": "cdn", "name": "CDN Cache", "category": "network", "icon": "💾", "path": "/cdn/", "order": 240},
{"id": "haproxy", "name": "HAProxy", "category": "network", "icon": "⚖️", "path": "/haproxy/", "order": 250},
{"id": "dns", "name": "DNS", "category": "network", "icon": "🌐", "path": "/dns/", "order": 260},
{"id": "netdata", "name": "Netdata", "category": "monitoring", "icon": "📊", "path": "/netdata/", "order": 300},
{"id": "mediaflow", "name": "Media Flow", "category": "monitoring", "icon": "📺", "path": "/mediaflow/", "order": 310},
{"id": "droplet", "name": "Droplet", "category": "publishing", "icon": "📤", "path": "/droplet/", "order": 400},
{"id": "metablogizer", "name": "MetaBlogizer", "category": "publishing", "icon": "📝", "path": "/metablogizer/", "order": 410},
{"id": "publish", "name": "Publish", "category": "publishing", "icon": "🚀", "path": "/publish/", "order": 420},
{"id": "streamlit", "name": "Streamlit", "category": "apps", "icon": "🎯", "path": "/streamlit/", "order": 500},
{"id": "streamforge", "name": "StreamForge", "category": "apps", "icon": "🔨", "path": "/streamforge/", "order": 510},
{"id": "users", "name": "Users", "category": "admin", "icon": "👤", "path": "/users/", "order": 600},
{"id": "mail", "name": "Mail Server", "category": "admin", "icon": "📧", "path": "/mail/", "order": 610},
{"id": "webmail", "name": "Webmail", "category": "admin", "icon": "📬", "path": "/webmail/", "order": 620},
{"id": "mail-lxc", "name": "Mail LXC", "category": "admin", "icon": "📧", "path": "/mail-lxc/", "order": 630},
{"id": "webmail-lxc", "name": "Webmail LXC", "category": "admin", "icon": "📬", "path": "/webmail-lxc/", "order": 640},
{"id": "portal", "name": "Portal", "category": "security", "icon": "🚪", "path": "/portal/", "order": 140},
]
CATEGORY_META = {
"dashboard": {"name": "Dashboard", "icon": "📊", "order": 0},
"security": {"name": "Security", "icon": "🛡️", "order": 1},
"network": {"name": "Network", "icon": "🌐", "order": 2},
"system": {"name": "System", "icon": "⚙️", "order": 3},
"core": {"name": "Core", "icon": "🔧", "order": 4},
"users": {"name": "Users", "icon": "👥", "order": 5},
"services": {"name": "Services", "icon": "🔌", "order": 6},
"privacy": {"name": "Privacy", "icon": "🔒", "order": 7},
"monitoring": {"name": "Monitoring", "icon": "📈", "order": 8},
"publishing": {"name": "Publishing", "icon": "📤", "order": 9},
"apps": {"name": "Applications", "icon": "🎯", "order": 10},
"admin": {"name": "Administration", "icon": "🛠️", "order": 11},
}
def _load_menu_definitions() -> list:
"""Load menu definitions from menu.d directory or use defaults."""
import json
menu_items = []
if MENU_DIR.exists():
for f in sorted(MENU_DIR.glob("*.json")):
try:
data = json.loads(f.read_text())
if isinstance(data, list):
menu_items.extend(data)
elif isinstance(data, dict):
menu_items.append(data)
except Exception as e:
log.warning("Failed to load menu %s: %s", f.name, e)
# If no menu files found, use defaults
if not menu_items:
menu_items = DEFAULT_MENU.copy()
return menu_items
def _check_module_installed(module_id: str) -> bool:
"""Check if a module is installed AND has a usable frontend.
A module is considered installed if it has a www directory with content.
Services without frontends are not shown in the menu.
"""
# Reject empty or invalid module IDs
if not module_id or not isinstance(module_id, str):
return False
# Hub is always installed
if module_id == "hub":
return True
# Portal is part of hub package
if module_id == "portal":
portal_path = Path("/usr/share/secubox/www/portal")
return portal_path.exists() and (portal_path / "index.html").exists()
# Check for www directory with an index.html or any html file
www_path = Path(f"/usr/share/secubox/www/{module_id}")
if www_path.exists() and www_path.is_dir():
# Check for index.html or any content
if (www_path / "index.html").exists():
return True
# Check for any HTML files
if list(www_path.glob("*.html")):
return True
return False
def _check_module_active(module_id: str) -> bool:
"""Check if a module's service is active (uses cache)."""
svc_name = f"secubox-{module_id}"
# Check cache first
if svc_name in _cache["services"]:
return _cache["services"][svc_name].get("active", False)
# Check for socket (for socket-based services)
sock = Path(f"/run/secubox/{module_id}.sock")
if sock.exists():
return True
# Check systemd service directly (for TCP port services)
result = subprocess.run(
["systemctl", "is-active", svc_name],
capture_output=True, text=True
)
return result.stdout.strip() == "active"
@router.get("/menu")
async def menu(user=Depends(require_jwt)):
"""
Dynamic menu endpoint (authenticated).
Returns categorized menu items for installed modules only.
Uses pre-computed cache for instant response.
"""
global _menu_cache
# Return from in-memory cache (instant)
if _menu_cache:
return _menu_cache
# Fallback to file cache
file_cache = _load_menu_cache_from_file()
if file_cache:
_menu_cache = file_cache
return file_cache
# Last resort: compute synchronously
return _compute_menu_sync()
app.include_router(router)