From 357f16bf932fc4a514983720f68f11e5eabe49f9 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Sat, 31 Jan 2026 06:46:31 +0100 Subject: [PATCH] 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 --- .../lib/secubox-console/secubox_console.py | 371 ++++++++++++++-- .../lib/secubox-console/secubox_frontend.py | 411 +++++++++++++++++- 2 files changed, 731 insertions(+), 51 deletions(-) diff --git a/package/secubox/secubox-console/root/usr/lib/secubox-console/secubox_console.py b/package/secubox/secubox-console/root/usr/lib/secubox-console/secubox_console.py index f40962b8..bc915cd2 100644 --- a/package/secubox/secubox-console/root/usr/lib/secubox-console/secubox_console.py +++ b/package/secubox/secubox-console/root/usr/lib/secubox-console/secubox_console.py @@ -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 Interactive SSH to device exec Execute command on device snapshot 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 [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""" diff --git a/package/secubox/secubox-console/root/usr/lib/secubox-console/secubox_frontend.py b/package/secubox/secubox-console/root/usr/lib/secubox-console/secubox_frontend.py index a9796aee..a6e4f7ea 100644 --- a/package/secubox/secubox-console/root/usr/lib/secubox-console/secubox_frontend.py +++ b/package/secubox/secubox-console/root/usr/lib/secubox-console/secubox_frontend.py @@ -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() # ============================================================================