f
This commit is contained in:
2
CLI.py
2
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
|
||||
|
||||
136
SYS/background_services.py
Normal file
136
SYS/background_services.py
Normal file
@@ -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
|
||||
87
TUI.py
87
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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
151
cmdnat/zerotier.py
Normal file
151
cmdnat/zerotier.py
Normal file
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user