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") raise ValueError("network_id required")
ports = list(ports or [999]) 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) addresses = get_assigned_addresses(net)
@@ -515,8 +515,9 @@ def discover_services_on_network(
# Look for online members with IP assignments # Look for online members with IP assignments
if m.get("online") and m.get("config", {}).get("ipAssignments"): if m.get("online") and m.get("config", {}).get("ipAssignments"):
for ip in m["config"]["ipAssignments"]: for ip in m["config"]["ipAssignments"]:
if ip not in addresses: addr = str(ip).split("/")[0]
addresses.append(ip) if addr not in addresses:
addresses.append(addr)
probes: List[ZeroTierServiceProbe] = [] probes: List[ZeroTierServiceProbe] = []
@@ -524,25 +525,28 @@ def discover_services_on_network(
host = str(addr or "").strip() host = str(addr or "").strip()
if not host: if not host:
continue continue
# Try both http and https schemes
# Performance optimization: if we have many addresses, skip those clearly not on our ZT subnet
# (Though fetch_central_members already filters for this network)
for port in ports: for port in ports:
for path in paths: # Try HTTP first as it's the common case for local storage
for scheme in ("http", "https"): for scheme in ("http", "https"):
# Fast probe of just the first path
path = paths[0]
url = f"{scheme}://{host}:{port}{path}" url = f"{scheme}://{host}:{port}{path}"
ok, code, payload = _probe_url(url, timeout=timeout, accept_json=accept_json) ok, code, payload = _probe_url(url, timeout=timeout, accept_json=accept_json)
if ok: if ok:
hint = None hint = None
# Heuristics: hydrus exposes /api_version with a JSON payload
try: 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"): if isinstance(payload, dict) and payload.get("api_version"):
hint = "hydrus" hint = "hydrus"
except Exception: except Exception:
pass pass
try:
if isinstance(payload, dict) and payload.get("status"):
hint = hint or "remote_storage"
except Exception:
pass
probes.append(ZeroTierServiceProbe( probes.append(ZeroTierServiceProbe(
address=host, address=host,
@@ -554,7 +558,7 @@ def discover_services_on_network(
payload=payload, payload=payload,
service_hint=hint, service_hint=hint,
)) ))
# stop probing other schemes for this host/port/path # Stop probing other schemes/paths for this host/port
break break
return probes return probes

View File

@@ -37,10 +37,10 @@ class ZeroTier(Store):
return [ return [
{"key": "NAME", "label": "Store Name", "default": "", "required": True}, {"key": "NAME", "label": "Store Name", "default": "", "required": True},
{"key": "NETWORK_ID", "label": "ZeroTier Network ID", "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": "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": "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}, {"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 json
import re import re
import sys import sys
import subprocess
import asyncio
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
@@ -438,6 +440,8 @@ class PipelineHubApp(App):
self._pipeline_running = False self._pipeline_running = False
self._pipeline_worker: Any = None self._pipeline_worker: Any = None
self._selected_row_index: int = 0 self._selected_row_index: int = 0
self._zt_server_proc: Optional[subprocess.Popen] = None
self._zt_server_last_config: Optional[str] = None
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Layout # Layout
@@ -499,6 +503,93 @@ class PipelineHubApp(App):
if self.worker_table: if self.worker_table:
self.worker_table.add_columns("ID", "Type", "Status", "Details") 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) # Initialize the store choices cache at startup (filters disabled stores)
try: try:
from cmdlet._shared import SharedArgs from cmdlet._shared import SharedArgs

View File

@@ -681,59 +681,119 @@ class ConfigModal(ModalScreen):
if not stype: return if not stype: return
if stype == "zerotier": if stype == "zerotier":
# Push a discovery screen # Push a discovery wizard
from TUI.modalscreen.selection_modal import SelectionModal from TUI.modalscreen.selection_modal import SelectionModal
from API import zerotier as zt from API import zerotier as zt
# Find all joined networks # 1. Choose Network
joined = zt.list_networks() joined = zt.list_networks()
if not joined: if not joined:
self.notify("Error: Join a ZeroTier network first in 'Networking'", severity="error") self.notify("Error: Join a ZeroTier network first in 'Networking'", severity="error")
return return
self.notify("Scanning ZeroTier networks for peers...") net_options = [f"{n.name or 'Network'} ({n.id})" for n in joined]
all_peers = [] def on_net_selected(net_choice: str):
central_token = self.config_data.get("networking", {}).get("zerotier", {}).get("api_key") if not net_choice: return
net_id = net_choice.split("(")[-1].rstrip(")")
for net in joined: # 2. Host or Connect?
probes = zt.discover_services_on_network(net.id, ports=[999, 45869, 5000], api_token=central_token) def on_mode_selected(mode: str):
for p in probes: if not mode: return
label = f"{p.service_hint or 'service'} @ {p.address}:{p.port} ({net.name})"
all_peers.append((label, p, net.id)) 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)
if not all_peers:
self.notify("No services found on port 999. Use manual setup.", severity="warning")
else: else:
options = [p[0] for p in all_peers] # 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...")
def on_peer_selected(choice: str): try:
if not choice: return probes = zt.discover_services_on_network(net_id, ports=[999], api_token=central_token)
# Find the probe data except Exception as e:
match = next((p for p in all_peers if p[0] == choice), None) self.notify(f"Discovery error: {e}", severity="error")
if not match: return return
label, probe, net_id = match
# Create a specific name based on host if not probes:
safe_host = str(probe.address).replace(".", "_") self.notify("No peers found on port 999. Use manual setup.", severity="warning")
new_name = f"zt_{safe_host}" # 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"] = {} if "store" not in self.config_data: self.config_data["store"] = {}
store_cfg = self.config_data["store"].setdefault("zerotier", {}) store_cfg = self.config_data["store"].setdefault("zerotier", {})
new_config = { new_config = {
"NAME": new_name, "NAME": new_name,
"NETWORK_ID": net_id, "NETWORK_ID": net_id,
"HOST": probe.address, "HOST": p_addr,
"PORT": probe.port, "PORT": "999",
"SERVICE": "hydrus" if probe.service_hint == "hydrus" else "remote" "SERVICE": "remote"
} }
if match and match.service_hint == "hydrus":
new_config["SERVICE"] = "hydrus"
new_config["PORT"] = "45869"
store_cfg[new_name] = new_config store_cfg[new_name] = new_config
self.editing_item_type = "store-zerotier" self.editing_item_type = "store-zerotier"
self.editing_item_name = new_name self.editing_item_name = new_name
self.refresh_view() self.refresh_view()
self.notify(f"Configured ZeroTier store '{new_name}'")
self.app.push_screen(SelectionModal("Discovered ZeroTier Services", options), callback=on_peer_selected) 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 return
new_name = f"new_{stype}" new_name = f"new_{stype}"