f
This commit is contained in:
@@ -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,38 +525,41 @@ 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
|
|
||||||
for port in ports:
|
# Performance optimization: if we have many addresses, skip those clearly not on our ZT subnet
|
||||||
for path in paths:
|
# (Though fetch_central_members already filters for this network)
|
||||||
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
|
|
||||||
|
|
||||||
probes.append(ZeroTierServiceProbe(
|
for port in ports:
|
||||||
address=host,
|
# Try HTTP first as it's the common case for local storage
|
||||||
port=int(port),
|
for scheme in ("http", "https"):
|
||||||
path=path,
|
# Fast probe of just the first path
|
||||||
url=url,
|
path = paths[0]
|
||||||
ok=True,
|
url = f"{scheme}://{host}:{port}{path}"
|
||||||
status_code=code,
|
ok, code, payload = _probe_url(url, timeout=timeout, accept_json=accept_json)
|
||||||
payload=payload,
|
if ok:
|
||||||
service_hint=hint,
|
hint = None
|
||||||
))
|
try:
|
||||||
# stop probing other schemes for this host/port/path
|
# remote_storage_server returns {"status": "ok", ...}
|
||||||
break
|
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
|
return probes
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
91
TUI.py
@@ -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
|
||||||
|
|||||||
@@ -681,60 +681,120 @@ 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 = []
|
|
||||||
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()
|
|
||||||
|
|
||||||
self.app.push_screen(SelectionModal("Discovered ZeroTier Services", options), callback=on_peer_selected)
|
def on_net_selected(net_choice: str):
|
||||||
return
|
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}"
|
new_name = f"new_{stype}"
|
||||||
if "store" not in self.config_data:
|
if "store" not in self.config_data:
|
||||||
|
|||||||
Reference in New Issue
Block a user