From 883f270f908d7d7c15a4d09e994810df9bcf1d1f Mon Sep 17 00:00:00 2001 From: Nose Date: Wed, 14 Jan 2026 02:39:31 -0800 Subject: [PATCH] f --- CLI.py | 2 + SYS/background_services.py | 136 ++++++++++++++++++++++++++++ TUI.py | 87 ++---------------- TUI/modalscreen/config_modal.py | 29 ++++-- cmdnat/status.py | 23 +++++ cmdnat/zerotier.py | 151 ++++++++++++++++++++++++++++++++ 6 files changed, 337 insertions(+), 91 deletions(-) create mode 100644 SYS/background_services.py create mode 100644 cmdnat/zerotier.py diff --git a/CLI.py b/CLI.py index 9530a50..b9031d9 100644 --- a/CLI.py +++ b/CLI.py @@ -62,6 +62,7 @@ _install_rich_traceback(show_locals=False) from SYS.background_notifier import ensure_background_notifier from SYS.logger import debug, set_debug from SYS.worker_manager import WorkerManager +from SYS.background_services import ensure_zerotier_server_running, stop_zerotier_server from SYS.cmdlet_catalog import ( get_cmdlet_arg_choices, @@ -4586,6 +4587,7 @@ class MedeiaCLI: self.build_app()() def run_repl(self) -> None: + ensure_zerotier_server_running() # console = Console(width=100) # Valid Rich rainbow colors diff --git a/SYS/background_services.py b/SYS/background_services.py new file mode 100644 index 0000000..d254e3f --- /dev/null +++ b/SYS/background_services.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import os +import sys +import subprocess +from pathlib import Path +from typing import Optional + +from SYS.config import load_config +from SYS.logger import debug, log + +_zt_server_proc: Optional[subprocess.Popen] = None +_zt_server_last_config: Optional[str] = None + +def ensure_zerotier_server_running() -> None: + """Check config and ensure the ZeroTier storage server is running if needed.""" + global _zt_server_proc, _zt_server_last_config + + try: + # Load config from the project root (where config.conf typically lives) + repo_root = Path(__file__).resolve().parent.parent + cfg = load_config(repo_root) + 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}" + + # Check if proc is still alive + if _zt_server_proc: + if _zt_server_proc.poll() is not None: + # Process died + debug("ZeroTier background server died. Restarting...") + _zt_server_proc = None + elif config_id == _zt_server_last_config: + # Already running with correct config + return + + # If config changed and we have a proc, stop it + if _zt_server_proc and config_id != _zt_server_last_config: + debug("ZeroTier server config changed. Stopping old process...") + try: + _zt_server_proc.terminate() + _zt_server_proc.wait(timeout=2) + except Exception: + try: + _zt_server_proc.kill() + except Exception: + pass + _zt_server_proc = None + + _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 storage_path or not Path(storage_path).exists(): + debug(f"ZeroTier host target '{serve_target}' not found at {storage_path}. Cannot start server.") + return + + repo_root = Path(__file__).resolve().parent.parent + server_script = repo_root / "scripts" / "remote_storage_server.py" + + if not server_script.exists(): + debug(f"ZeroTier server script not found at {server_script}") + return + + # Use the same python executable that is currently running + # On Windows, explicitly prefer the .venv python if it exists + python_exe = sys.executable + if sys.platform == "win32": + venv_py = repo_root / ".venv" / "Scripts" / "python.exe" + if venv_py.exists(): + python_exe = str(venv_py) + + cmd = [python_exe, str(server_script), + "--storage-path", str(storage_path), + "--port", str(port)] + if api_key: + cmd += ["--api-key", str(api_key)] + + try: + debug(f"Starting ZeroTier storage server: {cmd}") + # Capture errors to a log file instead of DEVNULL + log_file = repo_root / "zt_server_error.log" + with open(log_file, "a") as f: + f.write(f"\n--- Starting server at {__import__('datetime').datetime.now()} ---\n") + f.write(f"Command: {' '.join(cmd)}\n") + f.write(f"CWD: {repo_root}\n") + f.write(f"Python: {python_exe}\n") + + err_f = open(log_file, "a") + # On Windows, CREATE_NO_WINDOW = 0x08000000 ensures no console pops up + import subprocess + _zt_server_proc = subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=err_f, + cwd=str(repo_root), + creationflags=0x08000000 if sys.platform == "win32" else 0 + ) + log(f"ZeroTier background server started on port {port} (sharing {serve_target})") + except Exception as e: + debug(f"Failed to start ZeroTier server: {e}") + _zt_server_proc = None + +def stop_zerotier_server() -> None: + """Stop the background server if it is running.""" + global _zt_server_proc + if _zt_server_proc: + try: + _zt_server_proc.terminate() + _zt_server_proc.wait(timeout=2) + except Exception: + try: + _zt_server_proc.kill() + except Exception: + pass + _zt_server_proc = None diff --git a/TUI.py b/TUI.py index 5aa9a02..6494285 100644 --- a/TUI.py +++ b/TUI.py @@ -48,6 +48,7 @@ from SYS.cmdlet_catalog import ensure_registry_loaded, list_cmdlet_names # type from SYS.cli_syntax import validate_pipeline_text # type: ignore # noqa: E402 from TUI.pipeline_runner import PipelineRunner # type: ignore # noqa: E402 +from SYS.background_services import ensure_zerotier_server_running, stop_zerotier_server def _dedup_preserve_order(items: List[str]) -> List[str]: @@ -503,92 +504,14 @@ 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) + self.set_interval(5.0, ensure_zerotier_server_running) 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 + stop_zerotier_server() 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 + # Method removed - logic moved to SYS.background_services + pass # Initialize the store choices cache at startup (filters disabled stores) try: diff --git a/TUI/modalscreen/config_modal.py b/TUI/modalscreen/config_modal.py index 6ce397c..925223c 100644 --- a/TUI/modalscreen/config_modal.py +++ b/TUI/modalscreen/config_modal.py @@ -725,9 +725,15 @@ class ConfigModal(ModalScreen): if not zt_net.get("port"): zt_net["port"] = "999" + try: + self.save_all() + from SYS.background_services import ensure_zerotier_server_running + ensure_zerotier_server_running() + self.notify(f"ZeroTier auto-saved: Sharing '{share_name}' on network {net_id}") + except Exception as e: + self.notify(f"Auto-save failed: {e}", severity="error") + 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) @@ -756,12 +762,11 @@ class ConfigModal(ModalScreen): "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] + try: + self.save_all() + self.notify(f"ZeroTier auto-saved: Manual template created.") + except Exception as e: + self.notify(f"Auto-save failed: {e}", severity="error") def on_peer_selected(peer_choice: str): if not peer_choice: return @@ -784,10 +789,16 @@ class ConfigModal(ModalScreen): new_config["PORT"] = "45869" store_cfg[new_name] = new_config + + try: + self.save_all() + self.notify(f"ZeroTier auto-saved: Store '{new_name}' added.") + except Exception as e: + self.notify(f"Auto-save failed: {e}", severity="error") + 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) diff --git a/cmdnat/status.py b/cmdnat/status.py index e98ebac..3b46549 100644 --- a/cmdnat/status.py +++ b/cmdnat/status.py @@ -238,6 +238,29 @@ def _run(result: Any, args: List[str], config: Dict[str, Any]) -> int: _add_startup_check(startup_table, "FOUND" if cf else "MISSING", "Cookies", detail=str(cf) if cf else "Not found") except Exception: pass + # ZeroTier Hosting + zt_conf = config.get("networking", {}).get("zerotier", {}) + if zt_conf.get("serve"): + from SYS.background_services import ensure_zerotier_server_running + ensure_zerotier_server_running() + + serve_target = zt_conf.get("serve") + port = zt_conf.get("port") or 999 + status = "OFFLINE" + detail = f"Sharing: {serve_target} on port {port}" + try: + from API.HTTP import HTTPClient + # Probing 127.0.0.1 is more reliable on Windows than localhost + with HTTPClient(timeout=1.0, retries=0) as client: + resp = client.get(f"http://127.0.0.1:{port}/health") + if resp.status_code == 200: + status = "ONLINE" + payload = resp.json() + detail += f" (Live: {payload.get('name', 'unknown')})" + except Exception: + pass + _add_startup_check(startup_table, status, "ZeroTier Host", detail=detail) + except Exception as exc: debug(f"Status check failed: {exc}") diff --git a/cmdnat/zerotier.py b/cmdnat/zerotier.py new file mode 100644 index 0000000..853bb85 --- /dev/null +++ b/cmdnat/zerotier.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import os +from typing import Any, Dict, List, Optional, Sequence, Tuple + +from cmdlet._shared import Cmdlet, CmdletArg +from SYS.result_table import ResultTable +from SYS.config import load_config +from API import zerotier as zt + +def exec(pipe: Sequence[Any], args: Sequence[str], options: Dict[str, Any]) -> ResultTable: + table = ResultTable(title="ZeroTier Status", max_columns=10) + + cfg = load_config() + + # 1. Local Node Status + node_id = "unknown" + try: + # Best effort to get local node ID + st = zt._run_cli_json("status") + if isinstance(st, dict): + node_id = st.get("address") or node_id + except: pass + + # 2. Hosting Status + zt_net = cfg.get("networking", {}).get("zerotier", {}) + serve_target = zt_net.get("serve") + if serve_target: + port = zt_net.get("port") or 999 + net_id = zt_net.get("network_id") or "all" + + status = "OFFLINE" + detail = "" + + # Try to find the local ZT address for this network + zt_addrs = [] + if net_id and net_id != "all": + zt_addrs = zt.get_assigned_addresses(net_id) + + # We probe localhost for hosting status, but show ZT IP in the table + display_addr = zt_addrs[0] if zt_addrs else "localhost" + + # Try probes + # Using 127.0.0.1 is often more reliable than 'localhost' on Windows + probe_targets = [f"http://127.0.0.1:{port}/health"] + if zt_addrs: + probe_targets.insert(0, f"http://{zt_addrs[0]}:{port}/health") + + from API.HTTP import HTTPClient + with HTTPClient(timeout=1.0, retries=0) as client: + for url in probe_targets: + try: + resp = client.get(url) + if resp.status_code == 200: + status = "ONLINE" + payload = resp.json() + detail = f"Serving {payload.get('name') or serve_target}" + break + else: + status = f"HTTP {resp.status_code}" + except Exception as exc: + if not detail: # Keep the first failure reason if all fail + detail = f"Probe failed: {exc}" + + if status == "OFFLINE" and not zt_addrs: + detail = "No ZeroTier IP assigned yet. Check 'zerotier-cli listnetworks'." + + row = table.add_row() + row.add_column("TYPE", "HOST") + row.add_column("NAME", serve_target) + row.add_column("ID", net_id) + row.add_column("ADDRESS", f"{display_addr}:{port}") + row.add_column("STATUS", status) + row.add_column("DETAIL", detail) + + # 3. Connections (Remote Stores) + zt_stores = cfg.get("store", {}).get("zerotier", {}) + if zt_stores: + for name, sconf in zt_stores.items(): + net_id = sconf.get("NETWORK_ID") or sconf.get("network_id") or "" + host = sconf.get("HOST") or sconf.get("host") or "" + port = sconf.get("PORT") or sconf.get("port") or 999 + svc = sconf.get("SERVICE") or sconf.get("service") or "remote" + + status = "probing..." + detail = "" + + if not host: + status = "MISCONFIGURED" + detail = "No host IP" + else: + try: + from API.HTTP import HTTPClient + with HTTPClient(timeout=2.0) as client: + # Paths depend on service type + path = "/api_version" if svc == "hydrus" else "/health" + resp = client.get(f"http://{host}:{port}{path}") + if resp.status_code == 200: + status = "ONLINE" + if svc == "remote": + p = resp.json() + detail = f"Remote store: {p.get('name', 'unknown')}" + else: + detail = "Hydrus API" + else: + status = f"HTTP {resp.status_code}" + except Exception as exc: + status = "OFFLINE" + detail = str(exc) + + row = table.add_row() + row.add_column("TYPE", "REMOTE") + row.add_column("NAME", name) + row.add_column("ID", net_id) + row.add_column("ADDRESS", f"{host}:{port}") + row.add_column("STATUS", status) + row.add_column("DETAIL", detail) + + # 4. Networking Networks (Raw ZT status) + try: + nets = zt.list_networks() + if not nets: + row = table.add_row() + row.add_column("TYPE", "INFO") + row.add_column("NAME", "ZeroTier CLI") + row.add_column("STATUS", "No networks found or CLI error") + row.add_column("DETAIL", f"CLI Path: {zt._get_cli_path() or 'Not found'}") + + for n in nets: + row = table.add_row() + row.add_column("TYPE", "NETWORK") + row.add_column("NAME", n.name) + row.add_column("ID", n.id) + row.add_column("ADDRESS", ", ".join(n.assigned_addresses)) + row.add_column("STATUS", n.status) + row.add_column("DETAIL", "") + except Exception as exc: + row = table.add_row() + row.add_column("TYPE", "ERROR") + row.add_column("NAME", "ZeroTier CLI") + row.add_column("STATUS", "EXCEPTION") + row.add_column("DETAIL", str(exc)) + + return table + +CMDLET = Cmdlet( + name=".zerotier", + summary="Check ZeroTier hosting and connection status", + usage=".zerotier", + exec=exec +)