mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 18:36:55 +00:00
- 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>
1481 lines
53 KiB
Python
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)
|