secubox-openwrt/package/secubox/secubox-console/root/usr/lib/secubox-console/secubox_frontend.py
CyberMind-FR 8df75f6c06 feat(console): Add Services tab with peer service discovery
- Add Services tab to TUI displaying services from all mesh peers
- Implement get_peer_services() fetching via P2P API (port 7331/services)
- Add 60-second caching to avoid slow repeated API calls
- Group services into categories: Web/Proxy, Security, AI/ML, Containers, Apps
- Fix service endpoint URL: /services not /api/services
- Increase API timeout to 15s for comprehensive service scans
- Version bump to 1.2.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 07:15:46 +01:00

1308 lines
47 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
SecuBox Frontend - Smart Linux Host Management App
KISS architecture with modern TUI
Features:
- Multi-device dashboard
- Real-time monitoring
- Backup orchestration
- Mesh visualization
- Security alerts
- One-click actions
Requirements:
pip install textual paramiko httpx rich
Usage:
python secubox_frontend.py
python secubox_frontend.py --host 192.168.255.1
"""
import os
import sys
import json
import time
import asyncio
import threading
from pathlib import Path
from datetime import datetime
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Callable
from concurrent.futures import ThreadPoolExecutor
# ============================================================================
# Try imports - graceful degradation
# ============================================================================
try:
from textual.app import App, ComposeResult
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer, Grid
from textual.widgets import Header, Footer, Static, Button, DataTable, Label, Input, TabbedContent, TabPane, Log, ProgressBar
from textual.reactive import reactive
from textual.timer import Timer
from textual.screen import Screen, ModalScreen
from textual import events
TEXTUAL_AVAILABLE = True
except ImportError:
TEXTUAL_AVAILABLE = False
try:
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.live import Live
from rich.layout import Layout
from rich.text import Text
RICH_AVAILABLE = True
except ImportError:
RICH_AVAILABLE = False
try:
import paramiko
PARAMIKO_AVAILABLE = True
except ImportError:
PARAMIKO_AVAILABLE = False
try:
import httpx
HTTPX_AVAILABLE = True
except ImportError:
HTTPX_AVAILABLE = False
# ============================================================================
# Configuration
# ============================================================================
VERSION = "1.2.0"
CONFIG_DIR = Path.home() / ".secubox-frontend"
DEVICES_FILE = CONFIG_DIR / "devices.json"
SETTINGS_FILE = CONFIG_DIR / "settings.json"
LOG_FILE = CONFIG_DIR / "frontend.log"
NODE_ID_FILE = CONFIG_DIR / "node.id"
CONSOLE_PORT = 7332
# ============================================================================
# Data Models
# ============================================================================
@dataclass
class Device:
"""SecuBox device"""
name: str
host: str
port: int = 22
user: str = "root"
status: str = "unknown"
version: str = ""
uptime: str = ""
memory_used: int = 0
memory_total: int = 0
disk_used: int = 0
disk_total: int = 0
services: Dict = field(default_factory=dict)
mesh_id: str = ""
mesh_peers: int = 0
alerts: List = field(default_factory=list)
last_check: float = 0
@property
def memory_percent(self) -> int:
if self.memory_total > 0:
return int(self.memory_used / self.memory_total * 100)
return 0
@property
def disk_percent(self) -> int:
if self.disk_total > 0:
return int(self.disk_used / self.disk_total * 100)
return 0
@dataclass
class Alert:
"""Security alert"""
timestamp: str
device: str
type: str
message: str
severity: str = "info"
# ============================================================================
# Device Manager
# ============================================================================
class DeviceManager:
"""Manages SecuBox devices"""
def __init__(self):
self.devices: Dict[str, Device] = {}
self.alerts: List[Alert] = []
self._ssh_cache = {}
self._services_cache: Dict[str, tuple] = {} # {host: (timestamp, services)}
self._services_cache_ttl = 60 # Cache services for 60 seconds
self._executor = ThreadPoolExecutor(max_workers=10)
self._init_config()
self._init_node_identity()
self._load_devices()
def _init_config(self):
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
def _init_node_identity(self):
"""Initialize console's mesh node identity"""
import socket
import uuid
if NODE_ID_FILE.exists():
self.node_id = NODE_ID_FILE.read_text().strip()
else:
hostname = socket.gethostname()
mac = uuid.getnode()
self.node_id = f"console-{mac:012x}"[:20]
NODE_ID_FILE.write_text(self.node_id)
self.node_name = os.environ.get("SECUBOX_CONSOLE_NAME", f"console@{socket.gethostname()}")
def get_local_ip(self) -> str:
"""Get local IP for mesh announcement"""
import socket
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
return ip
except:
return "127.0.0.1"
def announce_to_mesh(self) -> int:
"""Register this console as a peer on mesh devices"""
local_ip = self.get_local_ip()
peer_data = {
"id": self.node_id,
"name": self.node_name,
"address": local_ip,
"port": CONSOLE_PORT,
"type": "console",
"status": "online",
"version": VERSION
}
registered = 0
for dev in self.devices.values():
if not dev.mesh_id or dev.status != "online":
continue
if HTTPX_AVAILABLE:
try:
r = httpx.post(
f"http://{dev.host}:7331/api/peers",
json=peer_data,
timeout=3
)
if r.status_code in (200, 201):
registered += 1
except:
# Fallback to SSH registration
try:
cmd = f"/usr/sbin/secubox-p2p add-peer {local_ip} \"{self.node_name}\""
out, err, code = self.ssh_exec(dev, cmd)
if code == 0:
registered += 1
except:
pass
return registered
def _load_devices(self):
if DEVICES_FILE.exists():
try:
data = json.loads(DEVICES_FILE.read_text())
for name, d in data.get("devices", {}).items():
self.devices[name] = Device(
name=name,
host=d.get("host", ""),
port=d.get("port", 22),
user=d.get("user", "root"),
mesh_id=d.get("mesh_id", "")
)
except Exception as e:
self.log(f"Load error: {e}")
def save_devices(self):
data = {"devices": {}}
for name, dev in self.devices.items():
data["devices"][name] = {
"host": dev.host,
"port": dev.port,
"user": dev.user,
"mesh_id": dev.mesh_id
}
DEVICES_FILE.write_text(json.dumps(data, indent=2))
def log(self, msg: str):
timestamp = datetime.now().isoformat()
with open(LOG_FILE, "a") as f:
f.write(f"[{timestamp}] {msg}\n")
def add_device(self, name: str, host: str, port: int = 22, user: str = "root"):
self.devices[name] = Device(name=name, host=host, port=port, user=user)
self.save_devices()
def remove_device(self, name: str):
if name in self.devices:
del self.devices[name]
self.save_devices()
def get_ssh(self, device: Device):
"""Get SSH connection"""
if not PARAMIKO_AVAILABLE:
return None
key = f"{device.host}:{device.port}"
if key in self._ssh_cache:
ssh = self._ssh_cache[key]
if ssh.get_transport() and ssh.get_transport().is_active():
return ssh
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
ssh.connect(
device.host,
port=device.port,
username=device.user,
timeout=5,
look_for_keys=True,
allow_agent=True
)
self._ssh_cache[key] = ssh
return ssh
except Exception as e:
self.log(f"SSH error {device.name}: {e}")
return None
def ssh_exec(self, device: Device, cmd: str) -> tuple:
"""Execute SSH command"""
ssh = self.get_ssh(device)
if not ssh:
return "", "Connection failed", 1
try:
stdin, stdout, stderr = ssh.exec_command(cmd, timeout=30)
code = stdout.channel.recv_exit_status()
return stdout.read().decode(), stderr.read().decode(), code
except Exception as e:
return "", str(e), 1
def refresh_device(self, device: Device):
"""Refresh device status"""
# Version & uptime
out, err, code = self.ssh_exec(device, """
cat /etc/secubox-version 2>/dev/null || echo unknown
uptime -p 2>/dev/null || uptime | cut -d',' -f1
free -m | awk '/Mem:/{print $2,$3}'
df -m / | awk 'NR==2{print $2,$3}'
cat /srv/secubox/mesh/node.id 2>/dev/null || echo ""
cat /srv/secubox/mesh/peers.json 2>/dev/null | grep -c '"addr"' || echo 0
""")
if code == 0:
lines = out.strip().split("\n")
device.status = "online"
device.version = lines[0] if len(lines) > 0 else ""
device.uptime = lines[1] if len(lines) > 1 else ""
if len(lines) > 2:
mem = lines[2].split()
if len(mem) >= 2:
device.memory_total = int(mem[0])
device.memory_used = int(mem[1])
if len(lines) > 3:
disk = lines[3].split()
if len(disk) >= 2:
device.disk_total = int(disk[0])
device.disk_used = int(disk[1])
device.mesh_id = lines[4] if len(lines) > 4 else ""
device.mesh_peers = int(lines[5]) if len(lines) > 5 and lines[5].isdigit() else 0
else:
device.status = "offline"
device.last_check = time.time()
# Check services
out, err, code = self.ssh_exec(device, """
pgrep haproxy >/dev/null && echo haproxy:running || echo haproxy:stopped
pgrep crowdsec >/dev/null && echo crowdsec:running || echo crowdsec:stopped
pgrep mitmproxy >/dev/null && echo mitmproxy:running || echo mitmproxy:stopped
""")
if code == 0:
for line in out.strip().split("\n"):
if ":" in line:
svc, status = line.split(":", 1)
device.services[svc] = status
return device
def refresh_all(self):
"""Refresh all devices in parallel"""
futures = []
for dev in self.devices.values():
futures.append(self._executor.submit(self.refresh_device, dev))
for f in futures:
try:
f.result(timeout=10)
except:
pass
def get_alerts(self, device: Device) -> List[Alert]:
"""Fetch security alerts from device"""
out, err, code = self.ssh_exec(device, "cat /tmp/secubox-mitm-alerts.json 2>/dev/null")
alerts = []
if code == 0 and out.strip():
try:
data = json.loads(out)
for a in data[-10:]: # Last 10
alerts.append(Alert(
timestamp=a.get("time", ""),
device=device.name,
type=a.get("type", ""),
message=f"{a.get('ip', '')} - {a.get('path', '')}",
severity="warning" if a.get("type") == "scan" else "info"
))
except:
pass
return alerts
def create_backup(self, device: Device, name: str = None) -> bool:
"""Create backup on device"""
name = name or f"backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
out, err, code = self.ssh_exec(device, f"secubox-recover snapshot {name}")
return code == 0
def sync_mesh(self, device: Device) -> bool:
"""Sync mesh on device"""
out, err, code = self.ssh_exec(device, "secubox-mesh sync")
return code == 0
def get_mesh_peers(self, device: Device) -> List[dict]:
"""Get mesh peers from device via P2P API"""
if not HTTPX_AVAILABLE:
return []
try:
r = httpx.get(f"http://{device.host}:7331/api/peers", timeout=2)
if r.status_code == 200:
return r.json().get("peers", [])
except:
pass
return []
def get_mesh_status(self, device: Device) -> dict:
"""Get mesh node status from device via P2P API"""
if not HTTPX_AVAILABLE:
return {}
try:
r = httpx.get(f"http://{device.host}:7331/api/status", timeout=2)
if r.status_code == 200:
return r.json()
except:
pass
return {}
def get_peer_services(self, device: Device, force_refresh: bool = False) -> List[dict]:
"""Get services running on a peer via P2P API or SSH (cached)"""
services = []
cache_key = device.host
# Check cache first (unless force refresh)
if not force_refresh and cache_key in self._services_cache:
cached_time, cached_services = self._services_cache[cache_key]
if time.time() - cached_time < self._services_cache_ttl:
return cached_services
# Try P2P API first (uses /services not /api/services on port 7331)
if HTTPX_AVAILABLE:
try:
r = httpx.get(f"http://{device.host}:7331/services", timeout=15)
if r.status_code == 200:
data = r.json()
services = data.get("services", [])
self._services_cache[cache_key] = (time.time(), services)
return services
except:
pass
# Fallback to SSH
out, err, code = self.ssh_exec(device, "/usr/sbin/secubox-p2p services 2>/dev/null")
if code == 0 and out.strip():
try:
data = json.loads(out)
services = data.get("services", [])
self._services_cache[cache_key] = (time.time(), services)
return services
except:
pass
return services
def get_all_peer_services(self) -> Dict[str, List[dict]]:
"""Get services from all peers (tries all devices)"""
all_services = {}
for name, dev in self.devices.items():
# Try all devices - the API call will fail gracefully if offline
services = self.get_peer_services(dev)
if services:
all_services[name] = services
return all_services
# ============================================================================
# Textual TUI App (Modern)
# ============================================================================
if TEXTUAL_AVAILABLE:
class AddDeviceScreen(ModalScreen):
"""Modal screen for adding a new device"""
CSS = """
AddDeviceScreen {
align: center middle;
}
#add-dialog {
width: 60;
height: auto;
background: $surface;
border: solid $primary;
padding: 1 2;
}
#add-dialog Label {
margin-bottom: 1;
}
#add-dialog Input {
margin-bottom: 1;
}
#add-buttons {
layout: horizontal;
height: 3;
margin-top: 1;
}
#add-buttons Button {
margin-right: 1;
}
"""
def compose(self) -> ComposeResult:
with Vertical(id="add-dialog"):
yield Label("[bold]Add SecuBox Device[/]")
yield Label("Name:")
yield Input(placeholder="my-secubox", id="input-name")
yield Label("Host (IP or hostname):")
yield Input(placeholder="192.168.1.1", id="input-host")
yield Label("SSH Port (default: 22):")
yield Input(placeholder="22", id="input-port")
yield Label("SSH User (default: root):")
yield Input(placeholder="root", id="input-user")
with Horizontal(id="add-buttons"):
yield Button("Add", variant="primary", id="btn-add-confirm")
yield Button("Cancel", id="btn-add-cancel")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-add-cancel":
self.dismiss(None)
elif event.button.id == "btn-add-confirm":
name = self.query_one("#input-name", Input).value.strip()
host = self.query_one("#input-host", Input).value.strip()
port = self.query_one("#input-port", Input).value.strip() or "22"
user = self.query_one("#input-user", Input).value.strip() or "root"
if name and host:
try:
port = int(port)
except ValueError:
port = 22
self.dismiss({"name": name, "host": host, "port": port, "user": user})
else:
self.notify("Name and Host are required", severity="error")
class DiscoverScreen(ModalScreen):
"""Modal screen for discovering devices"""
CSS = """
DiscoverScreen {
align: center middle;
}
#discover-dialog {
width: 70;
height: 20;
background: $surface;
border: solid $primary;
padding: 1 2;
}
#discover-log {
height: 12;
background: $surface-darken-1;
border: solid $accent;
}
"""
def __init__(self, manager: "DeviceManager"):
super().__init__()
self.manager = manager
def compose(self) -> ComposeResult:
with Vertical(id="discover-dialog"):
yield Label("[bold]🔍 Discovering SecuBox Devices[/]")
yield Log(id="discover-log")
with Horizontal():
yield Button("Close", id="btn-discover-close")
def on_mount(self) -> None:
self.run_worker(self._discover, exclusive=True)
async def _discover(self) -> None:
log = self.query_one("#discover-log", Log)
log.write_line("Starting discovery...")
discovered = []
# Phase 1: Query mesh peers
log.write_line("Phase 1: Querying mesh peers...")
for dev in self.manager.devices.values():
if dev.mesh_id:
log.write_line(f" Checking {dev.name}...")
peers = await asyncio.to_thread(self._query_mesh, dev.host)
for peer in peers:
if peer.get("address") and peer["address"] not in discovered:
discovered.append(peer["address"])
log.write_line(f" Found: {peer.get('name', 'unknown')} ({peer['address']})")
# Phase 2: Network scan (simplified)
log.write_line("Phase 2: Quick network scan...")
import socket
for subnet_end in ["192.168.255", "192.168.1"]:
for i in range(1, 20): # Quick scan first 20 IPs
ip = f"{subnet_end}.{i}"
if ip not in discovered:
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(0.3)
if sock.connect_ex((ip, 7331)) == 0:
discovered.append(ip)
log.write_line(f" Found: {ip}")
sock.close()
except:
pass
# Phase 3: Probe and add
log.write_line(f"Phase 3: Probing {len(discovered)} hosts...")
added = 0
for ip in discovered:
result = await asyncio.to_thread(self._probe_host, ip)
if result:
self.manager.add_device(**result)
log.write_line(f" Added: {result['name']}")
added += 1
log.write_line(f"\nDone! Added {added} devices.")
def _query_mesh(self, host: str) -> list:
if not HTTPX_AVAILABLE:
return []
try:
r = httpx.get(f"http://{host}:7331/api/peers", timeout=2)
if r.status_code == 200:
return r.json().get("peers", [])
except:
pass
return []
def _probe_host(self, host: str) -> dict:
if not HTTPX_AVAILABLE:
return None
try:
r = httpx.get(f"http://{host}:7331/api/status", timeout=2)
if r.status_code == 200:
data = r.json()
name = data.get("node_name") or f"secubox-{host.split('.')[-1]}"
return {
"name": name,
"host": host,
"port": 22,
"user": "root"
}
except:
pass
return None
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-discover-close":
self.dismiss(None)
class DeviceWidget(Static):
"""Single device status widget"""
def __init__(self, device: Device, **kwargs):
super().__init__(**kwargs)
self.device = device
def compose(self) -> ComposeResult:
yield Static(self._render())
def _render(self) -> str:
d = self.device
status_icon = "🟢" if d.status == "online" else "🔴"
mesh_icon = "🔗" if d.mesh_id else " "
mem_bar = self._bar(d.memory_percent, 10)
disk_bar = self._bar(d.disk_percent, 10)
services = " ".join([
f"[green]●[/]" if s == "running" else "[red]●[/]"
for s in d.services.values()
][:3])
return f"""[bold]{status_icon} {d.name}[/] {mesh_icon}
[dim]{d.host}[/] | {d.version or 'unknown'}
Mem: {mem_bar} {d.memory_percent}%
Disk: {disk_bar} {d.disk_percent}%
Services: {services}"""
def _bar(self, pct: int, width: int) -> str:
filled = int(pct / 100 * width)
return f"[green]{'' * filled}[/][dim]{'' * (width - filled)}[/]"
class SecuBoxApp(App):
"""SecuBox Frontend TUI Application"""
CSS = """
Screen {
background: $surface;
}
#header-bar {
dock: top;
height: 3;
background: $primary;
padding: 1;
}
#main-container {
layout: horizontal;
}
#sidebar {
width: 30;
background: $surface-darken-1;
border-right: solid $primary;
}
#content {
width: 1fr;
}
.device-card {
margin: 1;
padding: 1;
background: $surface-darken-2;
border: solid $primary;
height: auto;
}
.device-card:hover {
border: solid $secondary;
}
#actions {
dock: bottom;
height: 5;
background: $surface-darken-1;
layout: horizontal;
padding: 1;
}
Button {
margin: 0 1;
}
#alerts-log {
height: 10;
background: $surface-darken-2;
border: solid $warning;
}
.status-online {
color: $success;
}
.status-offline {
color: $error;
}
"""
BINDINGS = [
("q", "quit", "Quit"),
("r", "refresh", "Refresh"),
("b", "backup", "Backup"),
("s", "sync", "Sync"),
("a", "add_device", "Add Device"),
("f", "discover", "Find Devices"),
("d", "delete_device", "Delete Device"),
("c", "connect_ssh", "SSH Connect"),
("m", "announce", "Announce to Mesh"),
]
def __init__(self, manager: DeviceManager):
super().__init__()
self.manager = manager
self.selected_device: Optional[str] = None
def compose(self) -> ComposeResult:
yield Header()
with Container(id="main-container"):
with Vertical(id="sidebar"):
yield Static("[bold]📡 Devices[/]", id="sidebar-title")
yield ScrollableContainer(id="device-list")
with Vertical(id="content"):
with TabbedContent():
with TabPane("Dashboard", id="tab-dashboard"):
yield Static(id="dashboard-content")
with TabPane("Alerts", id="tab-alerts"):
yield Log(id="alerts-log")
with TabPane("Mesh", id="tab-mesh"):
yield Static(id="mesh-content")
with TabPane("Services", id="tab-services"):
yield ScrollableContainer(Static(id="services-content"))
with Horizontal(id="actions"):
yield Button("🔄 Refresh", id="btn-refresh", variant="primary")
yield Button("🔍 Find", id="btn-discover")
yield Button(" Add", id="btn-add")
yield Button("📢 Announce", id="btn-announce")
yield Button("🔗 Sync", id="btn-sync")
yield Button("🖥️ SSH", id="btn-ssh")
yield Footer()
def on_mount(self) -> None:
self.title = f"SecuBox Frontend v{VERSION}"
self.sub_title = f"{len(self.manager.devices)} devices"
self._refresh_devices()
self.set_interval(30, self._refresh_devices)
def _refresh_devices(self) -> None:
"""Refresh all device data"""
self.manager.refresh_all()
self._update_device_list()
self._update_dashboard()
self._update_alerts()
self._update_mesh()
self._update_services()
def _update_device_list(self) -> None:
"""Update sidebar device list"""
container = self.query_one("#device-list", ScrollableContainer)
container.remove_children()
for name, dev in self.manager.devices.items():
status = "🟢" if dev.status == "online" else "🔴"
mesh = "🔗" if dev.mesh_id else ""
btn = Button(
f"{status} {name} {mesh}",
id=f"dev-{name}",
classes="device-btn"
)
container.mount(btn)
def _update_dashboard(self) -> None:
"""Update main dashboard"""
content = self.query_one("#dashboard-content", Static)
lines = ["[bold cyan]═══ SecuBox Dashboard ═══[/]\n"]
online = sum(1 for d in self.manager.devices.values() if d.status == "online")
total = len(self.manager.devices)
lines.append(f"Devices: [green]{online}[/]/{total} online\n")
for name, dev in self.manager.devices.items():
status = "[green]●[/]" if dev.status == "online" else "[red]●[/]"
mesh = "[blue]🔗[/]" if dev.mesh_id else " "
mem_pct = dev.memory_percent
disk_pct = dev.disk_percent
mem_bar = self._make_bar(mem_pct)
disk_bar = self._make_bar(disk_pct)
svcs = " ".join([
"[green]●[/]" if v == "running" else "[red]●[/]"
for v in list(dev.services.values())[:3]
])
lines.append(f"""
{status} [bold]{name}[/] {mesh} [{dev.host}]
Version: {dev.version or '-'} | Up: {dev.uptime or '-'}
Mem: {mem_bar} {mem_pct}% | Disk: {disk_bar} {disk_pct}%
Services: {svcs}
Mesh: {dev.mesh_peers} peers""")
content.update("\n".join(lines))
def _make_bar(self, pct: int, width: int = 10) -> str:
filled = int(pct / 100 * width)
color = "green" if pct < 70 else "yellow" if pct < 90 else "red"
return f"[{color}]{'' * filled}[/][dim]{'' * (width - filled)}[/]"
def _update_alerts(self) -> None:
"""Update alerts log"""
log = self.query_one("#alerts-log", Log)
for dev in self.manager.devices.values():
if dev.status != "online":
continue
alerts = self.manager.get_alerts(dev)
for alert in alerts:
severity_color = "yellow" if alert.severity == "warning" else "blue"
log.write_line(
f"[{severity_color}][{alert.type}][/] {alert.device}: {alert.message}"
)
def _update_mesh(self) -> None:
"""Update mesh visualization tab"""
content = self.query_one("#mesh-content", Static)
lines = ["[bold cyan]═══ Mesh Network ═══[/]\n"]
# Show console's own identity
lines.append("[bold yellow]This Console:[/]")
lines.append(f" ID: {self.manager.node_id}")
lines.append(f" Name: {self.manager.node_name}")
lines.append(f" IP: {self.manager.get_local_ip()}")
lines.append(f" Port: {CONSOLE_PORT}")
lines.append("")
# Count mesh-enabled devices
mesh_devices = [d for d in self.manager.devices.values() if d.mesh_id]
online_mesh = [d for d in mesh_devices if d.status == "online"]
lines.append(f"[bold]SecuBox Nodes:[/] [green]{len(online_mesh)}[/]/{len(mesh_devices)} online\n")
if not mesh_devices:
lines.append("[dim]No mesh-enabled devices found.[/]")
lines.append("\nTo enable mesh on a device:")
lines.append(" 1. Install secubox-p2p on the device")
lines.append(" 2. Configure via: uci set secubox-p2p.main.enabled=1")
lines.append(" 3. Start daemon: /etc/init.d/secubox-p2p start")
else:
lines.append("[bold]Connected Mesh Peers:[/]\n")
for dev in mesh_devices:
status_icon = "[green]●[/]" if dev.status == "online" else "[red]●[/]"
local_tag = "[yellow](local)[/] " if "local" in dev.mesh_id.lower() else ""
lines.append(f" {status_icon} {local_tag}[bold]{dev.name}[/]")
lines.append(f" ID: {dev.mesh_id}")
lines.append(f" Host: {dev.host}")
lines.append(f" Peers: {dev.mesh_peers}")
lines.append("")
# Fetch remote peers from first online mesh device
if online_mesh and HTTPX_AVAILABLE:
try:
r = httpx.get(f"http://{online_mesh[0].host}:7331/api/peers", timeout=2)
if r.status_code == 200:
data = r.json()
remote_peers = [p for p in data.get("peers", []) if not p.get("is_local")]
if remote_peers:
lines.append("[bold]Remote Peers (from mesh):[/]")
for peer in remote_peers[:10]: # Show max 10
status = "[green]●[/]" if peer.get("status") == "online" else "[red]●[/]"
lines.append(f" {status} {peer.get('name', 'unknown')} ({peer.get('address', '?')})")
except:
pass
content.update("\n".join(lines))
def _update_services(self) -> None:
"""Update services tab with peer services"""
content = self.query_one("#services-content", Static)
lines = ["[bold cyan]═══ Peer Services ═══[/]\n"]
# Get services from all peers
all_services = self.manager.get_all_peer_services()
if not all_services:
lines.append("[dim]No services data available.[/]")
lines.append("\nServices will appear when peers are online")
lines.append("and running secubox-p2p daemon.")
else:
for peer_name, services in all_services.items():
# Count running services
running = sum(1 for s in services if s.get("status") == "running")
total = len(services)
lines.append(f"[bold yellow]📦 {peer_name}[/] ({running}/{total} running)")
lines.append("")
# Group services by category
web_services = []
security_services = []
ai_services = []
container_services = []
app_services = []
for svc in services:
name = svc.get("name", "")
status = svc.get("status", "stopped")
port = svc.get("port", "")
# Categorize more comprehensively
if name in ("haproxy", "nginx", "uhttpd", "squid", "cdn-cache", "vhost-manager"):
web_services.append(svc)
elif name in ("crowdsec", "crowdsec-firewall-bouncer", "firewall", "tor", "tor-shield", "mitmproxy", "adguardhome"):
security_services.append(svc)
elif name in ("localai", "ollama", "streamlit"):
ai_services.append(svc)
elif "lxc" in name or "docker" in name or "container" in name:
container_services.append(svc)
elif status == "running" and port:
app_services.append(svc)
# Display categories
if web_services:
lines.append(" [bold blue]Web/Proxy:[/]")
for svc in web_services:
icon = "[green]●[/]" if svc["status"] == "running" else "[red]●[/]"
port_info = f" :{svc['port']}" if svc.get("port") else ""
lines.append(f" {icon} {svc['name']}{port_info}")
if security_services:
lines.append(" [bold red]Security:[/]")
for svc in security_services:
icon = "[green]●[/]" if svc["status"] == "running" else "[red]●[/]"
port_info = f" :{svc['port']}" if svc.get("port") else ""
lines.append(f" {icon} {svc['name']}{port_info}")
if ai_services:
lines.append(" [bold magenta]AI/ML:[/]")
for svc in ai_services:
icon = "[green]●[/]" if svc["status"] == "running" else "[red]●[/]"
port_info = f" :{svc['port']}" if svc.get("port") else ""
lines.append(f" {icon} {svc['name']}{port_info}")
if container_services:
lines.append(" [bold cyan]Containers:[/]")
for svc in container_services[:5]:
icon = "[green]●[/]" if svc["status"] == "running" else "[red]●[/]"
lines.append(f" {icon} {svc['name']}")
if app_services:
lines.append(" [bold green]Applications:[/]")
for svc in app_services[:8]:
icon = "[green]●[/]" if svc["status"] == "running" else "[red]●[/]"
port_info = f" :{svc['port']}" if svc.get("port") else ""
lines.append(f" {icon} {svc['name']}{port_info}")
if len(app_services) > 8:
lines.append(f" [dim]... and {len(app_services) - 8} more[/]")
lines.append("")
content.update("\n".join(lines))
def on_button_pressed(self, event: Button.Pressed) -> None:
btn_id = event.button.id
if btn_id == "btn-refresh":
self.action_refresh()
elif btn_id == "btn-discover":
self.action_discover()
elif btn_id == "btn-announce":
self.action_announce()
elif btn_id == "btn-backup":
self.action_backup()
elif btn_id == "btn-sync":
self.action_sync()
elif btn_id == "btn-add":
self.action_add_device()
elif btn_id == "btn-ssh":
self.action_connect_ssh()
elif btn_id and btn_id.startswith("dev-"):
self.selected_device = btn_id[4:]
self.notify(f"Selected: {self.selected_device}")
def action_refresh(self) -> None:
self.notify("Refreshing...")
self._refresh_devices()
self.notify("Refreshed!", severity="information")
def action_backup(self) -> None:
if not self.selected_device:
self.notify("Select a device first", severity="warning")
return
dev = self.manager.devices.get(self.selected_device)
if dev:
self.notify(f"Creating backup on {dev.name}...")
if self.manager.create_backup(dev):
self.notify("Backup created!", severity="information")
else:
self.notify("Backup failed!", severity="error")
def action_sync(self) -> None:
self.notify("Syncing mesh...")
for dev in self.manager.devices.values():
if dev.status == "online" and dev.mesh_id:
self.manager.sync_mesh(dev)
# Re-announce console to mesh
self.manager.announce_to_mesh()
self.notify("Mesh synced!", severity="information")
def action_add_device(self) -> None:
"""Show add device dialog"""
def handle_add(result: dict) -> None:
if result:
self.manager.add_device(
result["name"],
result["host"],
result["port"],
result["user"]
)
self.notify(f"Added: {result['name']}", severity="information")
self._refresh_devices()
self.push_screen(AddDeviceScreen(), handle_add)
def action_discover(self) -> None:
"""Show discover devices dialog"""
def handle_discover(result) -> None:
self._refresh_devices()
self.push_screen(DiscoverScreen(self.manager), handle_discover)
def action_delete_device(self) -> None:
"""Delete selected device"""
if not self.selected_device:
self.notify("Select a device first", severity="warning")
return
self.manager.remove_device(self.selected_device)
self.notify(f"Removed: {self.selected_device}", severity="information")
self.selected_device = None
self._refresh_devices()
def action_connect_ssh(self) -> None:
"""SSH to selected device"""
if self.selected_device:
dev = self.manager.devices.get(self.selected_device)
if dev:
# Suspend app, run SSH, then resume
self.suspend()
os.system(f"ssh {dev.user}@{dev.host} -p {dev.port}")
self.resume()
else:
self.notify("Select a device first", severity="warning")
def action_announce(self) -> None:
"""Announce this console as a mesh peer"""
self.notify("Announcing to mesh...")
registered = self.manager.announce_to_mesh()
if registered:
self.notify(f"Registered on {registered} device(s)", severity="information")
else:
self.notify("No mesh devices available", severity="warning")
self._update_mesh()
# ============================================================================
# Rich Fallback TUI
# ============================================================================
class RichFallbackApp:
"""Fallback TUI using Rich (when Textual not available)"""
def __init__(self, manager: DeviceManager):
self.manager = manager
self.console = Console()
self.running = True
def make_layout(self) -> Layout:
layout = Layout()
layout.split_column(
Layout(name="header", size=3),
Layout(name="main"),
Layout(name="footer", size=3)
)
layout["main"].split_row(
Layout(name="devices", ratio=1),
Layout(name="details", ratio=2)
)
return layout
def make_header(self) -> Panel:
online = sum(1 for d in self.manager.devices.values() if d.status == "online")
return Panel(
f"[bold cyan]SecuBox Frontend[/] v{VERSION} | "
f"Devices: [green]{online}[/]/{len(self.manager.devices)}",
style="cyan"
)
def make_devices_table(self) -> Table:
table = Table(title="Devices", expand=True)
table.add_column("Status", width=3)
table.add_column("Name")
table.add_column("Host")
table.add_column("Mem")
table.add_column("Mesh")
for name, dev in self.manager.devices.items():
status = "[green]●[/]" if dev.status == "online" else "[red]●[/]"
mesh = f"🔗{dev.mesh_peers}" if dev.mesh_id else "-"
table.add_row(
status,
name,
dev.host,
f"{dev.memory_percent}%",
mesh
)
return table
def make_footer(self) -> Panel:
return Panel(
"[dim]r: refresh | b: backup | s: sync | q: quit[/]",
style="dim"
)
def run(self):
self.manager.refresh_all()
with Live(self.make_layout(), refresh_per_second=1, console=self.console) as live:
import select
import termios
import tty
old = termios.tcgetattr(sys.stdin)
try:
tty.setcbreak(sys.stdin.fileno())
while self.running:
layout = self.make_layout()
layout["header"].update(self.make_header())
layout["devices"].update(self.make_devices_table())
layout["footer"].update(self.make_footer())
live.update(layout)
if select.select([sys.stdin], [], [], 0.5)[0]:
key = sys.stdin.read(1)
if key == 'q':
self.running = False
elif key == 'r':
self.manager.refresh_all()
elif key == 's':
for d in self.manager.devices.values():
if d.mesh_id:
self.manager.sync_mesh(d)
finally:
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old)
# ============================================================================
# Simple CLI Fallback
# ============================================================================
class SimpleCLI:
"""Simple CLI when no TUI available"""
def __init__(self, manager: DeviceManager):
self.manager = manager
def run(self):
print(f"SecuBox Frontend v{VERSION}")
print("=" * 40)
self.manager.refresh_all()
for name, dev in self.manager.devices.items():
status = "ONLINE" if dev.status == "online" else "OFFLINE"
print(f"\n[{status}] {name} ({dev.host})")
print(f" Version: {dev.version}")
print(f" Memory: {dev.memory_percent}%")
print(f" Disk: {dev.disk_percent}%")
print(f" Mesh: {dev.mesh_id or 'disabled'} ({dev.mesh_peers} peers)")
# ============================================================================
# Main Entry Point
# ============================================================================
def main():
import argparse
parser = argparse.ArgumentParser(description="SecuBox Frontend")
parser.add_argument("--host", help="Quick connect to host")
parser.add_argument("--add", nargs=2, metavar=("NAME", "HOST"), help="Add device")
parser.add_argument("--remove", metavar="NAME", help="Remove device")
parser.add_argument("--list", action="store_true", help="List devices")
parser.add_argument("--simple", action="store_true", help="Simple CLI mode")
parser.add_argument("--version", action="store_true", help="Show version")
args = parser.parse_args()
if args.version:
print(f"SecuBox Frontend v{VERSION}")
return
manager = DeviceManager()
# Quick add host
if args.host:
name = f"quick-{args.host.split('.')[-1]}"
manager.add_device(name, args.host)
print(f"Added: {name} ({args.host})")
if args.add:
manager.add_device(args.add[0], args.add[1])
print(f"Added: {args.add[0]} ({args.add[1]})")
return
if args.remove:
manager.remove_device(args.remove)
print(f"Removed: {args.remove}")
return
if args.list:
for name, dev in manager.devices.items():
print(f"{name}: {dev.host}")
return
# Launch TUI
if args.simple or (not TEXTUAL_AVAILABLE and not RICH_AVAILABLE):
SimpleCLI(manager).run()
elif TEXTUAL_AVAILABLE:
app = SecuBoxApp(manager)
app.run()
elif RICH_AVAILABLE:
RichFallbackApp(manager).run()
else:
SimpleCLI(manager).run()
if __name__ == "__main__":
main()