This commit is contained in:
2026-01-14 01:59:30 -08:00
parent e27e13b64c
commit 7a0d226443
4 changed files with 236 additions and 81 deletions

View File

@@ -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

View File

@@ -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},
]

91
TUI.py
View File

@@ -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

View File

@@ -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: