From 7a0d2264430692705d53b0017fe2341078d8678a Mon Sep 17 00:00:00 2001 From: Nose Date: Wed, 14 Jan 2026 01:59:30 -0800 Subject: [PATCH] f --- API/zerotier.py | 72 +++++++-------- Store/ZeroTier.py | 4 +- TUI.py | 91 +++++++++++++++++++ TUI/modalscreen/config_modal.py | 150 ++++++++++++++++++++++---------- 4 files changed, 236 insertions(+), 81 deletions(-) diff --git a/API/zerotier.py b/API/zerotier.py index 3686455..1886523 100644 --- a/API/zerotier.py +++ b/API/zerotier.py @@ -505,7 +505,7 @@ def discover_services_on_network( raise ValueError("network_id required") ports = list(ports or [999]) - paths = list(paths or ["/health", "/api_version", "/api_version/", "/session_key"]) + paths = list(paths or ["/health", "/api_version"]) addresses = get_assigned_addresses(net) @@ -515,8 +515,9 @@ def discover_services_on_network( # Look for online members with IP assignments if m.get("online") and m.get("config", {}).get("ipAssignments"): for ip in m["config"]["ipAssignments"]: - if ip not in addresses: - addresses.append(ip) + addr = str(ip).split("/")[0] + if addr not in addresses: + addresses.append(addr) probes: List[ZeroTierServiceProbe] = [] @@ -524,38 +525,41 @@ def discover_services_on_network( host = str(addr or "").strip() if not host: continue - # Try both http and https schemes - for port in ports: - for path in paths: - for scheme in ("http", "https"): - url = f"{scheme}://{host}:{port}{path}" - ok, code, payload = _probe_url(url, timeout=timeout, accept_json=accept_json) - if ok: - hint = None - # Heuristics: hydrus exposes /api_version with a JSON payload - try: - if isinstance(payload, dict) and payload.get("api_version"): - hint = "hydrus" - except Exception: - pass - try: - if isinstance(payload, dict) and payload.get("status"): - hint = hint or "remote_storage" - except Exception: - pass + + # Performance optimization: if we have many addresses, skip those clearly not on our ZT subnet + # (Though fetch_central_members already filters for this network) - probes.append(ZeroTierServiceProbe( - address=host, - port=int(port), - path=path, - url=url, - ok=True, - status_code=code, - payload=payload, - service_hint=hint, - )) - # stop probing other schemes for this host/port/path - break + for port in ports: + # Try HTTP first as it's the common case for local storage + for scheme in ("http", "https"): + # Fast probe of just the first path + path = paths[0] + url = f"{scheme}://{host}:{port}{path}" + ok, code, payload = _probe_url(url, timeout=timeout, accept_json=accept_json) + if ok: + hint = None + try: + # remote_storage_server returns {"status": "ok", ...} + if isinstance(payload, dict) and payload.get("status"): + hint = "remote_storage" + # hydrus returns {"api_version": ...} + if isinstance(payload, dict) and payload.get("api_version"): + hint = "hydrus" + except Exception: + pass + + probes.append(ZeroTierServiceProbe( + address=host, + port=int(port), + path=path, + url=url, + ok=True, + status_code=code, + payload=payload, + service_hint=hint, + )) + # Stop probing other schemes/paths for this host/port + break return probes diff --git a/Store/ZeroTier.py b/Store/ZeroTier.py index e29a05f..3ba4c14 100644 --- a/Store/ZeroTier.py +++ b/Store/ZeroTier.py @@ -37,10 +37,10 @@ class ZeroTier(Store): return [ {"key": "NAME", "label": "Store Name", "default": "", "required": True}, {"key": "NETWORK_ID", "label": "ZeroTier Network ID", "default": "", "required": True}, - {"key": "SERVICE", "label": "Service Type (remote|hydrus)", "default": "remote", "required": True}, + {"key": "HOST", "label": "Peer address (IP)", "default": "", "required": True}, {"key": "PORT", "label": "Service Port", "default": "999", "required": False}, + {"key": "SERVICE", "label": "Service Type (remote|hydrus)", "default": "remote", "required": False}, {"key": "API_KEY", "label": "API Key (optional)", "default": "", "required": False, "secret": True}, - {"key": "HOST", "label": "Preferred peer host (optional)", "default": "", "required": False}, {"key": "TIMEOUT", "label": "Request timeout (s)", "default": "5", "required": False}, ] diff --git a/TUI.py b/TUI.py index 7e61884..5aa9a02 100644 --- a/TUI.py +++ b/TUI.py @@ -5,6 +5,8 @@ from __future__ import annotations import json import re import sys +import subprocess +import asyncio from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple @@ -438,6 +440,8 @@ class PipelineHubApp(App): self._pipeline_running = False self._pipeline_worker: Any = None self._selected_row_index: int = 0 + self._zt_server_proc: Optional[subprocess.Popen] = None + self._zt_server_last_config: Optional[str] = None # ------------------------------------------------------------------ # Layout @@ -499,6 +503,93 @@ class PipelineHubApp(App): if self.worker_table: self.worker_table.add_columns("ID", "Type", "Status", "Details") + self.set_interval(5.0, self._manage_zerotier_server) + + def on_unmount(self) -> None: + if hasattr(self, "_zt_server_proc") and self._zt_server_proc: + try: + self._zt_server_proc.terminate() + self._zt_server_proc.wait(timeout=2) + except Exception: + try: + self._zt_server_proc.kill() + except Exception: + pass + + async def _manage_zerotier_server(self) -> None: + """Background task to start/stop the ZeroTier storage server based on config.""" + try: + cfg = load_config() + except Exception: + return + + zt_conf = cfg.get("networking", {}).get("zerotier", {}) + serve_target = zt_conf.get("serve") + port = zt_conf.get("port") or 999 + api_key = zt_conf.get("api_key") + + # Config hash to detect changes + config_id = f"{serve_target}|{port}|{api_key}" + if config_id == self._zt_server_last_config: + # Check if proc is still alive + if self._zt_server_proc: + if self._zt_server_proc.poll() is not None: + # Crashed? + self._zt_server_proc = None + self.notify("ZeroTier Host Server stopped unexpectedly", severity="warning") + else: + return + elif not serve_target: + return + + # Stop existing if config changed + if self._zt_server_proc: + self.notify("Stopping ZeroTier Host Server (config change)...") + self._zt_server_proc.terminate() + self._zt_server_proc.wait() + self._zt_server_proc = None + + self._zt_server_last_config = config_id + + if not serve_target: + return + + # Resolve path + storage_path = None + folders = cfg.get("store", {}).get("folder", {}) + for name, block in folders.items(): + if name.lower() == serve_target.lower(): + storage_path = block.get("path") or block.get("PATH") + break + + if not storage_path: + # Fallback to direct path + storage_path = serve_target + + if not Path(storage_path).exists(): + return + + # Start server + cmd = [sys.executable, str(REPO_ROOT / "scripts" / "remote_storage_server.py"), + "--storage-path", str(storage_path), + "--port", str(port)] + if api_key: + cmd += ["--api-key", str(api_key)] + + try: + # Run in a way that doesn't create a visible window on Windows if possible, + # though for scripts it's fine. + self._zt_server_proc = subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + cwd=str(REPO_ROOT) + ) + self.notify(f"ZeroTier Host: Sharing '{serve_target}' on port {port}") + except Exception as e: + self.notify(f"Failed to start ZeroTier server: {e}", severity="error") + self._zt_server_proc = None + # Initialize the store choices cache at startup (filters disabled stores) try: from cmdlet._shared import SharedArgs diff --git a/TUI/modalscreen/config_modal.py b/TUI/modalscreen/config_modal.py index 2b971cc..6ce397c 100644 --- a/TUI/modalscreen/config_modal.py +++ b/TUI/modalscreen/config_modal.py @@ -681,60 +681,120 @@ class ConfigModal(ModalScreen): if not stype: return if stype == "zerotier": - # Push a discovery screen + # Push a discovery wizard from TUI.modalscreen.selection_modal import SelectionModal from API import zerotier as zt - # Find all joined networks + # 1. Choose Network joined = zt.list_networks() if not joined: self.notify("Error: Join a ZeroTier network first in 'Networking'", severity="error") return - self.notify("Scanning ZeroTier networks for peers...") - - all_peers = [] - central_token = self.config_data.get("networking", {}).get("zerotier", {}).get("api_key") - - for net in joined: - probes = zt.discover_services_on_network(net.id, ports=[999, 45869, 5000], api_token=central_token) - for p in probes: - label = f"{p.service_hint or 'service'} @ {p.address}:{p.port} ({net.name})" - all_peers.append((label, p, net.id)) - - if not all_peers: - self.notify("No services found on port 999. Use manual setup.", severity="warning") - else: - options = [p[0] for p in all_peers] - - def on_peer_selected(choice: str): - if not choice: return - # Find the probe data - match = next((p for p in all_peers if p[0] == choice), None) - if not match: return - label, probe, net_id = match - - # Create a specific name based on host - safe_host = str(probe.address).replace(".", "_") - new_name = f"zt_{safe_host}" - - if "store" not in self.config_data: self.config_data["store"] = {} - store_cfg = self.config_data["store"].setdefault("zerotier", {}) - - new_config = { - "NAME": new_name, - "NETWORK_ID": net_id, - "HOST": probe.address, - "PORT": probe.port, - "SERVICE": "hydrus" if probe.service_hint == "hydrus" else "remote" - } - store_cfg[new_name] = new_config - self.editing_item_type = "store-zerotier" - self.editing_item_name = new_name - self.refresh_view() + net_options = [f"{n.name or 'Network'} ({n.id})" for n in joined] - self.app.push_screen(SelectionModal("Discovered ZeroTier Services", options), callback=on_peer_selected) - return + def on_net_selected(net_choice: str): + if not net_choice: return + net_id = net_choice.split("(")[-1].rstrip(")") + + # 2. Host or Connect? + def on_mode_selected(mode: str): + if not mode: return + + if mode == "Host (Share a local store)": + # 3a. Select Local Store to Share + local_stores = [] + for s_type, s_data in self.config_data.get("store", {}).items(): + if s_type == "zerotier": continue + for s_name in s_data.keys(): + local_stores.append(f"{s_name} ({s_type})") + + if not local_stores: + self.notify("No local stores available to share.", severity="error") + return + + def on_share_selected(share_choice: str): + if not share_choice: return + share_name = share_choice.split(" (")[0] + + # Update networking config + if "networking" not in self.config_data: self.config_data["networking"] = {} + zt_net = self.config_data["networking"].setdefault("zerotier", {}) + zt_net["serve"] = share_name + zt_net["network_id"] = net_id + if not zt_net.get("port"): + zt_net["port"] = "999" + + self.refresh_view() + self.notify(f"ZeroTier configured to share '{share_name}' on network {net_id}") + self.notify("CLICK 'SAVE' to start the server.") + + self.app.push_screen(SelectionModal("Select Local Store to Share", local_stores), callback=on_share_selected) + + else: + # 3b. Connect to Remote Peer + # Discover Peers (Port 999 only) + central_token = self.config_data.get("networking", {}).get("zerotier", {}).get("api_key") + self.notify(f"Scanning Network {net_id} for peers...") + + try: + probes = zt.discover_services_on_network(net_id, ports=[999], api_token=central_token) + except Exception as e: + self.notify(f"Discovery error: {e}", severity="error") + return + + if not probes: + self.notify("No peers found on port 999. Use manual setup.", severity="warning") + # Create empty template + new_name = f"zt_remote" + if "store" not in self.config_data: self.config_data["store"] = {} + store_cfg = self.config_data["store"].setdefault("zerotier", {}) + store_cfg[new_name] = { + "NAME": new_name, + "NETWORK_ID": net_id, + "HOST": "", + "PORT": "999", + "SERVICE": "remote" + } + self.editing_item_type = "store-zerotier" + self.editing_item_name = new_name + self.refresh_view() + return + + peer_options = [f"{p.address} ({p.service_hint or 'service'})" for p in probes] + + def on_peer_selected(peer_choice: str): + if not peer_choice: return + p_addr = peer_choice.split(" ")[0] + match = next((p for p in probes if p.address == p_addr), None) + + new_name = f"zt_{p_addr.replace('.', '_')}" + if "store" not in self.config_data: self.config_data["store"] = {} + store_cfg = self.config_data["store"].setdefault("zerotier", {}) + + new_config = { + "NAME": new_name, + "NETWORK_ID": net_id, + "HOST": p_addr, + "PORT": "999", + "SERVICE": "remote" + } + if match and match.service_hint == "hydrus": + new_config["SERVICE"] = "hydrus" + new_config["PORT"] = "45869" + + store_cfg[new_name] = new_config + self.editing_item_type = "store-zerotier" + self.editing_item_name = new_name + self.refresh_view() + self.notify(f"Configured ZeroTier store '{new_name}'") + + self.app.push_screen(SelectionModal("Select Remote Peer", peer_options), callback=on_peer_selected) + + self.app.push_screen(SelectionModal("ZeroTier Mode", ["Host (Share a local store)", "Connect (Use a remote store)"]), callback=on_mode_selected) + + self.app.push_screen(SelectionModal("Select ZeroTier Network", net_options), callback=on_net_selected) + return new_name = f"new_{stype}" if "store" not in self.config_data: