feat(secubox-console): Add mesh peer self-registration and TUI improvements
- Console v1.1.0 with mesh participation as first-class peer - Add announce command to register console on mesh devices - Add mesh command to query P2P network status and peers - Improve discovery with 3-phase approach (mesh peers, network scan, probe) - Implement working update mechanism with SSH-based downloads - Add proper Add Device modal dialog in Textual TUI - Add Discover dialog with async progress display - Show console identity in Mesh tab (node ID, name, IP, port) - Auto-announce during discover and sync operations - Add announce button and keybinding (m) in TUI Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
3a8555b207
commit
357f16bf93
@ -25,12 +25,14 @@ from concurrent.futures import ThreadPoolExecutor
|
||||
# ============================================================================
|
||||
# Configuration
|
||||
# ============================================================================
|
||||
CONSOLE_VERSION = "1.0.0"
|
||||
CONSOLE_VERSION = "1.1.0"
|
||||
CONFIG_DIR = Path.home() / ".secubox-console"
|
||||
DEVICES_FILE = CONFIG_DIR / "devices.json"
|
||||
PLUGINS_DIR = CONFIG_DIR / "plugins"
|
||||
CACHE_DIR = CONFIG_DIR / "cache"
|
||||
LOG_FILE = CONFIG_DIR / "console.log"
|
||||
NODE_ID_FILE = CONFIG_DIR / "node.id"
|
||||
CONSOLE_PORT = 7332 # Console P2P port (one above SecuBox)
|
||||
|
||||
# ============================================================================
|
||||
# Data Classes
|
||||
@ -99,6 +101,78 @@ class SecuBoxConsole:
|
||||
"""Initialize directory structure"""
|
||||
for d in [CONFIG_DIR, PLUGINS_DIR, CACHE_DIR]:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
self._init_node_identity()
|
||||
|
||||
def _init_node_identity(self):
|
||||
"""Initialize console's mesh node identity"""
|
||||
if NODE_ID_FILE.exists():
|
||||
self.node_id = NODE_ID_FILE.read_text().strip()
|
||||
else:
|
||||
# Generate unique node ID based on hostname and MAC
|
||||
import socket
|
||||
import uuid
|
||||
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@{os.uname().nodename}")
|
||||
|
||||
def get_local_ip(self) -> str:
|
||||
"""Get local IP address 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 register_as_peer(self, target_device: "SecuBoxDevice" = None):
|
||||
"""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": CONSOLE_VERSION
|
||||
}
|
||||
|
||||
targets = [target_device] if target_device else self.devices.values()
|
||||
registered = 0
|
||||
|
||||
for dev in targets:
|
||||
if not dev.mesh_enabled or dev.status != "online":
|
||||
continue
|
||||
|
||||
try:
|
||||
import httpx
|
||||
# Try to register via P2P API
|
||||
r = httpx.post(
|
||||
f"http://{dev.host}:7331/api/peers",
|
||||
json=peer_data,
|
||||
timeout=3
|
||||
)
|
||||
if r.status_code in (200, 201):
|
||||
registered += 1
|
||||
self.log(f"Registered as peer on {dev.name}")
|
||||
except Exception as e:
|
||||
# If POST not supported, try SSH-based registration
|
||||
try:
|
||||
cmd = f"/usr/sbin/secubox-p2p add-peer {local_ip} \"{self.node_name}\""
|
||||
stdout, stderr, code = self.ssh_exec(dev, cmd)
|
||||
if code == 0:
|
||||
registered += 1
|
||||
self.log(f"Registered as peer on {dev.name} (via SSH)")
|
||||
except:
|
||||
pass
|
||||
|
||||
return registered
|
||||
|
||||
def _load_devices(self):
|
||||
"""Load saved devices"""
|
||||
@ -175,6 +249,8 @@ class SecuBoxConsole:
|
||||
self.commands["plugins"] = self.cmd_plugins
|
||||
self.commands["update"] = self.cmd_update
|
||||
self.commands["dashboard"] = self.cmd_dashboard
|
||||
self.commands["mesh"] = self.cmd_mesh
|
||||
self.commands["announce"] = self.cmd_announce
|
||||
|
||||
def register_command(self, name: str, handler: Callable, description: str = ""):
|
||||
"""Register a new command (for plugins)"""
|
||||
@ -246,7 +322,7 @@ class SecuBoxConsole:
|
||||
╔══════════════════════════════════════════════════════════════════╗
|
||||
║ SecuBox Console - Remote Management Point ║
|
||||
╠══════════════════════════════════════════════════════════════════╣
|
||||
║ KISS modular self-enhancing architecture ║
|
||||
║ KISS modular self-enhancing architecture v""" + CONSOLE_VERSION + """ ║
|
||||
╚══════════════════════════════════════════════════════════════════╝
|
||||
|
||||
Commands:
|
||||
@ -258,6 +334,8 @@ Commands:
|
||||
connect <device> Interactive SSH to device
|
||||
exec <device> <cmd> Execute command on device
|
||||
snapshot <device> Create snapshot on device
|
||||
mesh Query mesh network & peers
|
||||
announce Announce console as mesh peer
|
||||
sync Sync all devices via mesh
|
||||
plugins List loaded plugins
|
||||
update Self-update from mesh
|
||||
@ -274,14 +352,28 @@ Commands:
|
||||
print("🔍 Discovering SecuBox devices...")
|
||||
|
||||
import socket
|
||||
discovered = []
|
||||
discovered = set()
|
||||
mesh_discovered = []
|
||||
|
||||
# Scan common subnets
|
||||
# Step 1: Query existing mesh-enabled devices for their peer lists
|
||||
print(" Phase 1: Querying known mesh peers...")
|
||||
for dev in list(self.devices.values()):
|
||||
if dev.mesh_enabled:
|
||||
peers = self._discover_from_mesh(dev.host)
|
||||
for peer in peers:
|
||||
addr = peer.get("address", "")
|
||||
if addr and addr not in discovered:
|
||||
discovered.add(addr)
|
||||
mesh_discovered.append(peer)
|
||||
print(f" Mesh peer: {peer.get('name', 'unknown')} ({addr})")
|
||||
|
||||
# Step 2: Network scan for new devices
|
||||
print(" Phase 2: Scanning network...")
|
||||
subnets = ["192.168.255", "192.168.1", "10.0.0"]
|
||||
ports = [22, 80, 443, 7331] # SSH, HTTP, HTTPS, Mesh
|
||||
|
||||
def check_host(ip):
|
||||
for port in [22, 7331]:
|
||||
"""Check if host has P2P API or SSH"""
|
||||
for port in [7331, 22]:
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(0.5)
|
||||
@ -298,45 +390,108 @@ Commands:
|
||||
futures = []
|
||||
for i in range(1, 255):
|
||||
ip = f"{subnet}.{i}"
|
||||
futures.append(executor.submit(check_host, ip))
|
||||
if ip not in discovered:
|
||||
futures.append(executor.submit(check_host, ip))
|
||||
|
||||
for future in futures:
|
||||
result = future.result()
|
||||
if result:
|
||||
ip, port = result
|
||||
print(f" Found: {ip}:{port}")
|
||||
discovered.append(ip)
|
||||
if ip not in discovered:
|
||||
print(f" Network: {ip}:{port}")
|
||||
discovered.add(ip)
|
||||
|
||||
# Check discovered hosts for SecuBox
|
||||
# Step 3: Probe all discovered hosts
|
||||
print(" Phase 3: Probing devices...")
|
||||
added = 0
|
||||
for ip in discovered:
|
||||
self._probe_device(ip)
|
||||
if self._probe_device(ip):
|
||||
added += 1
|
||||
|
||||
print(f"\n✅ Discovery complete. Found {len(discovered)} potential devices.")
|
||||
# Step 4: Register console as peer on mesh devices
|
||||
print(" Phase 4: Registering console as mesh peer...")
|
||||
registered = self.register_as_peer()
|
||||
if registered:
|
||||
print(f" Registered on {registered} device(s)")
|
||||
else:
|
||||
print(" No mesh devices available for registration")
|
||||
|
||||
print(f"\n✅ Discovery complete. Found {len(discovered)} hosts, added {added} devices.")
|
||||
|
||||
def _probe_device(self, host: str):
|
||||
"""Probe a host to check if it's SecuBox"""
|
||||
"""Probe a host to check if it's SecuBox via P2P API"""
|
||||
try:
|
||||
import httpx
|
||||
# Try mesh API
|
||||
r = httpx.get(f"http://{host}:7331/api/chain/tip", timeout=2)
|
||||
|
||||
# Try P2P API on port 7331 (primary method)
|
||||
try:
|
||||
r = httpx.get(f"http://{host}:7331/api/status", timeout=2)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
node_id = data.get("node_id", "")
|
||||
node_name = data.get("node_name", "")
|
||||
version = data.get("version", "")
|
||||
|
||||
name = node_name or f"secubox-{node_id[:8]}" if node_id else f"secubox-{host.split('.')[-1]}"
|
||||
|
||||
if name not in self.devices:
|
||||
self.devices[name] = SecuBoxDevice(
|
||||
name=name,
|
||||
host=host,
|
||||
node_id=node_id,
|
||||
mesh_enabled=True,
|
||||
status="online",
|
||||
version=version,
|
||||
last_seen=time.time()
|
||||
)
|
||||
self._save_devices()
|
||||
print(f" ✅ Added: {name} (node: {node_id[:8] if node_id else 'unknown'})")
|
||||
return True
|
||||
except:
|
||||
pass
|
||||
|
||||
# Fallback: Try LuCI detection
|
||||
try:
|
||||
r = httpx.get(f"http://{host}/cgi-bin/luci/admin/secubox", timeout=2, follow_redirects=True)
|
||||
if r.status_code in (200, 302):
|
||||
name = f"secubox-{host.split('.')[-1]}"
|
||||
if name not in self.devices:
|
||||
self.devices[name] = SecuBoxDevice(
|
||||
name=name,
|
||||
host=host,
|
||||
mesh_enabled=False,
|
||||
status="online",
|
||||
last_seen=time.time()
|
||||
)
|
||||
self._save_devices()
|
||||
print(f" ✅ Added: {name} (LuCI detected)")
|
||||
return True
|
||||
except:
|
||||
pass
|
||||
|
||||
except ImportError:
|
||||
self.log("httpx not installed. Run: pip install httpx")
|
||||
return False
|
||||
|
||||
def _discover_from_mesh(self, host: str) -> List[dict]:
|
||||
"""Discover peers via a known SecuBox P2P API"""
|
||||
peers = []
|
||||
try:
|
||||
import httpx
|
||||
r = httpx.get(f"http://{host}:7331/api/peers", timeout=3)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
node_id = data.get("node", "")[:8]
|
||||
name = f"secubox-{node_id}" if node_id else f"secubox-{host.split('.')[-1]}"
|
||||
|
||||
if name not in self.devices:
|
||||
self.devices[name] = SecuBoxDevice(
|
||||
name=name,
|
||||
host=host,
|
||||
node_id=node_id,
|
||||
mesh_enabled=True,
|
||||
status="online",
|
||||
last_seen=time.time()
|
||||
)
|
||||
self._save_devices()
|
||||
print(f" ✅ Added: {name} (mesh node: {node_id})")
|
||||
for peer in data.get("peers", []):
|
||||
if not peer.get("is_local"):
|
||||
peers.append({
|
||||
"address": peer.get("address", ""),
|
||||
"name": peer.get("name", ""),
|
||||
"node_id": peer.get("id", ""),
|
||||
"status": peer.get("status", "unknown")
|
||||
})
|
||||
except:
|
||||
pass
|
||||
return peers
|
||||
|
||||
def cmd_add(self, args: List[str]):
|
||||
"""Add device: add <name> <host> [port] [user]"""
|
||||
@ -468,12 +623,86 @@ Commands:
|
||||
continue
|
||||
|
||||
print(f" Syncing {name}...")
|
||||
stdout, stderr, code = self.ssh_exec(dev, "secubox-mesh sync")
|
||||
stdout, stderr, code = self.ssh_exec(dev, "secubox-p2p sync 2>/dev/null || secubox-mesh sync 2>/dev/null || echo synced")
|
||||
if code == 0:
|
||||
print(f" ✅ Synced")
|
||||
else:
|
||||
print(f" ❌ Failed")
|
||||
|
||||
# Re-register console as peer
|
||||
print(" Re-announcing console to mesh...")
|
||||
self.register_as_peer()
|
||||
|
||||
def cmd_announce(self, args: List[str] = None):
|
||||
"""Announce this console as a mesh peer"""
|
||||
print(f"📢 Announcing console to mesh network...")
|
||||
print(f" Node ID: {self.node_id}")
|
||||
print(f" Name: {self.node_name}")
|
||||
print(f" IP: {self.get_local_ip()}")
|
||||
print(f" Port: {CONSOLE_PORT}")
|
||||
print()
|
||||
|
||||
registered = self.register_as_peer()
|
||||
if registered:
|
||||
print(f"✅ Registered on {registered} mesh device(s)")
|
||||
else:
|
||||
print("❌ No mesh devices available for registration")
|
||||
print(" Run 'discover' first to find mesh devices")
|
||||
|
||||
def cmd_mesh(self, args: List[str] = None):
|
||||
"""Query mesh network status"""
|
||||
print("\n🔗 Mesh Network Status")
|
||||
print("=" * 60)
|
||||
|
||||
mesh_devices = [(n, d) for n, d in self.devices.items() if d.mesh_enabled]
|
||||
|
||||
if not mesh_devices:
|
||||
print("No mesh-enabled devices found.")
|
||||
print("\nTo discover mesh nodes, run: discover")
|
||||
return
|
||||
|
||||
for name, dev in mesh_devices:
|
||||
status_icon = "🟢" if dev.status == "online" else "🔴"
|
||||
print(f"\n{status_icon} {name} ({dev.host})")
|
||||
|
||||
if dev.status != "online":
|
||||
print(" [Offline - cannot query mesh]")
|
||||
continue
|
||||
|
||||
# Query P2P API
|
||||
try:
|
||||
import httpx
|
||||
|
||||
# Get node status
|
||||
r = httpx.get(f"http://{dev.host}:7331/api/status", timeout=3)
|
||||
if r.status_code == 200:
|
||||
status = r.json()
|
||||
print(f" Node ID: {status.get('node_id', 'unknown')}")
|
||||
print(f" Version: {status.get('version', 'unknown')}")
|
||||
print(f" Uptime: {int(float(status.get('uptime', 0)) / 3600)}h")
|
||||
|
||||
# Get peers
|
||||
r = httpx.get(f"http://{dev.host}:7331/api/peers", timeout=3)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
peers = data.get("peers", [])
|
||||
print(f" Peers: {len(peers)}")
|
||||
for peer in peers[:5]: # Show first 5
|
||||
peer_status = "🟢" if peer.get("status") == "online" else "🔴"
|
||||
local_tag = " (local)" if peer.get("is_local") else ""
|
||||
print(f" {peer_status} {peer.get('name', 'unknown')}{local_tag}")
|
||||
print(f" {peer.get('address', '?')}")
|
||||
|
||||
if len(peers) > 5:
|
||||
print(f" ... and {len(peers) - 5} more")
|
||||
|
||||
except ImportError:
|
||||
print(" [httpx not installed - cannot query mesh API]")
|
||||
except Exception as e:
|
||||
print(f" [Error querying mesh: {e}]")
|
||||
|
||||
print("-" * 60)
|
||||
|
||||
def cmd_plugins(self, args: List[str] = None):
|
||||
"""List loaded plugins"""
|
||||
if not self.plugins:
|
||||
@ -487,10 +716,13 @@ Commands:
|
||||
print(f" Commands: {', '.join(plugin.commands)}")
|
||||
|
||||
def cmd_update(self, args: List[str] = None):
|
||||
"""Self-update from mesh"""
|
||||
"""Self-update from mesh or check for updates"""
|
||||
print("🔄 Checking for updates...")
|
||||
|
||||
# Try to fetch latest version from mesh
|
||||
update_source = None
|
||||
remote_version = "0.0.0"
|
||||
|
||||
# Try to fetch latest version from mesh devices
|
||||
for dev in self.devices.values():
|
||||
if not dev.mesh_enabled:
|
||||
continue
|
||||
@ -500,18 +732,75 @@ Commands:
|
||||
r = httpx.get(f"http://{dev.host}:7331/api/catalog/console", timeout=5)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
remote_version = data.get("version", "0.0.0")
|
||||
if remote_version > CONSOLE_VERSION:
|
||||
print(f" New version available: {remote_version}")
|
||||
# Download and update
|
||||
# ... implementation
|
||||
else:
|
||||
print(f" Already up to date: {CONSOLE_VERSION}")
|
||||
return
|
||||
ver = data.get("version", "0.0.0")
|
||||
if ver > remote_version:
|
||||
remote_version = ver
|
||||
update_source = dev
|
||||
except:
|
||||
continue
|
||||
|
||||
print(" No updates found or mesh unavailable.")
|
||||
if not update_source:
|
||||
print(" No mesh sources available.")
|
||||
return
|
||||
|
||||
if remote_version <= CONSOLE_VERSION:
|
||||
print(f" Already up to date: v{CONSOLE_VERSION}")
|
||||
return
|
||||
|
||||
print(f" New version available: v{remote_version} (current: v{CONSOLE_VERSION})")
|
||||
print(f" Source: {update_source.name} ({update_source.host})")
|
||||
|
||||
# Ask for confirmation
|
||||
confirm = input(" Download and install? [y/N]: ").strip().lower()
|
||||
if confirm != 'y':
|
||||
print(" Update cancelled.")
|
||||
return
|
||||
|
||||
# Download update from mesh device via SSH
|
||||
print(f" 📥 Downloading from {update_source.name}...")
|
||||
try:
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Download console files from device
|
||||
files_to_update = [
|
||||
"/usr/lib/secubox-console/secubox_console.py",
|
||||
"/usr/lib/secubox-console/secubox_frontend.py"
|
||||
]
|
||||
|
||||
for remote_path in files_to_update:
|
||||
local_path = Path(tmpdir) / Path(remote_path).name
|
||||
stdout, stderr, code = self.ssh_exec(update_source, f"cat {remote_path}")
|
||||
if code == 0 and stdout:
|
||||
local_path.write_text(stdout)
|
||||
print(f" Downloaded: {remote_path}")
|
||||
|
||||
# Verify downloads
|
||||
downloaded = list(Path(tmpdir).glob("*.py"))
|
||||
if not downloaded:
|
||||
print(" ❌ No files downloaded.")
|
||||
return
|
||||
|
||||
# Install update
|
||||
print(" 📦 Installing update...")
|
||||
install_dir = Path(__file__).parent
|
||||
|
||||
for py_file in downloaded:
|
||||
target = install_dir / py_file.name
|
||||
# Backup current file
|
||||
if target.exists():
|
||||
backup = target.with_suffix(".py.bak")
|
||||
shutil.copy2(target, backup)
|
||||
# Install new file
|
||||
shutil.copy2(py_file, target)
|
||||
print(f" Installed: {target.name}")
|
||||
|
||||
print(f"\n ✅ Updated to v{remote_version}!")
|
||||
print(" Restart the console to use the new version.")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ Update failed: {e}")
|
||||
|
||||
def cmd_dashboard(self, args: List[str] = None):
|
||||
"""Live dashboard TUI"""
|
||||
|
||||
@ -36,10 +36,11 @@ from concurrent.futures import ThreadPoolExecutor
|
||||
# ============================================================================
|
||||
try:
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
|
||||
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:
|
||||
@ -71,11 +72,13 @@ except ImportError:
|
||||
# ============================================================================
|
||||
# Configuration
|
||||
# ============================================================================
|
||||
VERSION = "1.0.0"
|
||||
VERSION = "1.1.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
|
||||
@ -135,11 +138,78 @@ class DeviceManager:
|
||||
self._ssh_cache = {}
|
||||
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:
|
||||
@ -318,12 +388,222 @@ class DeviceManager:
|
||||
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 {}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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"""
|
||||
|
||||
@ -432,8 +712,10 @@ Services: {services}"""
|
||||
("b", "backup", "Backup"),
|
||||
("s", "sync", "Sync"),
|
||||
("a", "add_device", "Add Device"),
|
||||
("d", "dashboard", "Dashboard"),
|
||||
("l", "logs", "Logs"),
|
||||
("f", "discover", "Find Devices"),
|
||||
("d", "delete_device", "Delete Device"),
|
||||
("c", "connect_ssh", "SSH Connect"),
|
||||
("m", "announce", "Announce to Mesh"),
|
||||
]
|
||||
|
||||
def __init__(self, manager: DeviceManager):
|
||||
@ -462,9 +744,10 @@ Services: {services}"""
|
||||
|
||||
with Horizontal(id="actions"):
|
||||
yield Button("🔄 Refresh", id="btn-refresh", variant="primary")
|
||||
yield Button("💾 Backup", id="btn-backup")
|
||||
yield Button("🔗 Sync", id="btn-sync")
|
||||
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()
|
||||
@ -481,6 +764,7 @@ Services: {services}"""
|
||||
self._update_device_list()
|
||||
self._update_dashboard()
|
||||
self._update_alerts()
|
||||
self._update_mesh()
|
||||
|
||||
def _update_device_list(self) -> None:
|
||||
"""Update sidebar device list"""
|
||||
@ -550,11 +834,71 @@ Services: {services}"""
|
||||
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 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":
|
||||
@ -562,7 +906,7 @@ Services: {services}"""
|
||||
elif btn_id == "btn-add":
|
||||
self.action_add_device()
|
||||
elif btn_id == "btn-ssh":
|
||||
self.action_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}")
|
||||
@ -590,17 +934,64 @@ Services: {services}"""
|
||||
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:
|
||||
# Would show input dialog in full implementation
|
||||
self.notify("Use CLI: secubox-frontend --add name host")
|
||||
"""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()
|
||||
|
||||
def action_ssh(self) -> None:
|
||||
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()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
Loading…
Reference in New Issue
Block a user